summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md7
-rw-r--r--internal/task/task.go144
-rw-r--r--internal/task/task_test.go185
-rw-r--r--internal/ui/detail_handlers.go24
-rw-r--r--internal/ui/input_helpers.go3
-rw-r--r--internal/ui/keyactions.go202
-rw-r--r--internal/ui/keyhandlers.go6
-rw-r--r--internal/ui/shell.go389
-rw-r--r--internal/ui/table.go69
-rw-r--r--internal/ui/table_test.go409
-rw-r--r--internal/ui/taskdetail.go2
-rw-r--r--internal/ui/ultra.go21
-rw-r--r--internal/version.go2
13 files changed, 1414 insertions, 49 deletions
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 <filter> 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.HasPre