From 02f5f1419c4cb5fccc47a0ce4dbf3957e73b0e79 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Mon, 22 Jun 2026 09:07:07 +0300 Subject: Add Taskwarrior shell prompt Add an in-app Taskwarrior command prompt for normal and ultra modes, including selected-task prefill, async completions, captured output, and non-interactive recurrence handling. Also preserve recurring-task delete/undo support in the staged changes. --- README.md | 7 + internal/task/task.go | 144 +++++++++++++-- internal/task/task_test.go | 185 +++++++++++++++++++ internal/ui/detail_handlers.go | 24 +++ internal/ui/input_helpers.go | 3 + internal/ui/keyactions.go | 202 +++++++++++++++++--- internal/ui/keyhandlers.go | 6 + internal/ui/shell.go | 389 +++++++++++++++++++++++++++++++++++++++ internal/ui/table.go | 69 ++++++- internal/ui/table_test.go | 409 +++++++++++++++++++++++++++++++++++++++++ internal/ui/taskdetail.go | 2 +- internal/ui/ultra.go | 21 ++- internal/version.go | 2 +- 13 files changed, 1414 insertions(+), 49 deletions(-) create mode 100644 internal/ui/shell.go diff --git a/README.md b/README.md index a8ccf24..2b81611 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,13 @@ Press `H` to view all available hotkeys. Example: press `+`, type `Buy milk` and hit Enter to add a new task called "Buy milk". +Press `:` in either table or ultra mode to open a Taskwarrior command prompt. +The prompt supplies `task`; type arguments such as `add Buy milk`, `projects`, +or `+home list`. Press `;` to open the same prompt pre-filled with the selected +task UUID, ready for commands like `modify`, `annotate`, or `done`. Press `Tab` +for completion, `Enter` to run, and `Esc` to cancel. Commands that print output +open a scrollable output panel. + ## Screenshot ![Task Samurai screenshot](screenshot.png) diff --git a/internal/task/task.go b/internal/task/task.go index 4e6d787..4eaff0e 100644 --- a/internal/task/task.go +++ b/internal/task/task.go @@ -3,6 +3,7 @@ package task import ( "bufio" "bytes" + "context" "encoding/json" "fmt" "io" @@ -37,28 +38,33 @@ type Task struct { Due string `json:"due"` Priority string `json:"priority"` Recur string `json:"recur"` + Parent string `json:"parent"` + RType string `json:"rtype"` Urgency float64 `json:"urgency"` Annotations []Annotation `json:"annotations"` } -func run(args ...string) error { - if dbg.writer != nil { - fmt.Fprintln(dbg.writer, "task "+strings.Join(args, " ")) - } - cmd := exec.Command("task", args...) +// RunResult contains the captured output from a task command invocation. +type RunResult struct { + Args []string + Stdout string + Stderr string +} - // Capture stderr to provide better error messages - var stderr bytes.Buffer - cmd.Stderr = &stderr +// CompletionSources contains values used for Taskwarrior shell completion. +type CompletionSources struct { + Commands []string + Columns []string + Projects []string + Tags []string + IDs []string + UUIDs []string + UDAs []string +} - 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 +func run(args ...string) error { + _, err := RunArgs(context.Background(), args) + return err } // modifyTask runs a modify command with validation @@ -144,6 +150,103 @@ func AddLine(line string) error { return AddArgs(fields) } +// RunLine splits line using shell-word rules and runs the resulting task +// arguments. A leading "task" token is ignored so callers may accept either +// "add foo" or "task add foo" from user input. +func RunLine(ctx context.Context, line string) (RunResult, error) { + fields, err := shlex.Split(line) + if err != nil { + return RunResult{}, err + } + if len(fields) > 0 && fields[0] == "task" { + fields = fields[1:] + } + return RunArgs(ctx, fields) +} + +// RunShellLine runs a user-entered task command in non-interactive mode. It +// avoids Taskwarrior's recurring-task prompt by applying the same behavior as +// answering "no": modify only the addressed recurrence. +func RunShellLine(ctx context.Context, line string) (RunResult, error) { + fields, err := shlex.Split(line) + if err != nil { + return RunResult{}, err + } + if len(fields) > 0 && fields[0] == "task" { + fields = fields[1:] + } + fields = append([]string{"rc.recurrence.confirmation=no"}, fields...) + return RunArgs(ctx, fields) +} + +// RunArgs runs "task" with args and captures stdout and stderr. +func RunArgs(ctx context.Context, args []string) (RunResult, error) { + copied := append([]string(nil), args...) + result := RunResult{Args: copied} + if len(copied) == 0 { + return result, fmt.Errorf("empty task command") + } + + if dbg.writer != nil { + fmt.Fprintln(dbg.writer, "task "+strings.Join(copied, " ")) + } + + cmd := exec.CommandContext(ctx, "task", copied...) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + result.Stdout = stdout.String() + result.Stderr = stderr.String() + if err != nil { + if strings.TrimSpace(result.Stderr) != "" { + return result, fmt.Errorf("%v: %s", err, strings.TrimSpace(result.Stderr)) + } + return result, err + } + return result, nil +} + +// LoadCompletionSources returns Taskwarrior-provided completion candidates. +func LoadCompletionSources(ctx context.Context) CompletionSources { + return CompletionSources{ + Commands: completionList(ctx, "_commands"), + Columns: completionList(ctx, "_columns"), + Projects: completionList(ctx, "_projects"), + Tags: completionList(ctx, "_tags"), + IDs: completionList(ctx, "_ids"), + UUIDs: completionList(ctx, "_uuids"), + UDAs: completionList(ctx, "_udas"), + } +} + +func completionList(ctx context.Context, command string) []string { + result, err := RunArgs(ctx, []string{command}) + if err != nil { + return nil + } + return outputLines(result.Stdout) +} + +func outputLines(output string) []string { + scanner := bufio.NewScanner(strings.NewReader(output)) + seen := make(map[string]struct{}) + var lines []string + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + if _, ok := seen[line]; ok { + continue + } + seen[line] = struct{}{} + lines = append(lines, line) + } + return lines +} + // Export retrieves all tasks using `task export rc.json.array=off` and parses // the JSON output into a slice of Task structs. // Export retrieves tasks using `task export rc.json.array=off` and parses @@ -195,6 +298,15 @@ func SetStatusUUID(uuid, status string) error { return run(uuid, "modify", "status:"+status) } +// RecurringSeries returns the recurring template and generated instances for +// the recurring task identified by rootUUID. +func RecurringSeries(rootUUID string) ([]Task, error) { + if strings.TrimSpace(rootUUID) == "" { + return nil, fmt.Errorf("empty recurring task UUID") + } + return Export(fmt.Sprintf("(%s or parent:%s)", rootUUID, rootUUID), "status.any:") +} + // Start begins the task with the given id. func Start(id int) error { return simpleTaskCommand(id, "start") diff --git a/internal/task/task_test.go b/internal/task/task_test.go index 2869260..838aa3a 100644 --- a/internal/task/task_test.go +++ b/internal/task/task_test.go @@ -1,6 +1,7 @@ package task import ( + "context" "fmt" "os" "os/exec" @@ -117,6 +118,190 @@ func TestAddAndExport(t *testing.T) { } } +func TestRunLineSplitsCapturesAndStripsTaskPrefix(t *testing.T) { + tmp := t.TempDir() + taskPath := filepath.Join(tmp, "task") + argsFile := filepath.Join(tmp, "args.txt") + + script := "#!/bin/sh\n" + + "printf '%s\\n' \"$@\" > " + argsFile + "\n" + + "echo stdout-value\n" + + "echo stderr-value >&2\n" + if err := os.WriteFile(taskPath, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + + origPath := os.Getenv("PATH") + os.Setenv("PATH", tmp+":"+origPath) + t.Cleanup(func() { os.Setenv("PATH", origPath) }) + + result, err := RunLine(context.Background(), `task add "hello world" project:home`) + if err != nil { + t.Fatalf("RunLine: %v", err) + } + if result.Stdout != "stdout-value\n" { + t.Fatalf("stdout = %q", result.Stdout) + } + if result.Stderr != "stderr-value\n" { + t.Fatalf("stderr = %q", result.Stderr) + } + + data, err := os.ReadFile(argsFile) + if err != nil { + t.Fatalf("read args: %v", err) + } + got := strings.Split(strings.TrimSpace(string(data)), "\n") + want := []string{"add", "hello world", "project:home"} + if strings.Join(got, "|") != strings.Join(want, "|") { + t.Fatalf("args = %#v, want %#v", got, want) + } +} + +func TestRunShellLineDisablesRecurrencePrompt(t *testing.T) { + tmp := t.TempDir() + taskPath := filepath.Join(tmp, "task") + argsFile := filepath.Join(tmp, "args.txt") + + script := "#!/bin/sh\n" + + "printf '%s\\n' \"$@\" > " + argsFile + "\n" + if err := os.WriteFile(taskPath, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + + origPath := os.Getenv("PATH") + os.Setenv("PATH", tmp+":"+origPath) + t.Cleanup(func() { os.Setenv("PATH", origPath) }) + + if _, err := RunShellLine(context.Background(), `task 260 modify project:foo`); err != nil { + t.Fatalf("RunShellLine: %v", err) + } + + data, err := os.ReadFile(argsFile) + if err != nil { + t.Fatalf("read args: %v", err) + } + got := strings.Split(strings.TrimSpace(string(data)), "\n") + want := []string{"rc.recurrence.confirmation=no", "260", "modify", "project:foo"} + if strings.Join(got, "|") != strings.Join(want, "|") { + t.Fatalf("args = %#v, want %#v", got, want) + } +} + +func TestRunLineReturnsCapturedErrorOutput(t *testing.T) { + tmp := t.TempDir() + taskPath := filepath.Join(tmp, "task") + script := "#!/bin/sh\n" + + "echo bad-output >&2\n" + + "exit 2\n" + if err := os.WriteFile(taskPath, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + + origPath := os.Getenv("PATH") + os.Setenv("PATH", tmp+":"+origPath) + t.Cleanup(func() { os.Setenv("PATH", origPath) }) + + result, err := RunLine(context.Background(), "bad command") + if err == nil { + t.Fatalf("expected error") + } + if result.Stderr != "bad-output\n" { + t.Fatalf("stderr = %q", result.Stderr) + } + if !strings.Contains(err.Error(), "bad-output") { + t.Fatalf("error did not include stderr: %v", err) + } +} + +func TestLoadCompletionSources(t *testing.T) { + tmp := t.TempDir() + taskPath := filepath.Join(tmp, "task") + script := "#!/bin/sh\n" + + "case \"$1\" in\n" + + " _commands) printf 'add\\nmodify\\n' ;;\n" + + " _columns) printf 'project\\ndue\\n' ;;\n" + + " _projects) printf 'home\\nwork\\n' ;;\n" + + " _tags) printf 'urgent\\n' ;;\n" + + " _ids) printf '1\\n2\\n' ;;\n" + + " _uuids) printf 'uuid-1\\n' ;;\n" + + " _udas) printf 'custom\\n' ;;\n" + + "esac\n" + if err := os.WriteFile(taskPath, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + + origPath := os.Getenv("PATH") + os.Setenv("PATH", tmp+":"+origPath) + t.Cleanup(func() { os.Setenv("PATH", origPath) }) + + sources := LoadCompletionSources(context.Background()) + if strings.Join(sources.Commands, ",") != "add,modify" { + t.Fatalf("commands = %#v", sources.Commands) + } + if strings.Join(sources.Columns, ",") != "project,due" { + t.Fatalf("columns = %#v", sources.Columns) + } + if strings.Join(sources.Projects, ",") != "home,work" { + t.Fatalf("projects = %#v", sources.Projects) + } + if strings.Join(sources.Tags, ",") != "urgent" { + t.Fatalf("tags = %#v", sources.Tags) + } + if strings.Join(sources.IDs, ",") != "1,2" { + t.Fatalf("ids = %#v", sources.IDs) + } + if strings.Join(sources.UUIDs, ",") != "uuid-1" { + t.Fatalf("uuids = %#v", sources.UUIDs) + } + if strings.Join(sources.UDAs, ",") != "custom" { + t.Fatalf("udas = %#v", sources.UDAs) + } +} + +func TestRecurringSeries(t *testing.T) { + tmp := t.TempDir() + taskPath := filepath.Join(tmp, "task") + argsFile := filepath.Join(tmp, "args.txt") + + script := "#!/bin/sh\n" + + "echo \"$@\" > " + argsFile + "\n" + + "if [ \"$1\" = \"(parent-uuid or parent:parent-uuid)\" ] && [ \"$2\" = \"status.any:\" ] && [ \"$3\" = \"export\" ]; then\n" + + " echo '{\"id\":0,\"uuid\":\"parent-uuid\",\"description\":\"template\",\"status\":\"recurring\",\"recur\":\"daily\"}'\n" + + " echo '{\"id\":1,\"uuid\":\"child-uuid\",\"parent\":\"parent-uuid\",\"description\":\"child\",\"status\":\"pending\",\"recur\":\"daily\"}'\n" + + " exit 0\n" + + "fi\n" + + "exit 1\n" + if err := os.WriteFile(taskPath, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + + origPath := os.Getenv("PATH") + os.Setenv("PATH", tmp+":"+origPath) + t.Cleanup(func() { os.Setenv("PATH", origPath) }) + + tasks, err := RecurringSeries("parent-uuid") + if err != nil { + t.Fatalf("RecurringSeries: %v", err) + } + if len(tasks) != 2 { + t.Fatalf("expected 2 tasks, got %d", len(tasks)) + } + if tasks[0].UUID != "parent-uuid" || tasks[0].Status != "recurring" { + t.Fatalf("unexpected template task: %#v", tasks[0]) + } + if tasks[1].UUID != "child-uuid" || tasks[1].Parent != "parent-uuid" { + t.Fatalf("unexpected child task: %#v", tasks[1]) + } + + data, err := os.ReadFile(argsFile) + if err != nil { + t.Fatalf("read args: %v", err) + } + if got := strings.TrimSpace(string(data)); got != "(parent-uuid or parent:parent-uuid) status.any: export rc.json.array=off" { + t.Fatalf("unexpected args: %q", got) + } +} + func TestModifyHelpers(t *testing.T) { if _, err := exec.LookPath("task"); err != nil { t.Skip("task command not available") diff --git a/internal/ui/detail_handlers.go b/internal/ui/detail_handlers.go index 05ea7c4..0c57572 100644 --- a/internal/ui/detail_handlers.go +++ b/internal/ui/detail_handlers.go @@ -79,6 +79,8 @@ func (m *Model) handleTaskDetailMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { return m.handleOpenURL() case "d": return m.handleDetailMarkDone() + case "D": + return m.handleDetailDeleteTask() case "U": return m.handleDetailUndo() case "i", "enter": @@ -102,6 +104,28 @@ func (m *Model) handleDetailMarkDone() (tea.Model, tea.Cmd) { return m, m.startBlink(id, true) } +func (m *Model) handleDetailDeleteTask() (tea.Model, tea.Cmd) { + if m.currentTaskDetail == nil { + return m, nil + } + tsk := *m.currentTaskDetail + m.closeDetailView() + count, recurring, err := m.deleteTaskWithUndo(tsk) + if err != nil { + m.showError(err) + return m, nil + } + if !m.reloadAndReport() { + return m, nil + } + if recurring { + m.statusMsg = fmt.Sprintf("Deleted %d recurring tasks", count) + } else { + m.statusMsg = "Deleted task" + } + return m, nil +} + // handleDetailUndo restores the most recently completed task from the undo // stack. The detail view is closed first because the undone task generally // differs from the one currently displayed, and handleUndo blinks the diff --git a/internal/ui/input_helpers.go b/internal/ui/input_helpers.go index 159eb6b..d1fdad6 100644 --- a/internal/ui/input_helpers.go +++ b/internal/ui/input_helpers.go @@ -81,6 +81,9 @@ func (m *Model) handleEditingModes(msg tea.KeyPressMsg) (handled bool, model tea case m.searching: model, cmd = m.handleSearchMode(msg) return true, model, cmd + case m.shellActive: + model, cmd = m.handleShellMode(msg) + return true, model, cmd case m.helpSearching: model, cmd = m.handleHelpSearchMode(msg) return true, model, cmd diff --git a/internal/ui/keyactions.go b/internal/ui/keyactions.go index 15e1b06..81ab468 100644 --- a/internal/ui/keyactions.go +++ b/internal/ui/keyactions.go @@ -63,6 +63,29 @@ func (m *Model) handleMarkDone() (tea.Model, tea.Cmd) { return m, m.startBlink(id, true) } +func (m *Model) handleDeleteTask() (tea.Model, tea.Cmd) { + tsk := m.getTaskForDelete() + if tsk == nil { + return m, nil + } + + count, recurring, err := m.deleteTaskWithUndo(*tsk) + if err != nil { + m.showError(err) + return m, nil + } + if !m.reloadAndReport() { + return m, nil + } + + if recurring { + m.statusMsg = fmt.Sprintf("Deleted %d recurring tasks", count) + } else { + m.statusMsg = "Deleted task" + } + return m, nil +} + func (m *Model) handleOpenURL() (tea.Model, tea.Cmd) { task := m.getTaskForOpenURL() if task == nil { @@ -95,13 +118,14 @@ func (m *Model) handleUndo() (tea.Model, tea.Cmd) { return m, nil } - uuid := m.undoStack[len(m.undoStack)-1] - m.undoStack = m.undoStack[:len(m.undoStack)-1] - - if err := task.SetStatusUUID(uuid, "pending"); err != nil { - m.showError(err) - return m, nil + action := m.undoStack[len(m.undoStack)-1] + for _, restore := range action.restores { + if err := task.SetStatusUUID(restore.uuid, restore.status); err != nil { + m.showError(err) + return m, nil + } } + m.undoStack = m.undoStack[:len(m.undoStack)-1] // Reload the task list to get the updated task with its new ID if err := m.reload(); err != nil { @@ -112,10 +136,15 @@ func (m *Model) handleUndo() (tea.Model, tea.Cmd) { // Find the task ID for blinking var id int var found bool - for _, tsk := range m.tasks { - if tsk.UUID == uuid { - id = tsk.ID - found = true + for _, restore := range action.restores { + for _, tsk := range m.tasks { + if tsk.UUID == restore.uuid { + id = tsk.ID + found = true + break + } + } + if found { break } } @@ -123,34 +152,157 @@ func (m *Model) handleUndo() (tea.Model, tea.Cmd) { // If task not found or has ID 0, try to get it directly from Taskwarrior if !found || id == 0 { // Use task export with UUID filter to get the specific task - filters := []string{uuid} - if m.filters != nil { - filters = append(filters, m.filters...) - } - filters = append(filters, "status:pending") - - tasks, err := task.Export(filters...) - if err == nil && len(tasks) > 0 { - id = tasks[0].ID - // Also update our local task list - for i, tsk := range m.tasks { - if tsk.UUID == uuid { - m.tasks[i].ID = id - break + for _, restore := range action.restores { + filters := []string{restore.uuid} + if m.filters != nil { + filters = append(filters, m.filters...) + } + filters = append(filters, "status:"+restore.status) + + tasks, err := task.Export(filters...) + if err == nil && len(tasks) > 0 { + id = tasks[0].ID + // Also update our local task list + for i, tsk := range m.tasks { + if tsk.UUID == restore.uuid { + m.tasks[i].ID = id + break + } } + break } } } // If we still don't have a valid ID, don't try to blink if id == 0 { - m.statusMsg = "Task restored" + m.statusMsg = undoStatus(action) return m, nil } return m, m.startBlink(id, false) } +func (m *Model) getTaskForDelete() *task.Task { + if m.showTaskDetail && m.currentTaskDetail != nil { + return m.currentTaskDetail + } + return m.getTaskAtCursor() +} + +func (m *Model) deleteTaskWithUndo(tsk task.Task) (int, bool, error) { + if strings.TrimSpace(tsk.UUID) == "" { + return 0, false, fmt.Errorf("task %d has no UUID", tsk.ID) + } + + recurring := isRecurringTask(tsk) + tasks := []task.Task{tsk} + if recurring { + series, err := task.RecurringSeries(recurringRootUUID(tsk)) + if err != nil { + return 0, true, fmt.Errorf("loading recurring series: %w", err) + } + tasks = mergeTasksByUUID(series, tsk) + } + + tasks = deleteOrder(tasks, recurringRootUUID(tsk)) + restores := make([]undoRestore, 0, len(tasks)) + for _, candidate := range tasks { + if strings.TrimSpace(candidate.UUID) == "" { + continue + } + restores = append(restores, undoRestore{uuid: candidate.UUID, status: undoStatusForTask(candidate)}) + } + if len(restores) == 0 { + return 0, recurring, fmt.Errorf("no task UUIDs to delete") + } + + completed := make([]undoRestore, 0, len(restores)) + for _, restore := range restores { + if err := task.SetStatusUUID(restore.uuid, "deleted"); err != nil { + rollbackUndoRestores(completed) + return 0, recurring, fmt.Errorf("deleting task %s: %w", restore.uuid, err) + } + completed = append(completed, restore) + } + + m.pushUndoAction("delete", restores) + return len(restores), recurring, nil +} + +func (m *Model) pushUndoAction(label string, restores []undoRestore) { + if len(restores) == 0 { + return + } + copied := append([]undoRestore(nil), restores...) + m.undoStack = append(m.undoStack, undoAction{label: label, restores: copied}) +} + +func isRecurringTask(tsk task.Task) bool { + return tsk.Parent != "" || tsk.Status == "recurring" || tsk.RType != "" || tsk.Recur != "" +} + +func recurringRootUUID(tsk task.Task) string { + if tsk.Parent != "" { + return tsk.Parent + } + return tsk.UUID +} + +func mergeTasksByUUID(tasks []task.Task, selected task.Task) []task.Task { + seen := make(map[string]struct{}, len(tasks)+1) + merged := make([]task.Task, 0, len(tasks)+1) + for _, tsk := range tasks { + if tsk.UUID == "" { + continue + } + if _, ok := seen[tsk.UUID]; ok { + continue + } + seen[tsk.UUID] = struct{}{} + merged = append(merged, tsk) + } + if selected.UUID != "" { + if _, ok := seen[selected.UUID]; !ok { + merged = append(merged, selected) + } + } + return merged +} + +func deleteOrder(tasks []task.Task, rootUUID string) []task.Task { + ordered := make([]task.Task, 0, len(tasks)) + var root []task.Task + for _, tsk := range tasks { + if tsk.UUID == rootUUID { + root = append(root, tsk) + continue + } + ordered = append(ordered, tsk) + } + return append(ordered, root...) +} + +func undoStatusForTask(tsk task.Task) string { + if tsk.Status == "" || tsk.Status == "deleted" { + return "pending" + } + return tsk.Status +} + +func rollbackUndoRestores(restores []undoRestore) { + for i := len(restores) - 1; i >= 0; i-- { + _ = task.SetStatusUUID(restores[i].uuid, restores[i].status) + } +} + +func undoStatus(action undoAction) string { + if action.label == "delete" && len(action.restores) > 1 { + return "Tasks restored" + } + return "Task restored" +} + func (m *Model) handleSetDueDate() (tea.Model, tea.Cmd) { id, err := m.getSelectedTaskID() if err != nil { diff --git a/internal/ui/keyhandlers.go b/internal/ui/keyhandlers.go index df82e80..0cfabec 100644 --- a/internal/ui/keyhandlers.go +++ b/internal/ui/keyhandlers.go @@ -61,6 +61,8 @@ func (m *Model) handleNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { return m.handleToggleStart() case "d": return m.handleMarkDone() + case "D": + return m.handleDeleteTask() case "o": return m.handleOpenURL() case "U": @@ -81,6 +83,10 @@ func (m *Model) handleNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { return m.handleAnnotate(true) case "f": return m.handleFilter() + case ":": + return m.handleShellPrompt() + case ";": + return m.handleShellPromptForSelectedTask() case "+": return m.handleAddTask() case "t": diff --git a/internal/ui/shell.go b/internal/ui/shell.go new file mode 100644 index 0000000..f497517 --- /dev/null +++ b/internal/ui/shell.go @@ -0,0 +1,389 @@ +package ui + +import ( + "context" + "fmt" + "strings" + "time" + "unicode" + + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "codeberg.org/snonux/tasksamurai/internal/task" +) + +const shellCommandTimeout = 2 * time.Minute + +func shellRunCmd(line string, selectedID int) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), shellCommandTimeout) + defer cancel() + + result, err := task.RunShellLine(ctx, line) + return shellDoneMsg{result: result, err: err, selectedID: selectedID} + } +} + +func (m *Model) handleShellPrompt() (tea.Model, tea.Cmd) { + return m.openShellPrompt("") +} + +func (m *Model) handleShellPromptForSelectedTask() (tea.Model, tea.Cmd) { + uuid := m.shellSelectedTaskUUID() + if uuid == "" { + return m.handleShellPrompt() + } + return m.openShellPrompt(uuid + " ") +} + +func (m *Model) openShellPrompt(value string) (tea.Model, tea.Cmd) { + m.clearEditingModes() + m.shellActive = true + m.shellInput.SetValue(value) + m.shellInput.CursorEnd() + m.shellInput.Focus() + m.refreshShellSuggestions() + m.updateTableHeight() + return m, m.loadShellCompletionsCmd() +} + +func (m *Model) handleShellMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "enter": + line := strings.TrimSpace(m.shellInput.Value()) + if line == "" { + m.shellActive = false + m.shellInput.Blur() + m.updateTableHeight() + return m, nil + } + + selectedID := m.shellSelectedTaskID() + m.shellHistory = append(m.shellHistory, line) + m.shellActive = false + m.shellInput.Blur() + m.updateTableHeight() + return m, shellRunCmd(line, selectedID) + case "esc": + m.shellActive = false + m.shellInput.Blur() + m.updateTableHeight() + return m, nil + case "tab": + m.refreshShellSuggestions() + if len(m.shellCompletion.Commands) == 0 { + return m, m.loadShellCompletionsCmd() + } + } + + var cmd tea.Cmd + m.shellInput, cmd = m.shellInput.Update(msg) + m.refreshShellSuggestions() + return m, cmd +} + +func (m *Model) handleShellDone(msg shellDoneMsg) (tea.Model, tea.Cmd) { + if !m.reloadAndReport() { + return m, nil + } + if msg.selectedID > 0 { + _ = m.selectTaskByID(msg.selectedID) + } + + output := shellOutput(msg.result, msg.err) + if strings.TrimSpace(output) == "" { + if msg.err != nil { + m.showError(msg.err) + } else { + m.statusMsg = fmt.Sprintf("task %s completed", strings.Join(msg.result.Args, " ")) + } + return m, nil + } + + m.showShellOutput(shellTitle(msg.result, msg.err), output) + return m, nil +} + +func (m *Model) handleShellCompletion(msg shellCompletionMsg) (tea.Model, tea.Cmd) { + m.shellCompletion = msg.sources + m.shellCompletionLoad = false + if m.shellActive { + m.refreshShellSuggestions() + } + return m, nil +} + +func (m *Model) handleShellOutputMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "esc", "q", "enter": + m.shellOutputVisible = false + return m, nil + case "up", "k": + m.shellOutputViewport.ScrollUp(1) + case "down", "j": + m.shellOutputViewport.ScrollDown(1) + case "pgup", "b": + m.shellOutputViewport.PageUp() + case "pgdown", "space": + m.shellOutputViewport.PageDown() + case "g", "home": + m.shellOutputViewport.GotoTop() + case "G", "end": + m.shellOutputViewport.GotoBottom() + } + return m, nil +} + +func (m *Model) renderShellOutputScreen() string { + width := m.tbl.Width() + if width <= 0 { + width = 80 + } + height := m.windowHeight - 2 + if height < 1 { + height = 1 + } + + m.shellOutputViewport.SetWidth(width) + m.shellOutputViewport.SetHeight(height) + + title := lipgloss.NewStyle(). + Foreground(lipgloss.Color(m.theme.StatusFG)). + Background(lipgloss.Color(m.theme.StatusBG)). + Width(width). + Render(m.shellOutputTitle) + footer := lipgloss.NewStyle(). + Foreground(lipgloss.Color(m.theme.StatusFG)). + Background(lipgloss.Color(m.theme.StatusBG)). + Width(width). + Render("Esc/q/Enter close | j/k scroll | PgUp/PgDn page") + return lipgloss.JoinVertical(lipgloss.Left, title, m.shellOutputViewport.View(), footer) +} + +func (m *Model) showShellOutput(title, output string) { + width := m.tbl.Width() + if width <= 0 { + width = 80 + } + height := m.windowHeight - 2 + if height < 1 { + height = 1 + } + + m.shellOutputVisible = true + m.shellOutputTitle = title + m.shellOutputViewport = viewport.New(viewport.WithWidth(width), viewport.WithHeight(height)) + m.shellOutputViewport.SetContent(strings.TrimRight(output, "\n")) +} + +func (m *Model) shellSelectedTaskID() int { + if m.showUltra { + id, err := m.getUltraSelectedTaskID() + if err == nil { + return id + } + return 0 + } + id, err := m.getSelectedTaskID() + if err == nil { + return id + } + return 0 +} + +func (m *Model) shellSelectedTaskUUID() string { + if m.showUltra { + tasks := m.ultraTaskList() + if m.ultraCursor < 0 || m.ultraCursor >= len(tasks) { + return "" + } + return strings.TrimSpace(tasks[m.ultraCursor].UUID) + } + + tsk := m.getTaskAtCursor() + if tsk == nil { + return "" + } + return strings.TrimSpace(tsk.UUID) +} + +func (m *Model) refreshShellSuggestions() { + m.shellInput.ShowSuggestions = true + m.shellInput.SetSuggestions(m.shellLineSuggestions()) +} + +func (m *Model) loadShellCompletionsCmd() tea.Cmd { + if m.shellCompletionLoad || len(m.shellCompletion.Commands) > 0 { + return nil + } + m.shellCompletionLoad = true + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + return shellCompletionMsg{sources: task.LoadCompletionSources(ctx)} + } +} + +func (m *Model) shellLineSuggestions() []string { + value := m.shellInput.Value() + start, end, token := shellTokenAt(value, m.shellInput.Position()) + replacementTokens := m.shellReplacementTokens(token, shellTokenIndex(value, start)) + if len(replacementTokens) == 0 { + return nil + } + + prefix := string([]rune(value)[:start]) + suffix := string([]rune(value)[end:]) + suggestions := make([]string, 0, len(replacementTokens)) + seen := make(map[string]struct{}) + for _, replacement := range replacementTokens { + candidate := prefix + replacement + suffix + if candidate == value { + continue + } + if !strings.HasPrefix(strings.ToLower(candidate), strings.ToLower(value)) { + continue + } + if _, ok := seen[candidate]; ok { + continue + } + seen[candidate] = struct{}{} + suggestions = append(suggestions, candidate) + } + return suggestions +} + +func (m *Model) shellReplacementTokens(token string, tokenIndex int) []string { + var out []string + commandPosition := tokenIndex == 0 || (tokenIndex == 1 && shellFirstTokenIsTask(m.shellInput.Value())) + if commandPosition { + if token != "" && strings.HasPrefix(strings.ToLower("task"), strings.ToLower(token)) { + out = append(out, "task") + } + if !strings.Contains(token, ":") && !strings.HasPrefix(token, "+") && !strings.HasPrefix(token, "-") { + out = append(out, matchingShellValues(token, m.shellCompletion.Commands)...) + } + } + out = append(out, m.attributeCompletions(token)...) + out = append(out, m.tagCompletions(token)...) + out = append(out, matchingShellValues(token, m.shellCompletion.IDs)...) + out = append(out, matchingShellValues(token, m.shellCompletion.UUIDs)...) + return out +} + +func (m *Model) attributeCompletions(token string) []string { + if strings.Contains(token, ":") { + key, value, _ := strings.Cut(token, ":") + switch strings.ToLower(key) { + case "project", "proj": + return prefixedValues(key+":", value, m.shellCompletion.Projects) + case "status": + return prefixedValues(key+":", value, []string{"pending", "completed", "deleted", "waiting", "recurring"}) + case "priority", "pri": + return prefixedValues(key+":", value, []string{"H", "M", "L"}) + } + return nil + } + + keys := append([]string(nil), m.shellCompletion.Columns...) + keys = append(keys, m.shellCompletion.UDAs...) + for i, key := range keys { + keys[i] = key + ":" + } + return matchingShellValues(token, keys) +} + +func (m *Model) tagCompletions(token string) []string { + if !strings.HasPrefix(token, "+") && !strings.HasPrefix(token, "-") { + return nil + } + sign := token[:1] + prefix := strings.TrimPrefix(token[1:], "#") + var tags []string + for _, tag := range m.shellCompletion.Tags { + tag = strings.TrimPrefix(tag, "#") + tags = append(tags, sign+tag) + } + return matchingShellValues(sign+prefix, tags) +} + +func shellTokenAt(value string, pos int) (int, int, string) { + runes := []rune(value) + if pos < 0 { + pos = 0 + } + if pos > len(runes) { + pos = len(runes) + } + + start := pos + for start > 0 && !unicode.IsSpace(runes[start-1]) { + start-- + } + end := pos + for end < len(runes) && !unicode.IsSpace(runes[end]) { + end++ + } + return start, end, string(runes[start:end]) +} + +func shellTokenIndex(value string, tokenStart int) int { + prefix := string([]rune(value)[:tokenStart]) + return len(strings.Fields(prefix)) +} + +func shellFirstTokenIsTask(value string) bool { + fields := strings.Fields(value) + return len(fields) > 0 && fields[0] == "task" +} + +func matchingShellValues(prefix string, values []string) []string { + var matches []string + for _, value := range values { + if strings.HasPrefix(strings.ToLower(value), strings.ToLower(prefix)) { + matches = append(matches, value) + } + } + return matches +} + +func prefixedValues(prefix, valuePrefix string, values []string) []string { + var matches []string + for _, value := range values { + if strings.HasPrefix(strings.ToLower(value), strings.ToLower(valuePrefix)) { + matches = append(matches, prefix+value) + } + } + return matches +} + +func shellOutput(result task.RunResult, err error) string { + var parts []string + if err != nil { + parts = append(parts, "Error: "+err.Error()) + } + if strings.TrimSpace(result.Stdout) != "" { + parts = append(parts, strings.TrimRight(result.Stdout, "\n")) + } + if strings.TrimSpace(result.Stderr) != "" { + stderr := strings.TrimRight(result.Stderr, "\n") + if err == nil || !strings.Contains(err.Error(), strings.TrimSpace(result.Stderr)) { + parts = append(parts, stderr) + } + } + return strings.Join(parts, "\n\n") +} + +func shellTitle(result task.RunResult, err error) string { + status := "output" + if err != nil { + status = "error" + } + command := strings.Join(result.Args, " ") + if command == "" { + command = "(empty)" + } + return fmt.Sprintf("task %s | %s", command, status) +} diff --git a/internal/ui/table.go b/internal/ui/table.go index 1cdb0c9..14b5c52 100644 --- a/internal/ui/table.go +++ b/internal/ui/table.go @@ -45,6 +45,16 @@ type helpSection struct { items []helpItem } +type undoRestore struct { + uuid string + status string +} + +type undoAction struct { + label string + restores []undoRestore +} + // blinkState holds row-level blink animation state for the task table. // A blink cycles the selected row's highlight on/off after a modification. type blinkState struct { @@ -111,6 +121,18 @@ type ultraModeState struct { ultraStartup bool } +// shellState holds the Taskwarrior command prompt and captured output panel. +type shellState struct { + shellActive bool + shellInput textinput.Model + shellHistory []string + shellOutputVisible bool + shellOutputTitle string + shellOutputViewport viewport.Model + shellCompletion task.CompletionSources + shellCompletionLoad bool +} + // editState holds inline field-editing state for the task table. // Each editing mode (annotate, desc, tags, …) is mutually exclusive; // clearEditingModes resets them all before activating a new one. @@ -168,6 +190,7 @@ type Model struct { ultraState // ultra mode task list and search state (see ultraState) detailEditState // detail-overlay external description editor state ultraModeState // ultra-mode lifecycle flags + shellState // Taskwarrior command prompt and output panel editState // inline field editing (see editState) cellExpanded bool @@ -191,7 +214,7 @@ type Model struct { filters []string tasks []task.Task - undoStack []string + undoStack []undoAction browserCmd string agentFilterHotkey string @@ -213,6 +236,16 @@ type descEditDoneMsg struct { tempFile string } +type shellDoneMsg struct { + result task.RunResult + err error + selectedID int +} + +type shellCompletionMsg struct { + sources task.CompletionSources +} + type blinkMsg struct{} type descriptionTempFile interface { @@ -307,6 +340,7 @@ func (m *Model) clearEditingModes() { m.filterEditing = false m.addingTask = false m.searching = false + m.shellActive = false m.prioritySelecting = false } @@ -335,7 +369,7 @@ func (m *Model) startBlink(id int, markDone bool) tea.Cmd { if markDone { for _, tsk := range m.tasks { if tsk.ID == id { - m.undoStack = append(m.undoStack, tsk.UUID) + m.pushUndoAction("done", []undoRestore{{uuid: tsk.UUID, status: "pending"}}) break } } @@ -394,6 +428,8 @@ func New(filters []string, browserCmd string) (Model, error) { m.addInput = textinput.New() m.addInput.Prompt = "add: " + m.shellInput = textinput.New() + m.shellInput.Prompt = "task " m.defaultTheme = DefaultTheme() m.theme = m.defaultTheme @@ -542,6 +578,10 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.handleEditDone(msg) case descEditDoneMsg: return m.handleDescEditDone(msg) + case shellDoneMsg: + return m.handleShellDone(msg) + case shellCompletionMsg: + return m.handleShellCompletion(msg) case blinkMsg: return m.handleBlinkMsg() case struct{ clearStatus bool }: @@ -552,6 +592,9 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.blinkID != 0 { return m.handleBlinkingState(msg) } + if m.shellOutputVisible { + return m.handleShellOutputMode(msg) + } // Check if we're in detail view if m.showTaskDetail { @@ -608,6 +651,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m *Model) handleWindowResize(msg tea.WindowSizeMsg) (tea.Model, tea.Cmd) { m.tbl.SetWidth(msg.Width) m.windowHeight = msg.Height + m.shellInput.SetWidth(msg.Width) m.computeColumnWidths() m.updateTableHeight() if m.showUltra { @@ -627,6 +671,14 @@ func (m *Model) handleWindowResize(msg tea.WindowSizeMsg) (tea.Model, tea.Cmd) { m.helpViewport.SetHeight(height) } } + if m.shellOutputVisible { + height := msg.Height - 2 + if height < 1 { + height = 1 + } + m.shellOutputViewport.SetWidth(msg.Width) + m.shellOutputViewport.SetHeight(height) + } return m, nil } @@ -667,7 +719,7 @@ func (m *Model) handleBlinkMsg() (tea.Model, tea.Cmd) { if mark { for _, tsk := range m.tasks { if tsk.ID == id { - m.undoStack = append(m.undoStack, tsk.UUID) + m.pushUndoAction("done", []undoRestore{{uuid: tsk.UUID, status: "pending"}}) break } } @@ -691,6 +743,8 @@ func (m Model) View() tea.View { content = m.renderHelpScreen() case m.showTaskDetail: content = m.renderDetailScreen() + case m.shellOutputVisible: + content = m.renderShellOutputScreen() case m.showUltra: content = m.renderUltraScreen() default: @@ -728,6 +782,8 @@ func (m Model) appendInlineInputOverlay(view string) string { overlay = m.addInput.View() case m.searching: overlay = m.searchInput.View() + case m.shellActive: + overlay = m.shellInput.View() } if overlay != "" { @@ -907,7 +963,8 @@ func (m Model) helpSections() []helpSection { {key: "+", desc: "add new task"}, {key: "e, E", desc: "edit entire task"}, {key: "d", desc: "mark task done"}, - {key: "U", desc: "undo last done"}, + {key: "D", desc: "delete task/recurring series"}, + {key: "U", desc: "undo last done/delete"}, {key: "s", desc: "start/stop task"}, }, }, @@ -931,6 +988,8 @@ func (m Model) helpSections() []helpSection { items: []helpItem{ {key: m.agentFilterHotkeyLabel(), desc: "toggle +agent/-agent filter"}, {key: "f", desc: "change filter"}, + {key: ":", desc: "run task command prompt"}, + {key: ";", desc: "run task command prompt for selected task"}, {key: "/, ?", desc: "search"}, {key: "n, N", desc: "next/previous match"}, {key: "space", desc: "refresh tasks"}, @@ -1247,7 +1306,7 @@ func (m *Model) updateTableHeight() { if m.cellExpanded { h-- } - if m.annotating || m.dueEditing || m.prioritySelecting || m.searching || m.descEditing || m.tagsEditing || m.recurEditing || m.projEditing || m.filterEditing || m.addingTask { + if m.annotating || m.dueEditing || m.prioritySelecting || m.searching || m.descEditing || m.tagsEditing || m.recurEditing || m.projEditing || m.filterEditing || m.addingTask || m.shellActive { h-- } if h < 1 { diff --git a/internal/ui/table_test.go b/internal/ui/table_test.go index 2ea9776..c4f1e82 100644 --- a/internal/ui/table_test.go +++ b/internal/ui/table_test.go @@ -434,6 +434,218 @@ func TestUndoHotkey(t *testing.T) { } } +func TestDeleteHotkeyUndo(t *testing.T) { + tmp := t.TempDir() + taskPath := filepath.Join(tmp, "task") + logFile := filepath.Join(tmp, "log.txt") + + script := fmt.Sprintf("#!/bin/sh\n"+ + "if echo \"$@\" | grep -q export; then\n"+ + " echo '{\"id\":1,\"uuid\":\"x\",\"description\":\"d\",\"status\":\"pending\",\"entry\":\"\",\"priority\":\"\",\"urgency\":0}'\n"+ + " exit 0\n"+ + "fi\n"+ + "echo \"$@\" >> %s\n", logFile) + + if err := os.WriteFile(taskPath, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + + origPath := os.Getenv("PATH") + os.Setenv("PATH", tmp+":"+origPath) + t.Cleanup(func() { os.Setenv("PATH", origPath) }) + + os.Setenv("TASKDATA", tmp) + os.Setenv("TASKRC", "/dev/null") + t.Cleanup(func() { + os.Unsetenv("TASKDATA") + os.Unsetenv("TASKRC") + }) + + m, err := New(nil, "firefox") + if err != nil { + t.Fatalf("New: %v", err) + } + + mv, _ := (&m).Update(tea.KeyPressMsg{Code: 'D', Text: "D"}) + m = *mv.(*Model) + mv, _ = (&m).Update(tea.KeyPressMsg{Code: 'U', Text: "U"}) + m = *mv.(*Model) + + data, err := os.ReadFile(logFile) + if err != nil { + t.Fatalf("read log: %v", err) + } + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + if len(lines) < 2 { + t.Fatalf("expected at least two commands, got %d", len(lines)) + } + if lines[0] != "x modify status:deleted" { + t.Fatalf("delete not called: %q", lines[0]) + } + if lines[1] != "x modify status:pending" { + t.Fatalf("undo delete not called: %q", lines[1]) + } +} + +func TestDeleteRecurringHotkeyUndo(t *testing.T) { + tmp := t.TempDir() + taskPath := filepath.Join(tmp, "task") + logFile := filepath.Join(tmp, "log.txt") + + script := fmt.Sprintf("#!/bin/sh\n"+ + "if [ \"$1\" = \"(parent or parent:parent)\" ] && [ \"$2\" = \"status.any:\" ] && [ \"$3\" = \"export\" ]; then\n"+ + " echo '{\"id\":0,\"uuid\":\"parent\",\"description\":\"template\",\"status\":\"recurring\",\"entry\":\"\",\"priority\":\"\",\"urgency\":0,\"recur\":\"daily\",\"rtype\":\"periodic\"}'\n"+ + " echo '{\"id\":1,\"uuid\":\"child\",\"parent\":\"parent\",\"description\":\"child\",\"status\":\"pending\",\"entry\":\"\",\"priority\":\"\",\"urgency\":0,\"recur\":\"daily\",\"rtype\":\"periodic\"}'\n"+ + " echo '{\"id\":2,\"uuid\":\"future\",\"parent\":\"parent\",\"description\":\"future\",\"status\":\"pending\",\"entry\":\"\",\"priority\":\"\",\"urgency\":0,\"recur\":\"daily\",\"rtype\":\"periodic\"}'\n"+ + " exit 0\n"+ + "fi\n"+ + "if echo \"$@\" | grep -q export; then\n"+ + " echo '{\"id\":1,\"uuid\":\"child\",\"parent\":\"parent\",\"description\":\"child\",\"status\":\"pending\",\"entry\":\"\",\"priority\":\"\",\"urgency\":0,\"recur\":\"daily\",\"rtype\":\"periodic\"}'\n"+ + " exit 0\n"+ + "fi\n"+ + "echo \"$@\" >> %s\n", logFile) + + if err := os.WriteFile(taskPath, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + + origPath := os.Getenv("PATH") + os.Setenv("PATH", tmp+":"+origPath) + t.Cleanup(func() { os.Setenv("PATH", origPath) }) + + os.Setenv("TASKDATA", tmp) + os.Setenv("TASKRC", "/dev/null") + t.Cleanup(func() { + os.Unsetenv("TASKDATA") + os.Unsetenv("TASKRC") + }) + + m, err := New(nil, "firefox") + if err != nil { + t.Fatalf("New: %v", err) + } + + mv, _ := (&m).Update(tea.KeyPressMsg{Code: 'D', Text: "D"}) + m = *mv.(*Model) + mv, _ = (&m).Update(tea.KeyPressMsg{Code: 'U', Text: "U"}) + m = *mv.(*Model) + + data, err := os.ReadFile(logFile) + if err != nil { + t.Fatalf("read log: %v", err) + } + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + want := []string{ + "child modify status:deleted", + "future modify status:deleted", + "parent modify status:deleted", + "child modify status:pending", + "future modify status:pending", + "parent modify status:recurring", + } + if !reflect.DeepEqual(lines, want) { + t.Fatalf("unexpected commands:\ngot %#v\nwant %#v", lines, want) + } +} + +func TestDeleteHotkeyInUltraMode(t *testing.T) { + tmp := t.TempDir() + taskPath := filepath.Join(tmp, "task") + logFile := filepath.Join(tmp, "log.txt") + + script := fmt.Sprintf("#!/bin/sh\n"+ + "if echo \"$@\" | grep -q export; then\n"+ + " echo '{\"id\":1,\"uuid\":\"x\",\"description\":\"d\",\"status\":\"pending\",\"entry\":\"\",\"priority\":\"\",\"urgency\":0}'\n"+ + " exit 0\n"+ + "fi\n"+ + "echo \"$@\" >> %s\n", logFile) + + if err := os.WriteFile(taskPath, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + + origPath := os.Getenv("PATH") + os.Setenv("PATH", tmp+":"+origPath) + t.Cleanup(func() { os.Setenv("PATH", origPath) }) + + os.Setenv("TASKDATA", tmp) + os.Setenv("TASKRC", "/dev/null") + t.Cleanup(func() { + os.Unsetenv("TASKDATA") + os.Unsetenv("TASKRC") + }) + + m, err := New(nil, "firefox") + if err != nil { + t.Fatalf("New: %v", err) + } + + mv, _ := (&m).Update(tea.KeyPressMsg{Code: 'u', Text: "u"}) + m = *mv.(*Model) + mv, _ = (&m).Update(tea.KeyPressMsg{Code: 'D', Text: "D"}) + m = *mv.(*Model) + + data, err := os.ReadFile(logFile) + if err != nil { + t.Fatalf("read log: %v", err) + } + if got := strings.TrimSpace(string(data)); got != "x modify status:deleted" { + t.Fatalf("ultra delete not called: %q", got) + } +} + +func TestDeleteHotkeyInDetailMode(t *testing.T) { + tmp := t.TempDir() + taskPath := filepath.Join(tmp, "task") + logFile := filepath.Join(tmp, "log.txt") + + script := fmt.Sprintf("#!/bin/sh\n"+ + "if echo \"$@\" | grep -q export; then\n"+ + " echo '{\"id\":1,\"uuid\":\"x\",\"description\":\"d\",\"status\":\"pending\",\"entry\":\"\",\"priority\":\"\",\"urgency\":0}'\n"+ + " exit 0\n"+ + "fi\n"+ + "echo \"$@\" >> %s\n", logFile) + + if err := os.WriteFile(taskPath, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + + origPath := os.Getenv("PATH") + os.Setenv("PATH", tmp+":"+origPath) + t.Cleanup(func() { os.Setenv("PATH", origPath) }) + + os.Setenv("TASKDATA", tmp) + os.Setenv("TASKRC", "/dev/null") + t.Cleanup(func() { + os.Unsetenv("TASKDATA") + os.Unsetenv("TASKRC") + }) + + m, err := New(nil, "firefox") + if err != nil { + t.Fatalf("New: %v", err) + } + + mv, _ := (&m).Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + m = *mv.(*Model) + if !m.showTaskDetail { + t.Fatalf("enter did not open detail mode") + } + mv, _ = (&m).Update(tea.KeyPressMsg{Code: 'D', Text: "D"}) + m = *mv.(*Model) + if m.showTaskDetail { + t.Fatalf("delete did not close detail mode") + } + + data, err := os.ReadFile(logFile) + if err != nil { + t.Fatalf("read log: %v", err) + } + if got := strings.TrimSpace(string(data)); got != "x modify status:deleted" { + t.Fatalf("detail delete not called: %q", got) + } +} + func TestOpenURLHotkey(t *testing.T) { tmp := t.TempDir() taskPath := filepath.Join(tmp, "task") @@ -1179,6 +1391,35 @@ func setupBasicTask(t *testing.T, tmp string) string { return taskPath } +func setupShellTask(t *testing.T, tmp string) (string, string) { + t.Helper() + taskPath := filepath.Join(tmp, "task") + runFile := filepath.Join(tmp, "run.txt") + script := fmt.Sprintf("#!/bin/sh\n"+ + "if echo \"$@\" | grep -q export; then\n"+ + " echo '{\"id\":1,\"uuid\":\"x\",\"description\":\"alpha\",\"status\":\"pending\",\"entry\":\"\",\"priority\":\"\",\"urgency\":0}'\n"+ + " echo '{\"id\":2,\"uuid\":\"y\",\"description\":\"beta\",\"status\":\"pending\",\"entry\":\"\",\"priority\":\"\",\"urgency\":0}'\n"+ + " exit 0\n"+ + "fi\n"+ + "case \"$1\" in\n"+ + " _commands) printf 'add\\nmodify\\nprojects\\n' ; exit 0 ;;\n"+ + " _columns) printf 'project\\ndue\\nstatus\\npriority\\n' ; exit 0 ;;\n"+ + " _projects) printf 'tasksamurai\\nwork\\n' ; exit 0 ;;\n"+ + " _tags) printf 'urgent\\nagent\\n' ; exit 0 ;;\n"+ + " _ids) printf '1\\n2\\n' ; exit 0 ;;\n"+ + " _uuids) printf 'uuid-1\\nuuid-2\\n' ; exit 0 ;;\n"+ + " _udas) printf 'custom\\n' ; exit 0 ;;\n"+ + "esac\n"+ + "printf '%%s\\n' \"$@\" > %q\n"+ + "if [ \"$1\" = \"projects\" ] || [ \"$2\" = \"projects\" ]; then\n"+ + " printf 'home\\nwork\\n'\n"+ + "fi\n", runFile) + if err := os.WriteFile(taskPath, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + return taskPath, runFile +} + func setupSharedSearchTaskSet(t *testing.T, tmp string) string { taskPath := filepath.Join(tmp, "task") script := "#!/bin/sh\n" + @@ -2168,6 +2409,174 @@ func TestUltraInlineOverlayRenders(t *testing.T) { } } +func TestShellPromptRendersInNormalAndUltraModes(t *testing.T) { + tmp := t.TempDir() + taskPath, _ := setupShellTask(t, tmp) + setupEnv(t, taskPath) + + m, err := New(nil, "firefox") + if err != nil { + t.Fatalf("New: %v", err) + } + mv, _ := (&m).Update(tea.WindowSizeMsg{Width: 80, Height: 24}) + m = *mv.(*Model) + + mv, cmd := (&m).Update(tea.KeyPressMsg{Code: ':', Text: ":"}) + m = *mv.(*Model) + if cmd == nil { + t.Fatalf(": should start async completion loading") + } + mv, _ = (&m).Update(cmd()) + m = *mv.(*Model) + if !m.shellActive { + t.Fatalf(": did not activate shell prompt in normal mode") + } + if !strings.Contains(m.View().Content, "task ") { + t.Fatalf("normal view did not render shell prompt") + } + + mv, _ = (&m).Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + m = *mv.(*Model) + mv, _ = (&m).Update(tea.KeyPressMsg{Code: 'u', Text: "u"}) + m = *mv.(*Model) + mv, cmd = (&m).Update(tea.KeyPressMsg{Code: ':', Text: ":"}) + m = *mv.(*Model) + if !m.shellActive { + t.Fatalf(": did not activate shell prompt in ultra mode") + } + if !strings.Contains(m.View().Content, "task ") { + t.Fatalf("ultra view did not render shell prompt") + } +} + +func TestShellPromptForSelectedTaskPrefillsUUID(t *testing.T) { + tmp := t.TempDir() + taskPath, _ := setupShellTask(t, tmp) + setupEnv(t, taskPath) + + m, err := New(nil, "firefox") + if err != nil { + t.Fatalf("New: %v", err) + } + + mv, cmd := (&m).Update(tea.KeyPressMsg{Code: ';', Text: ";"}) + m = *mv.(*Model) + if !m.shellActive { + t.Fatalf("; did not activate shell prompt in normal mode") + } + if got := m.shellInput.Value(); got != "x " { + t.Fatalf("normal ; prefill = %q, want %q", got, "x ") + } + if cmd == nil { + t.Fatalf("; should start async completion loading") + } + + mv, _ = (&m).Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + m = *mv.(*Model) + mv, _ = (&m).Update(tea.KeyPressMsg{Code: 'u', Text: "u"}) + m = *mv.(*Model) + mv, _ = (&m).Update(tea.KeyPressMsg{Code: 'j', Text: "j"}) + m = *mv.(*Model) + mv, _ = (&m).Update(tea.KeyPressMsg{Code: ';', Text: ";"}) + m = *mv.(*Model) + if !m.shellActive { + t.Fatalf("; did not activate shell prompt in ultra mode") + } + if got := m.shellInput.Value(); got != "y " { + t.Fatalf("ultra ; prefill = %q, want %q", got, "y ") + } +} + +func TestShellPromptExecutesCommandAndShowsOutput(t *testing.T) { + tmp := t.TempDir() + taskPath, runFile := setupShellTask(t, tmp) + setupEnv(t, taskPath) + + m, err := New(nil, "firefox") + if err != nil { + t.Fatalf("New: %v", err) + } + mv, _ := (&m).Update(tea.WindowSizeMsg{Width: 80, Height: 24}) + m = *mv.(*Model) + + mv, _ = (&m).Update(tea.KeyPressMsg{Code: ':', Text: ":"}) + m = *mv.(*Model) + for _, r := range "projects" { + mv, _ = (&m).Update(tea.KeyPressMsg{Code: r, Text: string(r)}) + m = *mv.(*Model) + } + mv, cmd := (&m).Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if cmd == nil { + t.Fatalf("enter did not return shell command") + } + m = *mv.(*Model) + mv, _ = (&m).Update(cmd()) + m = *mv.(*Model) + + data, err := os.ReadFile(runFile) + if err != nil { + t.Fatalf("read run file: %v", err) + } + if strings.TrimSpace(string(data)) != "rc.recurrence.confirmation=no\nprojects" { + t.Fatalf("task command args = %q", data) + } + if !m.shellOutputVisible { + t.Fatalf("command output did not open shell output panel") + } + if view := m.View().Content; !strings.Contains(view, "home") || !strings.Contains(view, "work") { + t.Fatalf("output panel did not render task output: %q", view) + } + + mv, _ = (&m).Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + m = *mv.(*Model) + if m.shellOutputVisible { + t.Fatalf("esc did not close shell output panel") + } +} + +func TestShellPromptTabCompletion(t *testing.T) { + tmp := t.TempDir() + taskPath, _ := setupShellTask(t, tmp) + setupEnv(t, taskPath) + + m, err := New(nil, "firefox") + if err != nil { + t.Fatalf("New: %v", err) + } + + mv, cmd := (&m).Update(tea.KeyPressMsg{Code: ':', Text: ":"}) + m = *mv.(*Model) + if cmd != nil { + mv, _ = (&m).Update(cmd()) + m = *mv.(*Model) + } + for _, r := range "ad" { + mv, _ = (&m).Update(tea.KeyPressMsg{Code: r, Text: string(r)}) + m = *mv.(*Model) + } + mv, _ = (&m).Update(tea.KeyPressMsg{Code: tea.KeyTab}) + m = *mv.(*Model) + if got := m.shellInput.Value(); got != "add" { + t.Fatalf("command completion = %q, want add", got) + } + + m.shellInput.SetValue("project:tas") + m.shellInput.CursorEnd() + mv, _ = (&m).handleShellMode(tea.KeyPressMsg{Code: tea.KeyTab}) + m = *mv.(*Model) + if got := m.shellInput.Value(); got != "project:tasksamurai" { + t.Fatalf("project completion = %q, want project:tasksamurai", got) + } + + m.shellInput.SetValue("+urg") + m.shellInput.CursorEnd() + mv, _ = (&m).handleShellMode(tea.KeyPressMsg{Code: tea.KeyTab}) + m = *mv.(*Model) + if got := m.shellInput.Value(); got != "+urgent" { + t.Fatalf("tag completion = %q, want +urgent", got) + } +} + // TestExpandedCellViewNoDoubleRender is a regression test for a bug where // expandedCellView() was appended to the layout unconditionally AND again // inside the cellExpanded guard, producing a duplicate line when expanded. diff --git a/internal/ui/taskdetail.go b/internal/ui/taskdetail.go index eaf0b45..f8417cf 100644 --- a/internal/ui/taskdetail.go +++ b/internal/ui/taskdetail.go @@ -297,7 +297,7 @@ func (m *Model) renderDetailFooter(lines []string) []string { lines = append(lines, ist.Render("Press ESC or q to return to table view")) lines = append(lines, ist.Render("Use ↑/k and ↓/j to navigate fields")) lines = append(lines, ist.Render("Press i or Enter to edit (Priority, Tags, Due, Recurrence, Description)")) - lines = append(lines, ist.Render("Press d to mark task done, U to undo last done")) + lines = append(lines, ist.Render("Press d to mark task done, D to delete, U to undo last done/delete")) if m.detailSearching { lines = append(lines, ist.Render("Type to search, Enter to confirm")) } else { diff --git a/internal/ui/ultra.go b/internal/ui/ultra.go index d246726..0d5f57b 100644 --- a/internal/ui/ultra.go +++ b/internal/ui/ultra.go @@ -94,7 +94,8 @@ func (m Model) ultraHelpSections() []helpSection { {key: "o", desc: "open URL from description"}, {key: "s", desc: "start/stop task"}, {key: "d", desc: "mark task done"}, - {key: "U", desc: "undo last done"}, + {key: "D", desc: "delete task/recurring series"}, + {key: "U", desc: "undo last done/delete"}, {key: "+", desc: "add new task"}, }, }, @@ -118,6 +119,8 @@ func (m Model) ultraHelpSections() []helpSection { items: []helpItem{ {key: "/", desc: "search ultra cards"}, {key: "n, N", desc: "next/previous match"}, + {key: ":", desc: "run task command prompt"}, + {key: ";", desc: "run task command prompt for selected task"}, }, }, { @@ -397,6 +400,8 @@ func (m *Model) ultraInputOverlay() string { return m.addInput.View() case m.searching: return m.searchInput.View() + case m.shellActive: + return m.shellInput.View() default: return "" } @@ -1055,6 +1060,8 @@ func (m *Model) handleUltraMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { return m.handleUltraToggleStart() case "d": return m.handleUltraMarkDone() + case "D": + return m.handleUltraDeleteTask() case "o": return m.handleOpenURL() case "p": @@ -1077,6 +1084,10 @@ func (m *Model) handleUltraMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { return m.handleUltraSetRecurrence() case "f": return m.handleFilter() + case ":": + return m.handleShellPrompt() + case ";": + return m.handleShellPromptForSelectedTask() case "+": m.ultraClearFocusedID() return m.handleAddTask() @@ -1204,6 +1215,14 @@ func (m *Model) handleUltraMarkDone() (tea.Model, tea.Cmd) { return m, m.startBlink(id, true) } +func (m *Model) handleUltraDeleteTask() (tea.Model, tea.Cmd) { + if _, ok := m.ultraPrepareSelectedTask(); !ok { + return m, nil + } + + return m.handleDeleteTask() +} + func (m *Model) handleUltraSetPriority() (tea.Model, tea.Cmd) { if _, ok := m.ultraPrepareSelectedTask(); !ok { return m, nil diff --git a/internal/version.go b/internal/version.go index 4edc781..0c79945 100644 --- a/internal/version.go +++ b/internal/version.go @@ -1,4 +1,4 @@ package internal // Version is the current version of Task Samurai. -const Version = "0.17.0" +const Version = "0.18.0" -- cgit v1.2.3