summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--integrationtests/ask_scope_test.go7
-rw-r--r--internal/askcli/command_complete_uuids.go50
-rw-r--r--internal/askcli/command_complete_uuids_test.go69
-rw-r--r--internal/askcli/completion.go5
-rw-r--r--internal/askcli/completion_test.go4
-rw-r--r--internal/askcli/dispatch_test.go7
-rw-r--r--internal/version.go2
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"