summaryrefslogtreecommitdiff
path: root/internal/ui
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-06-22 09:07:07 +0300
committerPaul Buetow <paul@buetow.org>2026-06-22 09:07:07 +0300
commit02f5f1419c4cb5fccc47a0ce4dbf3957e73b0e79 (patch)
tree5ad976209e913bfcf8a022ea1d4c0d3cb0d45c60 /internal/ui
parente2795b421a92ec16a64b1780dd71cb1a68b92680 (diff)
Add Taskwarrior shell promptv0.18.0
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.
Diffstat (limited to 'internal/ui')
-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
9 files changed, 1093 insertions, 32 deletions
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(