diff options
| -rw-r--r-- | internal/askcli/command_add.go | 8 | ||||
| -rw-r--r-- | internal/askcli/command_complete_uuids.go | 6 | ||||
| -rw-r--r-- | internal/askcli/command_complete_uuids_test.go | 28 | ||||
| -rw-r--r-- | internal/askcli/command_edit.go | 30 | ||||
| -rw-r--r-- | internal/askcli/command_edit_test.go | 86 | ||||
| -rw-r--r-- | internal/askcli/commands_registry.go | 6 | ||||
| -rw-r--r-- | internal/askcli/dispatch.go | 1 | ||||
| -rw-r--r-- | internal/askcli/formatter.go | 24 | ||||
| -rw-r--r-- | internal/version.go | 2 |
9 files changed, 184 insertions, 7 deletions
diff --git a/internal/askcli/command_add.go b/internal/askcli/command_add.go index 096046d..ccfb034 100644 --- a/internal/askcli/command_add.go +++ b/internal/askcli/command_add.go @@ -27,6 +27,12 @@ func (d *Dispatcher) handleAdd(ctx context.Context, args []string, stdout, stder writeInfoError(stderr, err) return code, nil } + return d.createTask(ctx, modifiers, description, dependencyUUIDs, stdout, stderr) +} + +// createTask creates a Taskwarrior task from the given modifiers, description, +// and resolved dependency UUIDs, assigns an alias, and prints the created task. +func (d *Dispatcher) createTask(ctx context.Context, modifiers []string, description string, dependencyUUIDs []string, stdout, stderr io.Writer) (int, error) { var outBuf bytes.Buffer // rc.verbose=nothing keeps Taskwarrior quiet by default. rc.verbose=new-uuid // then re-enables the UUID-only confirmation we parse below. @@ -36,7 +42,7 @@ func (d *Dispatcher) handleAdd(ctx context.Context, args []string, stdout, stder taskArgs = append(taskArgs, "depends:"+strings.Join(dependencyUUIDs, ",")) } taskArgs = append(taskArgs, description) - code, err = d.runner.Run(ctx, taskArgs, nil, &outBuf, stderr) + code, err := d.runner.Run(ctx, taskArgs, nil, &outBuf, stderr) if code != 0 { return code, err } diff --git a/internal/askcli/command_complete_uuids.go b/internal/askcli/command_complete_uuids.go index bc160b6..5c26649 100644 --- a/internal/askcli/command_complete_uuids.go +++ b/internal/askcli/command_complete_uuids.go @@ -85,9 +85,11 @@ func taskCompletionAliasItems(tasks []TaskExport, aliases map[string]string) []s return items } -// truncateDescription shortens s to at most maxLen characters, appending "…" -// when the string is cut, so the completion hint fits on one line. +// truncateDescription collapses any embedded newlines to keep the completion +// item on a single line, then shortens s to at most maxLen characters, +// appending "…" when the string is cut, so the completion hint fits on one line. func truncateDescription(s string, maxLen int) string { + s = oneLineDescription(s) runes := []rune(s) if len(runes) <= maxLen { return s diff --git a/internal/askcli/command_complete_uuids_test.go b/internal/askcli/command_complete_uuids_test.go index 0859acc..2c1b5fd 100644 --- a/internal/askcli/command_complete_uuids_test.go +++ b/internal/askcli/command_complete_uuids_test.go @@ -255,3 +255,31 @@ func TestTruncateDescription_Unicode(t *testing.T) { t.Fatalf("truncateDescription = %q, want %q", got, "日本語…") } } + +func TestTruncateDescription_CollapsesNewlines(t *testing.T) { + // Multi-line descriptions must collapse to a single line so the + // tab-separated completion output is not broken into bogus entries. + got := truncateDescription("first line\nsecond line\n\nfourth", 100) + if got != "first line second line fourth" { + t.Fatalf("truncateDescription = %q, want single-line collapse", got) + } + if strings.ContainsAny(got, "\r\n") { + t.Fatalf("truncateDescription left a newline in %q", got) + } +} + +func TestOneLineDescription(t *testing.T) { + cases := map[string]string{ + "plain": "plain", + "a\nb": "a b", + "a\r\nb": "a b", + "a\n\n\nb": "a b", + " leading\n indented ": "leading indented", + "keep internal spaces": "keep internal spaces", + } + for in, want := range cases { + if got := oneLineDescription(in); got != want { + t.Fatalf("oneLineDescription(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/internal/askcli/command_edit.go b/internal/askcli/command_edit.go new file mode 100644 index 0000000..585f550 --- /dev/null +++ b/internal/askcli/command_edit.go @@ -0,0 +1,30 @@ +package askcli + +import ( + "context" + "io" + + "codeberg.org/snonux/hexai/internal/editor" +) + +// captureFromEditor opens the user's editor on a temporary file and returns its +// trimmed contents after the editor exits. It is a variable so tests can stub it. +var captureFromEditor = func() (string, error) { + return editor.OpenTempAndEdit(nil) +} + +// handleEdit opens the configured editor on a temporary file and creates a new +// task from the resulting (possibly multi-line) content. +func (d *Dispatcher) handleEdit(ctx context.Context, args []string, stdout, stderr io.Writer) (int, error) { + _ = args + description, err := captureFromEditor() + if err != nil { + writeInfoError(stderr, err) + return 1, nil + } + if description == "" { + _, _ = io.WriteString(stderr, "error: ask edit aborted: empty description\n") + return 1, nil + } + return d.createTask(ctx, nil, description, nil, stdout, stderr) +} diff --git a/internal/askcli/command_edit_test.go b/internal/askcli/command_edit_test.go new file mode 100644 index 0000000..3f4b778 --- /dev/null +++ b/internal/askcli/command_edit_test.go @@ -0,0 +1,86 @@ +package askcli + +import ( + "bytes" + "context" + "errors" + "io" + "strings" + "testing" +) + +func stubEditorCapture(t *testing.T, content string, err error) { + t.Helper() + old := captureFromEditor + captureFromEditor = func() (string, error) { + return content, err + } + t.Cleanup(func() { captureFromEditor = old }) +} + +func TestHandleEdit_Success(t *testing.T) { + now := useIsolatedTaskAliasCache(t) + writeTaskAliasCacheForTest(t, taskAliasCache{ + NextID: 1, + Entries: []taskAliasCacheEntry{ + {UUID: "existing-uuid", Alias: "0", CreatedAt: now}, + }, + }) + // editor.OpenTempAndEdit trims content, so mimic that here. + stubEditorCapture(t, "Multi line\ntask description", nil) + + var capturedArgs []string + d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { + capturedArgs = args + _, _ = io.WriteString(stdout, "Created task abc-123-def.") + return 0, nil + }}) + + var stdout, stderr bytes.Buffer + code, _ := d.Dispatch(context.Background(), []string{"edit"}, nil, &stdout, &stderr) + if code != 0 { + t.Fatalf("edit code = %d stderr = %q", code, stderr.String()) + } + if got := strings.TrimSpace(stdout.String()); got != "created task 1" { + t.Fatalf("stdout = %q, want created task 1", stdout.String()) + } + if len(capturedArgs) == 0 || capturedArgs[len(capturedArgs)-1] != "Multi line\ntask description" { + t.Fatalf("description arg = %v, want trimmed multi-line content", capturedArgs) + } +} + +func TestHandleEdit_EmptyContentAborts(t *testing.T) { + stubEditorCapture(t, "", nil) + + d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { + t.Fatalf("runner should not be called on empty content") + return 0, nil + }}) + + var stdout, stderr bytes.Buffer + code, _ := d.Dispatch(context.Background(), []string{"edit"}, nil, &stdout, &stderr) + if code != 1 { + t.Fatalf("edit code = %d, want 1", code) + } + if !strings.Contains(stderr.String(), "empty description") { + t.Fatalf("stderr = %q, want empty description error", stderr.String()) + } +} + +func TestHandleEdit_EditorError(t *testing.T) { + stubEditorCapture(t, "", errors.New("boom")) + + d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { + t.Fatalf("runner should not be called when editor fails") + return 0, nil + }}) + + var stdout, stderr bytes.Buffer + code, _ := d.Dispatch(context.Background(), []string{"edit"}, nil, &stdout, &stderr) + if code != 1 { + t.Fatalf("edit code = %d, want 1", code) + } + if !strings.Contains(stderr.String(), "boom") { + t.Fatalf("stderr = %q, want editor error", stderr.String()) + } +} diff --git a/internal/askcli/commands_registry.go b/internal/askcli/commands_registry.go index c755560..31773d5 100644 --- a/internal/askcli/commands_registry.go +++ b/internal/askcli/commands_registry.go @@ -207,6 +207,12 @@ func init() { includeInCompletion: true, }) commandRegistry.add(commandEntry{ + name: "edit", + description: "Create a task from $EDITOR content", + handler: wrapSimpleCommand((*Dispatcher).handleEdit), + includeInCompletion: true, + }) + commandRegistry.add(commandEntry{ name: "fish", description: "Emit Fish shell completion script", handler: wrapSimpleCommand((*Dispatcher).handleFish), diff --git a/internal/askcli/dispatch.go b/internal/askcli/dispatch.go index 81b8944..924ca67 100644 --- a/internal/askcli/dispatch.go +++ b/internal/askcli/dispatch.go @@ -80,6 +80,7 @@ func (d *Dispatcher) help(w io.Writer) (int, error) { _, _ = io.WriteString(w, " ask no-agent <subcommand...> Alias for ask na\n") _, _ = io.WriteString(w, "\nSubcommands:\n") _, _ = io.WriteString(w, " ask add [mods...] [depends:<id|uuid>,...] <description...> Create a new task and print created task <id>\n") + _, _ = io.WriteString(w, " ask edit Open $EDITOR and create a task from its content\n") _, _ = 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 completed List completed tasks\n") diff --git a/internal/askcli/formatter.go b/internal/askcli/formatter.go index 8d62125..93b8921 100644 --- a/internal/askcli/formatter.go +++ b/internal/askcli/formatter.go @@ -4,6 +4,7 @@ import ( "fmt" "io" "os" + "regexp" "slices" "strings" @@ -55,14 +56,15 @@ func taskListWidthsFor(tasks []TaskExport, aliases map[string]string, terminalWi widths.Status = max(widths.Status, len(t.Status)) widths.Started = max(widths.Started, len(formatTaskStarted(t))) widths.Tags = max(widths.Tags, len(formatTaskTags(t.Tags))) - longestDescription = max(longestDescription, len(t.Description)) + longestDescription = max(longestDescription, len(oneLineDescription(t.Description))) } widths.Description = taskListDescriptionWidth(widths, terminalWidth, longestDescription) return widths } func writeTaskListHeader(b *strings.Builder, widths taskListWidths) { - fmt.Fprintf(b, "%-*s | %-*s | %-*s | %-*s | %-*s | %-*s | %-*s\n", + fmt.Fprintf( + b, "%-*s | %-*s | %-*s | %-*s | %-*s | %-*s | %-*s\n", widths.Urgency, "Urg", widths.Priority, "Pri", widths.ID, "ID", @@ -79,7 +81,8 @@ func writeTaskListSeparator(b *strings.Builder, widths taskListWidths) { } func writeTaskListRow(b *strings.Builder, widths taskListWidths, t TaskExport, aliases map[string]string) { - fmt.Fprintf(b, "%-*s | %-*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.ID, displayTaskAlias(t.UUID, aliases), @@ -98,6 +101,7 @@ func formatTaskTags(tags []string) string { } func formatTaskDescription(desc string, width int) string { + desc = oneLineDescription(desc) if width <= 0 || len(desc) <= width { return desc } @@ -107,6 +111,20 @@ func formatTaskDescription(desc string, width int) string { return desc[:width-3] + "..." } +// newlineRun matches a run of one or more line breaks together with any inline +// whitespace surrounding them. +var newlineRun = regexp.MustCompile(`[ \t]*[\r\n]+[ \t]*`) + +// oneLineDescription collapses any embedded line breaks in a task description +// into a single space so multi-line descriptions render on one line and never +// break the tab-separated completion output or the task list table. +func oneLineDescription(desc string) string { + if !strings.ContainsAny(desc, "\r\n") { + return desc + } + return strings.TrimSpace(newlineRun.ReplaceAllString(desc, " ")) +} + func formatTaskStarted(t TaskExport) string { if t.Start == "" { return "no" diff --git a/internal/version.go b/internal/version.go index ba6df42..f99e47f 100644 --- a/internal/version.go +++ b/internal/version.go @@ -1,4 +1,4 @@ // Package internal provides the Hexai semantic version identifier used by CLI and LSP binaries. package internal -const Version = "0.39.5" +const Version = "0.40.0" |
