summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-06-27 23:07:14 +0300
committerPaul Buetow <paul@buetow.org>2025-06-27 23:07:14 +0300
commit3654835ece8616c4aa65dc68a4ac7918c0d38d3c (patch)
tree3e17deab4f7732dffe1556b97ff12b2bb4b462e5
parentcfc9e9a45cbf517a833c1fbffda6ed5068d08454 (diff)
Fix multiple bugs and improve error handling
- Fix file handle leak in SetDebugLog by properly closing previous debug files - Capture and display stderr from all taskwarrior commands for better error messages - Handle browser launch errors with status bar notifications - Add validation for task IDs to prevent negative/zero IDs - Add mutual exclusion for editing modes to prevent UI state conflicts - Add bounds checking for array access in expandedCellView - Cache compiled regular expressions for search performance - Add CLAUDE.md file with project documentation for AI assistance 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
-rw-r--r--CLAUDE.md100
-rw-r--r--README.md2
-rw-r--r--internal/task/task.go89
-rw-r--r--internal/ui/table.go84
4 files changed, 259 insertions, 16 deletions
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..3514455
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,100 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Project Overview
+
+TaskSamurai is a fast Terminal User Interface (TUI) for Taskwarrior written in Go. It provides a keyboard-driven table interface for task management with extensive hotkey support and real-time integration with the Taskwarrior CLI.
+
+## Development Commands
+
+```bash
+# Build the application
+go-task
+
+# Run the application
+go-task run
+
+# Run all tests
+go-task test
+
+# Install to $GOPATH/bin
+go-task install
+
+# Run with debug logging
+./tasksamurai --debug-log debug.log
+
+# Run with custom browser command
+./tasksamurai --browser-cmd chromium
+
+# Run with disco mode (random theme changes)
+./tasksamurai --disco
+```
+
+## Architecture
+
+### Core Components
+
+1. **cmd/tasksamurai/main.go**: Entry point that initializes flags, creates the UI model, and starts the Bubble Tea program.
+
+2. **internal/task/**: Taskwarrior integration layer
+ - Executes `task` commands for all CRUD operations
+ - Parses JSON export format from Taskwarrior
+ - Handles task filtering, sorting, and statistics calculation
+ - All operations require the `task` command to be available in PATH
+
+3. **internal/ui/**: Terminal UI implementation using Bubble Tea framework
+ - Main model in `model.go` handles application state and message processing
+ - `table.go` manages the task table display
+ - `input.go` processes keyboard input and executes commands
+ - `theme.go` handles color themes and disco mode
+ - Search functionality with regex support in search methods
+
+4. **internal/atable/**: Custom table widget implementation
+ - Provides flexible table rendering for the task display
+ - Handles column widths, scrolling, and cell formatting
+
+### Key Design Patterns
+
+- **Message-Driven Architecture**: Uses Bubble Tea's message passing for all UI updates
+- **Command Pattern**: All Taskwarrior operations go through the `internal/task` package
+- **MVC-like Structure**: Clear separation between data (task), view (table), and control (input handling)
+
+### Integration Points
+
+- **Taskwarrior CLI**: All task operations execute `task` commands via `exec.Command`
+- **Terminal**: Uses ANSI escape sequences and terminal control through Bubble Tea
+- **External Browser**: Opens URLs via configurable browser command
+
+## Testing Approach
+
+Tests are located alongside implementation files (*_test.go pattern). Key test areas:
+
+- Task operations and JSON parsing in `internal/task/`
+- UI component behavior in `internal/ui/`
+- Tests create temporary directories for isolated Taskwarrior environments
+- Tests check for `task` command availability and skip if not present
+
+Run a single test file:
+```bash
+go test ./internal/task/task_test.go
+```
+
+Run tests for a specific package:
+```bash
+go test ./internal/ui/
+```
+
+## Important Implementation Notes
+
+1. **Taskwarrior Dependency**: The application requires Taskwarrior to be installed and available in PATH. All operations fail gracefully if `task` is not available.
+
+2. **State Management**: The UI state is managed through the main model in `internal/ui/model.go`. State updates happen through Bubble Tea messages.
+
+3. **Error Handling**: Errors from Taskwarrior commands are displayed in the status bar at the bottom of the UI.
+
+4. **Performance**: The table widget uses efficient rendering to handle large task lists. Only visible rows are rendered.
+
+5. **Configuration**: User preferences like browser command and debug logging are passed via command-line flags, not configuration files.
+
+6. **Hotkeys**: Extensive hotkey system defined in `internal/ui/input.go`. See README.md for complete reference. \ No newline at end of file
diff --git a/README.md b/README.md
index c95cdc6..92ccaa9 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@ Task Samurai is a fast terminal interface for [Taskwarrior](https://taskwarrior.
## Why does this exist?
-- I wanted to tinker with agentic coding. This project was entirely implemented using OpenAI Codex.
+- I wanted to tinker with agentic coding.
- I wanted a faster UI for Taskwarrior than other options like vit which is Python based.
- I wanted something built with Bubble Tea but never had time to deep dive into it.
diff --git a/internal/task/task.go b/internal/task/task.go
index b20eef0..be3b6ce 100644
--- a/internal/task/task.go
+++ b/internal/task/task.go
@@ -38,18 +38,27 @@ type Task struct {
}
var debugWriter io.Writer
+var debugFile *os.File // Track the file handle to close it properly
// SetDebugLog enables logging of executed commands to the given file.
// Passing an empty path disables logging.
func SetDebugLog(path string) error {
- if path == "" {
+ // Close existing debug file if open
+ if debugFile != nil {
+ debugFile.Close()
+ debugFile = nil
debugWriter = nil
+ }
+
+ if path == "" {
return nil
}
+
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
return err
}
+ debugFile = f
debugWriter = f
return nil
}
@@ -71,8 +80,7 @@ func Add(description string, tags []string) error {
// is passed as a separate command-line argument, allowing the caller to
// specify additional modifiers like due dates or tags.
func AddArgs(args []string) error {
- cmd := exec.Command("task", append([]string{"add"}, args...)...)
- return cmd.Run()
+ return run(append([]string{"add"}, args...)...)
}
// AddLine splits the given line into shell words and runs "task add" with the
@@ -94,8 +102,16 @@ func AddLine(line string) error {
func Export(filters ...string) ([]Task, error) {
args := append(filters, "export", "rc.json.array=off")
cmd := exec.Command("task", args...)
+
+ var stderr bytes.Buffer
+ cmd.Stderr = &stderr
+
out, err := cmd.Output()
if err != nil {
+ // Include stderr output in the error message
+ if stderr.Len() > 0 {
+ return nil, fmt.Errorf("%v: %s", err, strings.TrimSpace(stderr.String()))
+ }
return nil, err
}
@@ -124,11 +140,26 @@ func run(args ...string) error {
fmt.Fprintln(debugWriter, "task "+strings.Join(args, " "))
}
cmd := exec.Command("task", args...)
- return cmd.Run()
+
+ // Capture stderr to provide better error messages
+ var stderr bytes.Buffer
+ cmd.Stderr = &stderr
+
+ if err := cmd.Run(); err != nil {
+ // Include stderr output in the error message
+ if stderr.Len() > 0 {
+ return fmt.Errorf("%v: %s", err, strings.TrimSpace(stderr.String()))
+ }
+ return err
+ }
+ return nil
}
// SetStatus changes the status of the task with the given id.
func SetStatus(id int, status string) error {
+ if id <= 0 {
+ return fmt.Errorf("invalid task ID: %d", id)
+ }
return run(strconv.Itoa(id), "modify", "status:"+status)
}
@@ -139,31 +170,49 @@ func SetStatusUUID(uuid, status string) error {
// Start begins the task with the given id.
func Start(id int) error {
+ if id <= 0 {
+ return fmt.Errorf("invalid task ID: %d", id)
+ }
return run(strconv.Itoa(id), "start")
}
// Stop stops the task with the given id.
func Stop(id int) error {
+ if id <= 0 {
+ return fmt.Errorf("invalid task ID: %d", id)
+ }
return run(strconv.Itoa(id), "stop")
}
// Done marks the task with the given id as completed.
func Done(id int) error {
+ if id <= 0 {
+ return fmt.Errorf("invalid task ID: %d", id)
+ }
return run(strconv.Itoa(id), "done")
}
// Delete removes the task with the given id.
func Delete(id int) error {
+ if id <= 0 {
+ return fmt.Errorf("invalid task ID: %d", id)
+ }
return run(strconv.Itoa(id), "delete")
}
// SetPriority changes the priority of the task with the given id.
func SetPriority(id int, priority string) error {
+ if id <= 0 {
+ return fmt.Errorf("invalid task ID: %d", id)
+ }
return run(strconv.Itoa(id), "modify", "priority:"+priority)
}
// AddTags adds tags to the task with the given id.
func AddTags(id int, tags []string) error {
+ if id <= 0 {
+ return fmt.Errorf("invalid task ID: %d", id)
+ }
args := []string{strconv.Itoa(id), "modify"}
for _, t := range tags {
if len(t) > 0 && t[0] != '+' {
@@ -176,6 +225,9 @@ func AddTags(id int, tags []string) error {
// RemoveTags removes tags from the task with the given id.
func RemoveTags(id int, tags []string) error {
+ if id <= 0 {
+ return fmt.Errorf("invalid task ID: %d", id)
+ }
args := []string{strconv.Itoa(id), "modify"}
for _, t := range tags {
if len(t) > 0 && t[0] != '-' {
@@ -189,6 +241,9 @@ func RemoveTags(id int, tags []string) error {
// SetTags sets the tags of the task with the given id to exactly the provided set.
// Tags not present will be removed and new tags added as needed.
func SetTags(id int, tags []string) error {
+ if id <= 0 {
+ return fmt.Errorf("invalid task ID: %d", id)
+ }
tasks, err := Export(strconv.Itoa(id))
if err != nil {
return err
@@ -232,21 +287,33 @@ func SetTags(id int, tags []string) error {
// SetRecurrence sets the recurrence for the task with the given id.
func SetRecurrence(id int, rec string) error {
+ if id <= 0 {
+ return fmt.Errorf("invalid task ID: %d", id)
+ }
return run(strconv.Itoa(id), "modify", "recur:"+rec)
}
// SetDueDate sets the due date for the task with the given id.
func SetDueDate(id int, due string) error {
+ if id <= 0 {
+ return fmt.Errorf("invalid task ID: %d", id)
+ }
return run(strconv.Itoa(id), "modify", "due:"+due)
}
// SetDescription changes the description of the task with the given id.
func SetDescription(id int, desc string) error {
+ if id <= 0 {
+ return fmt.Errorf("invalid task ID: %d", id)
+ }
return run(strconv.Itoa(id), "modify", "description:"+desc)
}
// Annotate adds an annotation to the task with the given id.
func Annotate(id int, text string) error {
+ if id <= 0 {
+ return fmt.Errorf("invalid task ID: %d", id)
+ }
return run(strconv.Itoa(id), "annotate", text)
}
@@ -255,6 +322,9 @@ func Annotate(id int, text string) error {
// annotation text is matched exactly when provided. If text is empty, the
// oldest annotation is removed.
func Denotate(id int, text string) error {
+ if id <= 0 {
+ return fmt.Errorf("invalid task ID: %d", id)
+ }
args := []string{strconv.Itoa(id), "denotate"}
if text != "" {
args = append(args, text)
@@ -266,6 +336,9 @@ func Denotate(id int, text string) error {
// given id and sets a single annotation with the provided text. If text is
// empty, all annotations are simply removed.
func ReplaceAnnotations(id int, text string) error {
+ if id <= 0 {
+ return fmt.Errorf("invalid task ID: %d", id)
+ }
tasks, err := Export(strconv.Itoa(id))
if err != nil {
return err
@@ -290,6 +363,11 @@ func ReplaceAnnotations(id int, text string) error {
// The caller is responsible for running the command, typically via
// tea.ExecProcess so that the terminal state is properly managed.
func EditCmd(id int) *exec.Cmd {
+ if id <= 0 {
+ // Return a command that will fail with an appropriate error
+ cmd := exec.Command("sh", "-c", fmt.Sprintf("echo 'invalid task ID: %d' >&2; exit 1", id))
+ return cmd
+ }
cmd := exec.Command("task", strconv.Itoa(id), "edit")
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
@@ -300,6 +378,9 @@ func EditCmd(id int) *exec.Cmd {
// Edit opens the task in an editor for manual modification.
// This is a convenience wrapper around EditCmd.
func Edit(id int) error {
+ if id <= 0 {
+ return fmt.Errorf("invalid task ID: %d", id)
+ }
return EditCmd(id).Run()
}
diff --git a/internal/ui/table.go b/internal/ui/table.go
index d75f350..de1776a 100644
--- a/internal/ui/table.go
+++ b/internal/ui/table.go
@@ -22,7 +22,10 @@ import (
var priorityOptions = []string{"H", "M", "L", ""}
-var urlRegex = regexp.MustCompile(`https?://\S+`)
+var (
+ urlRegex = regexp.MustCompile(`https?://\S+`)
+ searchRegexCache = make(map[string]*regexp.Regexp)
+)
func init() {
rand.Seed(time.Now().UnixNano())
@@ -113,6 +116,8 @@ type Model struct {
theme Theme
defaultTheme Theme
disco bool
+
+ statusMsg string
}
// editDoneMsg is emitted when the external editor process finishes.
@@ -136,6 +141,19 @@ func blinkCmd() tea.Cmd {
return tea.Tick(blinkInterval, func(time.Time) tea.Msg { return blinkMsg{} })
}
+// clearEditingModes ensures only one editing mode is active at a time
+func (m *Model) clearEditingModes() {
+ m.annotating = false
+ m.descEditing = false
+ m.tagsEditing = false
+ m.dueEditing = false
+ m.recurEditing = false
+ m.filterEditing = false
+ m.addingTask = false
+ m.searching = false
+ m.prioritySelecting = false
+}
+
func (m *Model) startBlink(id int, markDone bool) tea.Cmd {
m.blinkID = id
m.blinkMarkDone = markDone
@@ -314,6 +332,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, blinkCmd()
}
return m, nil
+ case struct{ clearStatus bool }:
+ m.statusMsg = ""
+ return m, nil
case tea.KeyMsg:
// Only allow navigation while a task row is blinking. This
// prevents accidental modifications to other tasks but still
@@ -545,9 +566,26 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.searching {
switch msg.Type {
case tea.KeyEnter:
- re, err := regexp.Compile(m.searchInput.Value())
- if err == nil {
- m.searchRegex = re
+ pattern := m.searchInput.Value()
+ if pattern != "" {
+ // Check cache first
+ if cached, ok := searchRegexCache[pattern]; ok {
+ m.searchRegex = cached
+ } else {
+ // Compile and cache if not found
+ re, err := regexp.Compile(pattern)
+ if err == nil {
+ m.searchRegex = re
+ // Limit cache size to prevent memory leak
+ if len(searchRegexCache) > 100 {
+ // Clear cache when it gets too large
+ searchRegexCache = make(map[string]*regexp.Regexp)
+ }
+ searchRegexCache[pattern] = re
+ } else {
+ m.searchRegex = nil
+ }
+ }
} else {
m.searchRegex = nil
}
@@ -638,13 +676,21 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case "o":
if row := m.tbl.SelectedRow(); row != nil {
desc := m.tasks[m.tbl.Cursor()].Description
- re := regexp.MustCompile(`https?://\S+`)
- url := re.FindString(desc)
+ url := urlRegex.FindString(desc)
if url != "" {
- _ = exec.Command(m.browserCmd, url).Run()
- idStr := ansi.Strip(row[1])
- if id, err := strconv.Atoi(idStr); err == nil {
- return m, m.startBlink(id, false)
+ if err := exec.Command(m.browserCmd, url).Run(); err != nil {
+ // Show error in status bar
+ m.statusMsg = fmt.Sprintf("Error opening browser: %v", err)
+ // Clear status message after delay
+ cmd := tea.Tick(3*time.Second, func(time.Time) tea.Msg {
+ return struct{ clearStatus bool }{true}
+ })
+ return m, cmd
+ } else {
+ idStr := ansi.Strip(row[1])
+ if id, err := strconv.Atoi(idStr); err == nil {
+ return m, m.startBlink(id, false)
+ }
}
}
}
@@ -668,6 +714,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if row := m.tbl.SelectedRow(); row != nil {
idStr := ansi.Strip(row[1])
if id, err := strconv.Atoi(idStr); err == nil {
+ m.clearEditingModes()
m.dueID = id
m.dueEditing = true
m.dueDate = time.Now()
@@ -691,6 +738,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if row := m.tbl.SelectedRow(); row != nil {
idStr := ansi.Strip(row[1])
if id, err := strconv.Atoi(idStr); err == nil {
+ m.clearEditingModes()
m.recurID = id
m.recurEditing = true
m.recurInput.SetValue(m.tasks[m.tbl.Cursor()].Recur)
@@ -703,6 +751,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if row := m.tbl.SelectedRow(); row != nil {
idStr := ansi.Strip(row[1])
if id, err := strconv.Atoi(idStr); err == nil {
+ m.clearEditingModes()
m.priorityID = id
m.prioritySelecting = true
m.priorityIndex = 0
@@ -714,6 +763,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if row := m.tbl.SelectedRow(); row != nil {
idStr := ansi.Strip(row[1])
if id, err := strconv.Atoi(idStr); err == nil {
+ m.clearEditingModes()
m.annotateID = id
m.annotating = true
m.replaceAnnotations = false
@@ -727,6 +777,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if row := m.tbl.SelectedRow(); row != nil {
idStr := ansi.Strip(row[1])
if id, err := strconv.Atoi(idStr); err == nil {
+ m.clearEditingModes()
m.annotateID = id
m.annotating = true
m.replaceAnnotations = true
@@ -737,12 +788,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
case "f":
+ m.clearEditingModes()
m.filterEditing = true
m.filterInput.SetValue(strings.Join(m.filters, " "))
m.filterInput.Focus()
m.updateTableHeight()
return m, nil
case "+":
+ m.clearEditingModes()
m.addingTask = true
m.addInput.SetValue("")
m.addInput.Focus()
@@ -752,6 +805,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if row := m.tbl.SelectedRow(); row != nil {
idStr := ansi.Strip(row[1])
if id, err := strconv.Atoi(idStr); err == nil {
+ m.clearEditingModes()
m.tagsID = id
m.tagsEditing = true
m.tagsInput.SetValue("")
@@ -776,6 +830,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.reload()
return m, nil
case "/", "?":
+ m.clearEditingModes()
m.searching = true
m.searchInput.SetValue("")
m.searchInput.Focus()
@@ -810,6 +865,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
col := m.tbl.ColumnCursor()
switch col {
case 0:
+ m.clearEditingModes()
m.priorityID = id
m.prioritySelecting = true
switch m.tasks[m.tbl.Cursor()].Priority {
@@ -831,10 +887,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} else {
m.dueDate = time.Now()
}
+ m.clearEditingModes()
m.dueEditing = true
m.updateTableHeight()
return m, nil
case 4:
+ m.clearEditingModes()
m.recurID = id
m.recurEditing = true
m.recurInput.SetValue(m.tasks[m.tbl.Cursor()].Recur)
@@ -861,6 +919,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.updateTableHeight()
return m, nil
case 7:
+ m.clearEditingModes()
m.descID = id
m.descEditing = true
m.descInput.SetValue(m.tasks[m.tbl.Cursor()].Description)
@@ -996,6 +1055,9 @@ func (m Model) View() string {
func (m Model) statusLine() string {
status := fmt.Sprintf("Total:%d InProgress:%d Due:%d | press H for help", m.total, m.inProgress, m.due)
+ if m.statusMsg != "" {
+ status = m.statusMsg
+ }
return lipgloss.NewStyle().
Foreground(lipgloss.Color(m.theme.StatusFG)).
Background(lipgloss.Color(m.theme.StatusBG)).
@@ -1221,7 +1283,7 @@ func (m Model) taskToRowSearch(t task.Task, re *regexp.Regexp, styles atable.Sty
func (m Model) expandedCellView() string {
row := m.tbl.Cursor()
col := m.tbl.ColumnCursor()
- if row < 0 || row >= len(m.tasks) || col < 0 {
+ if row < 0 || row >= len(m.tasks) || col < 0 || col > 8 {
return ""
}
t := m.tasks[row]