diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-26 23:21:47 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-26 23:21:47 +0200 |
| commit | de7c0d61f5e1d195062f41f42dd1acd8d4e4e24a (patch) | |
| tree | fd21714eb743ad56d9d74dcc65480345893512aa | |
| parent | b0392db09b960e70caf73db41cc74c9182733935 (diff) | |
Implement ask started-task info 4c3640dc-3730-40c9-bfa6-db90564e3171
| -rw-r--r-- | docs/usage.md | 2 | ||||
| -rw-r--r-- | internal/askcli/command_info_add.go | 73 | ||||
| -rw-r--r-- | internal/askcli/command_info_add_test.go | 47 | ||||
| -rw-r--r-- | internal/askcli/command_list_test.go | 5 | ||||
| -rw-r--r-- | internal/askcli/dispatch.go | 2 | ||||
| -rw-r--r-- | internal/askcli/formatter.go | 21 | ||||
| -rw-r--r-- | internal/askcli/formatter_test.go | 15 |
7 files changed, 138 insertions, 27 deletions
diff --git a/docs/usage.md b/docs/usage.md index 2a02e05..87a6778 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -146,7 +146,7 @@ cat SOMEFILE.txt | hexai --tps-simulation 20 | `ask list started` | List started tasks | | `ask list limit:N` | Limit results | | `ask list sort:priority-,urgency-` | Sort by priority then urgency | -| `ask info <uuid>` | Show task details | +| `ask info [uuid]` | Show task details, or the current started task if UUID is omitted | | `ask annotate <uuid> "note"` | Add annotation | | `ask start <uuid>` | Start working on a task | | `ask stop <uuid>` | Stop work on a task | diff --git a/internal/askcli/command_info_add.go b/internal/askcli/command_info_add.go index 11aea7b..9a7b518 100644 --- a/internal/askcli/command_info_add.go +++ b/internal/askcli/command_info_add.go @@ -10,24 +10,10 @@ import ( ) func (d Dispatcher) handleInfo(ctx context.Context, args []string, stdout, stderr io.Writer) (int, error) { - if len(args) < 2 { - io.WriteString(stderr, "error: ask info requires a UUID argument\n") - return 1, nil - } - uuid := NormalizeUUID(args[1]) - if IsNumericID(uuid) { - io.WriteString(stderr, RejectNumericID()) - return 1, nil - } - var outBuf bytes.Buffer - code, err := d.runner.Run(ctx, []string{"uuid:" + uuid, "export"}, nil, &outBuf, stderr) - if code != 0 { - return code, err - } - tasks, err := ParseTaskExport(&outBuf) - if err != nil || len(tasks) == 0 { - io.WriteString(stderr, "error: task not found\n") - return 1, nil + tasks, code, err := d.infoTasks(ctx, args, stderr) + if err != nil { + writeInfoError(stderr, err) + return code, nil } if d.jsonOutput { data, err := json.Marshal(tasks) @@ -43,6 +29,57 @@ func (d Dispatcher) handleInfo(ctx context.Context, args []string, stdout, stder return 0, nil } +func (d Dispatcher) infoTasks(ctx context.Context, args []string, stderr io.Writer) ([]TaskExport, int, error) { + if len(args) >= 2 { + uuid := NormalizeUUID(args[1]) + if IsNumericID(uuid) { + return nil, 1, fmt.Errorf(strings.TrimSpace(RejectNumericID())) + } + return d.exportTasks(ctx, []string{"uuid:" + uuid, "export"}, stderr) + } + return d.startedInfoTasks(ctx, stderr) +} + +func (d Dispatcher) startedInfoTasks(ctx context.Context, stderr io.Writer) ([]TaskExport, int, error) { + tasks, code, err := d.exportTasks(ctx, []string{"started", "export"}, stderr) + if err != nil { + return nil, code, err + } + switch len(tasks) { + case 0: + return nil, 1, fmt.Errorf("no started task found") + case 1: + return tasks, 0, nil + default: + return nil, 1, fmt.Errorf("multiple started tasks found; pass a UUID explicitly") + } +} + +func (d Dispatcher) exportTasks(ctx context.Context, args []string, stderr io.Writer) ([]TaskExport, int, error) { + var outBuf bytes.Buffer + code, err := d.runner.Run(ctx, args, nil, &outBuf, stderr) + if code != 0 { + return nil, code, err + } + tasks, err := ParseTaskExport(&outBuf) + if err != nil { + return nil, 1, err + } + if len(tasks) == 0 && len(args) > 0 && strings.HasPrefix(args[0], "uuid:") { + return nil, 1, fmt.Errorf("task not found") + } + return tasks, 0, nil +} + +func writeInfoError(stderr io.Writer, err error) { + msg := err.Error() + if strings.HasPrefix(msg, "error:") { + io.WriteString(stderr, msg+"\n") + return + } + fmt.Fprintf(stderr, "error: %v\n", err) +} + func (d Dispatcher) handleAdd(ctx context.Context, args []string, stdout, stderr io.Writer) (int, error) { if len(args) < 2 { io.WriteString(stderr, "error: ask add requires a description\n") diff --git a/internal/askcli/command_info_add_test.go b/internal/askcli/command_info_add_test.go index fc36930..9bc13df 100644 --- a/internal/askcli/command_info_add_test.go +++ b/internal/askcli/command_info_add_test.go @@ -29,6 +29,9 @@ func TestHandleInfo_Success(t *testing.T) { if !strings.Contains(output, "H") { t.Fatalf("output missing priority: %s", output) } + if !strings.Contains(output, "Started: no") { + t.Fatalf("output missing explicit started state: %s", output) + } } func TestHandleInfo_NumericID(t *testing.T) { @@ -43,13 +46,55 @@ func TestHandleInfo_NumericID(t *testing.T) { } func TestHandleInfo_MissingUUID(t *testing.T) { + jsonData := `[{"uuid":"started-uuid","description":"Started task","status":"pending","priority":"M","start":"2026-03-26T10:00:00Z","urgency":5.0,"depends":[]}]` + d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { + if len(args) == 2 && args[0] == "started" && args[1] == "export" { + io.WriteString(stdout, jsonData) + } + return 0, nil + }}) + var stdout, stderr bytes.Buffer + code, _ := d.Dispatch(context.Background(), []string{"info"}, nil, &stdout, &stderr) + if code != 0 { + t.Fatalf("info code = %d, want 0 for implicit started task", code) + } + if !strings.Contains(stdout.String(), "started-uuid") { + t.Fatalf("output missing started task UUID: %s", stdout.String()) + } +} + +func TestHandleInfo_MissingUUID_NoStartedTask(t *testing.T) { d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { + if len(args) == 2 && args[0] == "started" && args[1] == "export" { + io.WriteString(stdout, "[]") + } return 0, nil }}) var stdout, stderr bytes.Buffer code, _ := d.Dispatch(context.Background(), []string{"info"}, nil, &stdout, &stderr) if code != 1 { - t.Fatalf("info code = %d, want 1 for missing UUID", code) + t.Fatalf("info code = %d, want 1 when no started task exists", code) + } + if !strings.Contains(stderr.String(), "no started task found") { + t.Fatalf("stderr = %q, want no-started-task error", stderr.String()) + } +} + +func TestHandleInfo_MissingUUID_MultipleStartedTasks(t *testing.T) { + jsonData := `[{"uuid":"started-1","description":"Started task 1","status":"pending","priority":"M","start":"2026-03-26T10:00:00Z","urgency":5.0,"depends":[]},{"uuid":"started-2","description":"Started task 2","status":"pending","priority":"H","start":"2026-03-26T11:00:00Z","urgency":8.0,"depends":[]}]` + d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { + if len(args) == 2 && args[0] == "started" && args[1] == "export" { + io.WriteString(stdout, jsonData) + } + return 0, nil + }}) + var stdout, stderr bytes.Buffer + code, _ := d.Dispatch(context.Background(), []string{"info"}, nil, &stdout, &stderr) + if code != 1 { + t.Fatalf("info code = %d, want 1 when multiple started tasks exist", code) + } + if !strings.Contains(stderr.String(), "multiple started tasks found") { + t.Fatalf("stderr = %q, want multiple-started-tasks error", stderr.String()) } } diff --git a/internal/askcli/command_list_test.go b/internal/askcli/command_list_test.go index 903f397..dade889 100644 --- a/internal/askcli/command_list_test.go +++ b/internal/askcli/command_list_test.go @@ -9,7 +9,7 @@ import ( ) func TestHandleList_Success(t *testing.T) { - jsonData := `[{"uuid":"uuid-1","description":"Task 1","status":"pending","priority":"H","tags":["cli"],"urgency":15.0,"depends":[]},{"uuid":"uuid-2","description":"Task 2","status":"completed","priority":"M","tags":["agent"],"urgency":10.0,"depends":[]}]` + jsonData := `[{"uuid":"uuid-1","description":"Task 1","status":"pending","priority":"H","tags":["cli"],"start":"2026-03-26T10:00:00Z","urgency":15.0,"depends":[]},{"uuid":"uuid-2","description":"Task 2","status":"completed","priority":"M","tags":["agent"],"urgency":10.0,"depends":[]}]` d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { for _, arg := range args { if arg == "export" { @@ -28,6 +28,9 @@ func TestHandleList_Success(t *testing.T) { if !strings.Contains(output, "uuid-1") || !strings.Contains(output, "uuid-2") { t.Fatalf("output missing UUIDs: %s", output) } + if !strings.Contains(output, "Started") || !strings.Contains(output, "yes") || !strings.Contains(output, "no") { + t.Fatalf("output missing explicit started state: %s", output) + } } func TestHandleList_SortedByPriority(t *testing.T) { diff --git a/internal/askcli/dispatch.go b/internal/askcli/dispatch.go index e828989..b361111 100644 --- a/internal/askcli/dispatch.go +++ b/internal/askcli/dispatch.go @@ -92,7 +92,7 @@ func (d Dispatcher) help(w io.Writer) (int, error) { io.WriteString(w, " ask list [filters] List active tasks (default)\n") io.WriteString(w, " ask ready List READY tasks (not blocked)\n") io.WriteString(w, " ask all [filters] List all tasks including completed/deleted\n") - io.WriteString(w, " ask info <uuid> Show task details\n") + io.WriteString(w, " ask info [uuid] Show task details or current started task\n") io.WriteString(w, " ask annotate <uuid> \"note\" Add annotation to task\n") io.WriteString(w, " ask start <uuid> Start working on task\n") io.WriteString(w, " ask stop <uuid> Stop working on task\n") diff --git a/internal/askcli/formatter.go b/internal/askcli/formatter.go index 828588b..f4c0104 100644 --- a/internal/askcli/formatter.go +++ b/internal/askcli/formatter.go @@ -22,6 +22,7 @@ type taskListWidths struct { Priority int UUID int Status int + Started int Tags int Description int } @@ -32,6 +33,7 @@ func taskListWidthsFor(tasks []TaskExport) taskListWidths { Priority: len("Priority"), UUID: len("UUID"), Status: len("Status"), + Started: len("Started"), Tags: len("Tags"), Description: len("Description"), } @@ -40,6 +42,7 @@ func taskListWidthsFor(tasks []TaskExport) taskListWidths { widths.Priority = maxInt(widths.Priority, len(t.Priority)) widths.UUID = maxInt(widths.UUID, len(t.UUID)) widths.Status = maxInt(widths.Status, len(t.Status)) + widths.Started = maxInt(widths.Started, len(formatTaskStarted(t))) widths.Tags = maxInt(widths.Tags, len(formatTaskTags(t.Tags))) widths.Description = maxInt(widths.Description, len(formatTaskDescription(t.Description))) } @@ -47,27 +50,29 @@ func taskListWidthsFor(tasks []TaskExport) taskListWidths { } func writeTaskListHeader(b *strings.Builder, widths taskListWidths) { - fmt.Fprintf(b, "%-*s | %-*s | %-*s | %-*s | %-*s | %-*s\n", + fmt.Fprintf(b, "%-*s | %-*s | %-*s | %-*s | %-*s | %-*s | %-*s\n", widths.Urgency, "Urgency", widths.Priority, "Priority", widths.UUID, "UUID", widths.Status, "Status", + widths.Started, "Started", widths.Tags, "Tags", widths.Description, "Description", ) } func writeTaskListSeparator(b *strings.Builder, widths taskListWidths) { - total := widths.Urgency + widths.Priority + widths.UUID + widths.Status + widths.Tags + widths.Description + 15 + total := widths.Urgency + widths.Priority + widths.UUID + widths.Status + widths.Started + widths.Tags + widths.Description + 18 io.WriteString(b, strings.Repeat("-", total)+"\n") } func writeTaskListRow(b *strings.Builder, widths taskListWidths, t TaskExport) { - fmt.Fprintf(b, "%-*s | %-*s | %-*s | %-*s | %-*s | %-*s\n", + fmt.Fprintf(b, "%-*s | %-*s | %-*s | %-*s | %-*s | %-*s | %-*s\n", widths.Urgency, fmt.Sprintf("%.1f", t.Urgency), widths.Priority, t.Priority, widths.UUID, t.UUID, widths.Status, t.Status, + widths.Started, formatTaskStarted(t), widths.Tags, formatTaskTags(t.Tags), widths.Description, formatTaskDescription(t.Description), ) @@ -87,6 +92,13 @@ func formatTaskDescription(desc string) string { return desc } +func formatTaskStarted(t TaskExport) string { + if t.Start == "" { + return "no" + } + return "yes" +} + func maxInt(a, b int) int { if a > b { return a @@ -99,13 +111,14 @@ func FormatTaskInfo(t TaskExport) string { fmt.Fprintf(&b, "UUID: %s\n", t.UUID) fmt.Fprintf(&b, "Description: %s\n", t.Description) fmt.Fprintf(&b, "Status: %s\n", t.Status) + fmt.Fprintf(&b, "Started: %s\n", formatTaskStarted(t)) fmt.Fprintf(&b, "Priority: %s\n", t.Priority) fmt.Fprintf(&b, "Urgency: %.1f\n", t.Urgency) if len(t.Tags) > 0 { fmt.Fprintf(&b, "Tags: %s\n", strings.Join(t.Tags, ", ")) } if t.Start != "" { - fmt.Fprintf(&b, "Started: %s\n", t.Start) + fmt.Fprintf(&b, "Start time: %s\n", t.Start) } if len(t.Depends) > 0 { fmt.Fprintf(&b, "Depends: %s\n", strings.Join(t.Depends, ", ")) diff --git a/internal/askcli/formatter_test.go b/internal/askcli/formatter_test.go index adb1e6e..52632f5 100644 --- a/internal/askcli/formatter_test.go +++ b/internal/askcli/formatter_test.go @@ -20,6 +20,9 @@ func TestFormatTaskList(t *testing.T) { if !strings.Contains(lines[0], "UUID") || !strings.Contains(lines[0], "Priority") { t.Fatalf("header missing UUID or Priority column: %s", lines[0]) } + if !strings.Contains(lines[0], "Started") { + t.Fatalf("header missing Started column: %s", lines[0]) + } if !strings.Contains(lines[2], "uuid-1") { t.Fatalf("first task line missing uuid-1: %s", lines[2]) } @@ -55,11 +58,12 @@ func TestFormatTaskList_AlignsHeaderAndSeparator(t *testing.T) { } widths := taskListWidthsFor(tasks) - wantHeader := fmt.Sprintf("%-*s | %-*s | %-*s | %-*s | %-*s | %-*s", + wantHeader := fmt.Sprintf("%-*s | %-*s | %-*s | %-*s | %-*s | %-*s | %-*s", widths.Urgency, "Urgency", widths.Priority, "Priority", widths.UUID, "UUID", widths.Status, "Status", + widths.Started, "Started", widths.Tags, "Tags", widths.Description, "Description", ) @@ -95,6 +99,12 @@ func TestFormatTaskInfo(t *testing.T) { if !strings.Contains(output, "H") { t.Fatalf("FormatTaskInfo missing priority H: %s", output) } + if !strings.Contains(output, "Started: yes") { + t.Fatalf("FormatTaskInfo missing explicit started state: %s", output) + } + if !strings.Contains(output, "Start time: 2026-03-22T10:00:00Z") { + t.Fatalf("FormatTaskInfo missing start timestamp: %s", output) + } if !strings.Contains(output, "cli, agent") { t.Fatalf("FormatTaskInfo missing tags: %s", output) } @@ -189,6 +199,9 @@ func TestFormatTaskInfo_NoOptionalFields(t *testing.T) { if !strings.Contains(output, "simple-uuid") { t.Fatalf("FormatTaskInfo missing UUID: %s", output) } + if !strings.Contains(output, "Started: no") { + t.Fatalf("FormatTaskInfo should show Started: no when not started: %s", output) + } if strings.Contains(output, "Tags:") { t.Fatalf("FormatTaskInfo should not contain Tags: for empty tags: %s", output) } |
