diff options
| -rw-r--r-- | integrationtests/ask_scope_test.go | 7 | ||||
| -rw-r--r-- | internal/askcli/command_complete_uuids.go | 50 | ||||
| -rw-r--r-- | internal/askcli/command_complete_uuids_test.go | 69 | ||||
| -rw-r--r-- | internal/askcli/completion.go | 5 | ||||
| -rw-r--r-- | internal/askcli/completion_test.go | 4 | ||||
| -rw-r--r-- | internal/askcli/dispatch_test.go | 7 | ||||
| -rw-r--r-- | internal/version.go | 2 |
7 files changed, 130 insertions, 14 deletions
diff --git a/integrationtests/ask_scope_test.go b/integrationtests/ask_scope_test.go index ef77757..a328881 100644 --- a/integrationtests/ask_scope_test.go +++ b/integrationtests/ask_scope_test.go @@ -75,9 +75,14 @@ func hasTag(tags []string, want string) bool { return false } +// hasSelectorLine reports whether any line in output starts with want followed +// by a tab or end-of-line. This handles the "selector\tdescription" format +// emitted by complete-uuids for fish shell autocompletion. func hasSelectorLine(output, want string) bool { for _, line := range strings.Split(strings.TrimSpace(output), "\n") { - if strings.TrimSpace(line) == want { + line = strings.TrimSpace(line) + // Accept exact match (no description) or tab-prefixed description. + if line == want || strings.HasPrefix(line, want+"\t") { return true } } diff --git a/internal/askcli/command_complete_uuids.go b/internal/askcli/command_complete_uuids.go index 32af2a5..8c00a7b 100644 --- a/internal/askcli/command_complete_uuids.go +++ b/internal/askcli/command_complete_uuids.go @@ -5,8 +5,13 @@ import ( "context" "fmt" "io" + "strings" ) +// completionDescriptionMaxLen is the maximum number of characters of a task +// description included in fish shell completion suggestions. +const completionDescriptionMaxLen = 60 + func (d *Dispatcher) handleCompleteUUIDs(ctx context.Context, args []string, stdout, stderr io.Writer) (int, error) { _ = args var outBuf bytes.Buffer @@ -24,12 +29,44 @@ func (d *Dispatcher) handleCompleteUUIDs(ctx context.Context, args []string, std fmt.Fprintf(stderr, "warning: failed to update task alias cache: %v\n", err) aliases = nil } - for _, selector := range taskCompletionSelectors(tasks, aliases) { - _, _ = io.WriteString(stdout, selector+"\n") + // Each line is "selector\tdescription" so fish shell can show the task + // summary alongside the ID in the autocompletion menu. + for _, item := range taskCompletionItems(tasks, aliases) { + _, _ = io.WriteString(stdout, item+"\n") } return 0, nil } +// taskCompletionItems returns tab-separated "selector\tdescription" strings +// for each task, placing the short alias before the UUID when available. +// Fish shell interprets the tab-separated format to display descriptions. +func taskCompletionItems(tasks []TaskExport, aliases map[string]string) []string { + items := make([]string, 0, len(tasks)*2) + for _, task := range tasks { + if task.UUID == "" { + continue + } + desc := truncateDescription(task.Description, completionDescriptionMaxLen) + if alias := displayTaskAlias(task.UUID, aliases); alias != "" && alias != task.UUID { + items = append(items, alias+"\t"+desc) + } + items = append(items, task.UUID+"\t"+desc) + } + return items +} + +// truncateDescription 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 { + runes := []rune(s) + if len(runes) <= maxLen { + return s + } + return string(runes[:maxLen]) + "…" +} + +// taskCompletionSelectors returns plain selector strings (no descriptions) for +// use in contexts that do not support tab-separated fish completion items. func taskCompletionSelectors(tasks []TaskExport, aliases map[string]string) []string { selectors := make([]string, 0, len(tasks)*2) for _, task := range tasks { @@ -43,3 +80,12 @@ func taskCompletionSelectors(tasks []TaskExport, aliases map[string]string) []st } return selectors } + +// completionItemSelector extracts the selector (before the tab) from a +// tab-separated completion item returned by taskCompletionItems. +func completionItemSelector(item string) string { + if idx := strings.IndexByte(item, '\t'); idx >= 0 { + return item[:idx] + } + return item +} diff --git a/internal/askcli/command_complete_uuids_test.go b/internal/askcli/command_complete_uuids_test.go index 94e5693..442e0a8 100644 --- a/internal/askcli/command_complete_uuids_test.go +++ b/internal/askcli/command_complete_uuids_test.go @@ -27,7 +27,7 @@ func TestHandleCompleteUUIDs_PrintsPendingUUIDs(t *testing.T) { if strings.Join(args, " ") != strings.Join(want, " ") { t.Fatalf("args = %v, want %v", args, want) } - _, _ = io.WriteString(stdout, `[{"uuid":"uuid-1"},{"uuid":"uuid-2"},{"uuid":""}]`) + _, _ = io.WriteString(stdout, `[{"uuid":"uuid-1","description":"First task"},{"uuid":"uuid-2","description":"Second task"},{"uuid":""}]`) return 0, nil }}) @@ -39,8 +39,10 @@ func TestHandleCompleteUUIDs_PrintsPendingUUIDs(t *testing.T) { if code != 0 { t.Fatalf("handleCompleteUUIDs code = %d, want 0", code) } - if got := stdout.String(); got != "0\nuuid-1\n1\nuuid-2\n" { - t.Fatalf("stdout = %q, want alias-first selector list", got) + // Each line is "selector\tdescription" so fish shell shows the task + // summary alongside the alias/UUID in the autocompletion menu. + if got := stdout.String(); got != "0\tFirst task\nuuid-1\tFirst task\n1\tSecond task\nuuid-2\tSecond task\n" { + t.Fatalf("stdout = %q, want tab-separated selector+description list", got) } if stderr.Len() != 0 { t.Fatalf("stderr = %q, want empty", stderr.String()) @@ -96,7 +98,7 @@ func TestHandleCompleteUUIDs_WarnsOnInvalidAliasCache(t *testing.T) { } d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { - _, _ = io.WriteString(stdout, `[{"uuid":"uuid-1"}]`) + _, _ = io.WriteString(stdout, `[{"uuid":"uuid-1","description":"Fallback task"}]`) return 0, nil }}) @@ -108,8 +110,9 @@ func TestHandleCompleteUUIDs_WarnsOnInvalidAliasCache(t *testing.T) { if code != 0 { t.Fatalf("handleCompleteUUIDs code = %d, want 0", code) } - if got := stdout.String(); got != "uuid-1\n" { - t.Fatalf("stdout = %q, want UUID-only fallback list", got) + // When alias cache is unavailable, output UUID with description (tab-separated). + if got := stdout.String(); got != "uuid-1\tFallback task\n" { + t.Fatalf("stdout = %q, want UUID-only fallback list with description", got) } if !strings.Contains(stderr.String(), "failed to update task alias cache") { t.Fatalf("stderr = %q, want cache warning", stderr.String()) @@ -133,3 +136,57 @@ func TestTaskCompletionSelectors_SkipsMissingAndDuplicateAliases(t *testing.T) { t.Fatalf("taskCompletionSelectors = %v, want %v", got, want) } } + +func TestTaskCompletionItems_IncludesDescriptions(t *testing.T) { + tasks := []TaskExport{ + {UUID: "uuid-1", Description: "First task"}, + {UUID: "", Description: "Ignored"}, + {UUID: "uuid-2", Description: "Second task"}, + } + aliases := map[string]string{ + "uuid-1": "0", + "uuid-2": "uuid-2", // same as UUID, so alias is skipped + } + + got := taskCompletionItems(tasks, aliases) + // Alias "0" differs from UUID so it gets its own entry; "uuid-2" matches + // UUID so no alias entry is emitted for it. + want := []string{ + "0\tFirst task", + "uuid-1\tFirst task", + "uuid-2\tSecond task", + } + if strings.Join(got, "\n") != strings.Join(want, "\n") { + t.Fatalf("taskCompletionItems = %v, want %v", got, want) + } +} + +func TestTruncateDescription_ShortString(t *testing.T) { + got := truncateDescription("hello", 10) + if got != "hello" { + t.Fatalf("truncateDescription = %q, want %q", got, "hello") + } +} + +func TestTruncateDescription_ExactLength(t *testing.T) { + got := truncateDescription("hello", 5) + if got != "hello" { + t.Fatalf("truncateDescription = %q, want %q", got, "hello") + } +} + +func TestTruncateDescription_LongString(t *testing.T) { + got := truncateDescription("hello world", 5) + if got != "hello…" { + t.Fatalf("truncateDescription = %q, want %q", got, "hello…") + } +} + +func TestTruncateDescription_Unicode(t *testing.T) { + // Japanese characters are multi-byte but each is one rune, so maxLen=3 + // should cut at 3 runes. + got := truncateDescription("日本語テスト", 3) + if got != "日本語…" { + t.Fatalf("truncateDescription = %q, want %q", got, "日本語…") + } +} diff --git a/internal/askcli/completion.go b/internal/askcli/completion.go index a396029..0d8b584 100644 --- a/internal/askcli/completion.go +++ b/internal/askcli/completion.go @@ -308,7 +308,10 @@ func writeFishAddDependencyModifierFunction(b *strings.Builder) { b.WriteString(" set chosen $pieces[1..-2]\n") b.WriteString(" end\n") b.WriteString(" end\n") - b.WriteString(" for selector in (__ask_task_selectors)\n") + // Each item from __ask_task_selectors is "selector\tdescription"; extract + // just the selector (before the tab) for matching and output purposes. + b.WriteString(" for item in (__ask_task_selectors)\n") + b.WriteString(" set -l selector (string split -m1 '\\t' -- $item)[1]\n") b.WriteString(" if contains -- $selector $chosen\n") b.WriteString(" continue\n") b.WriteString(" end\n") diff --git a/internal/askcli/completion_test.go b/internal/askcli/completion_test.go index f43fa32..baa3d84 100644 --- a/internal/askcli/completion_test.go +++ b/internal/askcli/completion_test.go @@ -33,6 +33,10 @@ func TestFishCompletion_IncludesCommandsAndExcludesExport(t *testing.T) { "complete -c ask -n '__ask_in_uuid_context' -a '(__ask_task_selectors)' -d 'Task selector'", "complete -c ask -n '__ask_in_dep_uuid_context' -a '(__ask_task_selectors)' -d 'Task selector'", "complete -c ask -n '__ask_in_add_dep_modifier_context' -a '(__ask_add_dependency_modifiers)' -d 'Task dependency'", + // The dep modifier function must extract just the selector (before the + // tab) from each tab-separated "selector\tdescription" completion item. + "for item in (__ask_task_selectors)", + "set -l selector (string split -m1 '\\t' -- $item)[1]", } { if !strings.Contains(script, line) { t.Fatalf("script missing dep completion line %q", line) diff --git a/internal/askcli/dispatch_test.go b/internal/askcli/dispatch_test.go index 079d156..e027b43 100644 --- a/internal/askcli/dispatch_test.go +++ b/internal/askcli/dispatch_test.go @@ -73,7 +73,7 @@ func TestDispatcher_CompleteUUIDsSubcommand(t *testing.T) { if strings.Join(args, " ") != "status:pending export" { t.Fatalf("args = %v, want pending export", args) } - _, _ = io.WriteString(stdout, `[{"uuid":"uuid-1"}]`) + _, _ = io.WriteString(stdout, `[{"uuid":"uuid-1","description":"Sample task"}]`) return 0, nil }}) var stdout, stderr bytes.Buffer @@ -84,8 +84,9 @@ func TestDispatcher_CompleteUUIDsSubcommand(t *testing.T) { if code != 0 { t.Fatalf("complete-uuids code = %d, want 0", code) } - if got := stdout.String(); got != "0\nuuid-1\n" { - t.Fatalf("stdout = %q, want selector list", got) + // Output is "selector\tdescription" for fish shell autocompletion display. + if got := stdout.String(); got != "0\tSample task\nuuid-1\tSample task\n" { + t.Fatalf("stdout = %q, want tab-separated selector list", got) } } diff --git a/internal/version.go b/internal/version.go index b9f06e0..f2804f2 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.28.2" +const Version = "0.29.0" |
