summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-06-05 10:26:16 +0300
committerPaul Buetow <paul@buetow.org>2026-06-05 10:26:16 +0300
commit1433f7a13ede0c819ec4f8fd4027ad3df8daa94f (patch)
treed3e98bc150711350585dd8203c5b50cc93243e52
parent0a52adf5752835da01e8a29df15e415398165c48 (diff)
Add 'ask edit' subcommand and collapse multi-line task descriptions
- ask edit opens $EDITOR and creates a task from the (multi-line) content, reusing the shared internal/editor package - collapse newlines in list output and fish completion so multi-line descriptions render on one line and don't break completion Bump version to 0.40.0 Amp-Thread-ID: https://ampcode.com/threads/T-019e96a1-9c8e-73d6-95b4-b55cb12cc762 Co-authored-by: Amp <amp@ampcode.com>
-rw-r--r--internal/askcli/command_add.go8
-rw-r--r--internal/askcli/command_complete_uuids.go6
-rw-r--r--internal/askcli/command_complete_uuids_test.go28
-rw-r--r--internal/askcli/command_edit.go30
-rw-r--r--internal/askcli/command_edit_test.go86
-rw-r--r--internal/askcli/commands_registry.go6
-rw-r--r--internal/askcli/dispatch.go1
-rw-r--r--internal/askcli/formatter.go24
-rw-r--r--internal/version.go2
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"