summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/fish-completion.md1
-rw-r--r--internal/askcli/command_dep_test.go10
-rw-r--r--internal/askcli/command_write_test.go78
-rw-r--r--internal/askcli/completion.go51
-rw-r--r--internal/askcli/completion_test.go53
5 files changed, 144 insertions, 49 deletions
diff --git a/docs/fish-completion.md b/docs/fish-completion.md
index d7e7289..f51d362 100644
--- a/docs/fish-completion.md
+++ b/docs/fish-completion.md
@@ -5,6 +5,7 @@ The `ask` task-management CLI embeds its Fish completion script in the binary an
It completes the top-level `ask` subcommands and the nested `ask dep` operations.
It also completes task selectors for UUID-taking commands by reading pending tasks through `ask complete-uuids`, which uses the local alias cache for stable short IDs.
Fish suggests each task's alias ID first and also keeps the raw UUID available as a fallback selector.
+Selector suggestions stop once a command has consumed its selector argument, and `ask dep add` / `ask dep rm` suggest selectors for both task positions.
The script preserves the global `--json` flag.
Load it into the current Fish session:
diff --git a/internal/askcli/command_dep_test.go b/internal/askcli/command_dep_test.go
index 408ef86..f674d28 100644
--- a/internal/askcli/command_dep_test.go
+++ b/internal/askcli/command_dep_test.go
@@ -58,6 +58,16 @@ func TestHandleDep_AddSuccess(t *testing.T) {
}
func TestHandleDep_RmSuccess(t *testing.T) {
+ dir := t.TempDir()
+ oldRoot := taskAliasCacheRoot
+ oldNow := nowTaskAliasCache
+ taskAliasCacheRoot = func() (string, error) { return filepath.Join(dir, "hexai"), nil }
+ nowTaskAliasCache = func() time.Time { return time.Date(2026, 3, 26, 12, 0, 0, 0, time.UTC) }
+ defer func() {
+ taskAliasCacheRoot = oldRoot
+ nowTaskAliasCache = oldNow
+ }()
+
d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) {
if len(args) == 2 && args[1] == "export" {
switch args[0] {
diff --git a/internal/askcli/command_write_test.go b/internal/askcli/command_write_test.go
index 31ea25a..981df8a 100644
--- a/internal/askcli/command_write_test.go
+++ b/internal/askcli/command_write_test.go
@@ -10,21 +10,29 @@ import (
"time"
)
-func TestHandleStart_AliasSelector(t *testing.T) {
+func useIsolatedTaskAliasCache(t *testing.T) time.Time {
+ t.Helper()
+
dir := t.TempDir()
oldRoot := taskAliasCacheRoot
oldNow := nowTaskAliasCache
+ fixedNow := time.Date(2026, 3, 26, 12, 0, 0, 0, time.UTC)
taskAliasCacheRoot = func() (string, error) { return filepath.Join(dir, "hexai"), nil }
- nowTaskAliasCache = func() time.Time { return time.Date(2026, 3, 26, 12, 0, 0, 0, time.UTC) }
- defer func() {
+ nowTaskAliasCache = func() time.Time { return fixedNow }
+ t.Cleanup(func() {
taskAliasCacheRoot = oldRoot
nowTaskAliasCache = oldNow
- }()
+ })
+ return fixedNow
+}
+
+func TestHandleStart_AliasSelector(t *testing.T) {
+ now := useIsolatedTaskAliasCache(t)
writeTaskAliasCacheForTest(t, taskAliasCache{
NextID: 1,
Entries: []taskAliasCacheEntry{
- {UUID: "test-uuid", Alias: "0", CreatedAt: nowTaskAliasCache()},
+ {UUID: "test-uuid", Alias: "0", CreatedAt: now},
},
})
@@ -49,20 +57,12 @@ func TestHandleStart_AliasSelector(t *testing.T) {
}
func TestHandleDenotate_Success(t *testing.T) {
- dir := t.TempDir()
- oldRoot := taskAliasCacheRoot
- oldNow := nowTaskAliasCache
- taskAliasCacheRoot = func() (string, error) { return filepath.Join(dir, "hexai"), nil }
- nowTaskAliasCache = func() time.Time { return time.Date(2026, 3, 26, 12, 0, 0, 0, time.UTC) }
- defer func() {
- taskAliasCacheRoot = oldRoot
- nowTaskAliasCache = oldNow
- }()
+ now := useIsolatedTaskAliasCache(t)
writeTaskAliasCacheForTest(t, taskAliasCache{
NextID: 1,
Entries: []taskAliasCacheEntry{
- {UUID: "test-uuid", Alias: "0", CreatedAt: nowTaskAliasCache()},
+ {UUID: "test-uuid", Alias: "0", CreatedAt: now},
},
})
@@ -111,20 +111,12 @@ func TestHandleDenotate_MissingArgs(t *testing.T) {
}
func TestHandleModify_Success(t *testing.T) {
- dir := t.TempDir()
- oldRoot := taskAliasCacheRoot
- oldNow := nowTaskAliasCache
- taskAliasCacheRoot = func() (string, error) { return filepath.Join(dir, "hexai"), nil }
- nowTaskAliasCache = func() time.Time { return time.Date(2026, 3, 26, 12, 0, 0, 0, time.UTC) }
- defer func() {
- taskAliasCacheRoot = oldRoot
- nowTaskAliasCache = oldNow
- }()
+ now := useIsolatedTaskAliasCache(t)
writeTaskAliasCacheForTest(t, taskAliasCache{
NextID: 1,
Entries: []taskAliasCacheEntry{
- {UUID: "test-uuid", Alias: "0", CreatedAt: nowTaskAliasCache()},
+ {UUID: "test-uuid", Alias: "0", CreatedAt: now},
},
})
@@ -158,20 +150,12 @@ func TestHandleModify_NumericID(t *testing.T) {
}
func TestHandleAnnotate_Success(t *testing.T) {
- dir := t.TempDir()
- oldRoot := taskAliasCacheRoot
- oldNow := nowTaskAliasCache
- taskAliasCacheRoot = func() (string, error) { return filepath.Join(dir, "hexai"), nil }
- nowTaskAliasCache = func() time.Time { return time.Date(2026, 3, 26, 12, 0, 0, 0, time.UTC) }
- defer func() {
- taskAliasCacheRoot = oldRoot
- nowTaskAliasCache = oldNow
- }()
+ now := useIsolatedTaskAliasCache(t)
writeTaskAliasCacheForTest(t, taskAliasCache{
NextID: 1,
Entries: []taskAliasCacheEntry{
- {UUID: "test-uuid", Alias: "0", CreatedAt: nowTaskAliasCache()},
+ {UUID: "test-uuid", Alias: "0", CreatedAt: now},
},
})
@@ -205,20 +189,12 @@ func TestHandleAnnotate_MissingArgs(t *testing.T) {
}
func TestHandleStart_Success(t *testing.T) {
- dir := t.TempDir()
- oldRoot := taskAliasCacheRoot
- oldNow := nowTaskAliasCache
- taskAliasCacheRoot = func() (string, error) { return filepath.Join(dir, "hexai"), nil }
- nowTaskAliasCache = func() time.Time { return time.Date(2026, 3, 26, 12, 0, 0, 0, time.UTC) }
- defer func() {
- taskAliasCacheRoot = oldRoot
- nowTaskAliasCache = oldNow
- }()
+ now := useIsolatedTaskAliasCache(t)
writeTaskAliasCacheForTest(t, taskAliasCache{
NextID: 1,
Entries: []taskAliasCacheEntry{
- {UUID: "test-uuid", Alias: "0", CreatedAt: nowTaskAliasCache()},
+ {UUID: "test-uuid", Alias: "0", CreatedAt: now},
},
})
@@ -252,6 +228,8 @@ func TestHandleStart_MissingUUID(t *testing.T) {
}
func TestHandleStop_Success(t *testing.T) {
+ useIsolatedTaskAliasCache(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] == "uuid:test-uuid" && args[1] == "export" {
io.WriteString(stdout, `[{"uuid":"test-uuid","description":"Task","status":"pending","priority":"M","tags":[],"urgency":0,"depends":[]}]`)
@@ -267,6 +245,8 @@ func TestHandleStop_Success(t *testing.T) {
}
func TestHandleDone_Success(t *testing.T) {
+ useIsolatedTaskAliasCache(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] == "uuid:test-uuid" && args[1] == "export" {
io.WriteString(stdout, `[{"uuid":"test-uuid","description":"Task","status":"pending","priority":"M","tags":[],"urgency":0,"depends":[]}]`)
@@ -282,6 +262,8 @@ func TestHandleDone_Success(t *testing.T) {
}
func TestHandlePriority_Success(t *testing.T) {
+ useIsolatedTaskAliasCache(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] == "uuid:test-uuid" && args[1] == "export" {
io.WriteString(stdout, `[{"uuid":"test-uuid","description":"Task","status":"pending","priority":"M","tags":[],"urgency":0,"depends":[]}]`)
@@ -309,6 +291,8 @@ func TestHandlePriority_MissingArgs(t *testing.T) {
}
func TestHandleTag_Success(t *testing.T) {
+ useIsolatedTaskAliasCache(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] == "uuid:test-uuid" && args[1] == "export" {
io.WriteString(stdout, `[{"uuid":"test-uuid","description":"Task","status":"pending","priority":"M","tags":[],"urgency":0,"depends":[]}]`)
@@ -355,6 +339,8 @@ func TestAllWriteHandlers_PassCorrectArgs(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.subcommand, func(t *testing.T) {
+ useIsolatedTaskAliasCache(t)
+
var capturedArgs []string
d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) {
if len(args) == 2 && args[1] == "export" {
@@ -399,6 +385,8 @@ func TestAllWriteHandlers_AcceptUUIDPrefix(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.subcommand, func(t *testing.T) {
+ useIsolatedTaskAliasCache(t)
+
var capturedArgs []string
d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) {
if len(args) == 2 && args[1] == "export" {
diff --git a/internal/askcli/completion.go b/internal/askcli/completion.go
index ec7631e..04dbb25 100644
--- a/internal/askcli/completion.go
+++ b/internal/askcli/completion.go
@@ -9,6 +9,19 @@ type fishCompletionItem struct {
description string
}
+var askSingleSelectorCompletionCommands = []string{
+ "info",
+ "annotate",
+ "start",
+ "stop",
+ "done",
+ "priority",
+ "tag",
+ "modify",
+ "denotate",
+ "delete",
+}
+
var askRootCompletionItems = []fishCompletionItem{
{name: "add", description: "Create a new task"},
{name: "list", description: "List active tasks"},
@@ -49,6 +62,34 @@ var askUUIDCompletionItems = []fishCompletionItem{
{name: "delete", description: "Delete a task"},
}
+func fishSingleSelectorCompletionContext(positional []string) bool {
+ if len(positional) != 1 {
+ return false
+ }
+
+ for _, command := range askSingleSelectorCompletionCommands {
+ if positional[0] == command {
+ return true
+ }
+ }
+ return false
+}
+
+func fishDepSelectorCompletionContext(positional []string) bool {
+ if len(positional) < 2 || positional[0] != "dep" {
+ return false
+ }
+
+ switch positional[1] {
+ case "add", "rm":
+ return len(positional) == 2 || len(positional) == 3
+ case "list":
+ return len(positional) == 2
+ default:
+ return false
+ }
+}
+
func FishCompletion() string {
return FishCompletionFor("ask")
}
@@ -136,11 +177,13 @@ func writeFishUUIDContextFunction(b *strings.Builder) {
b.WriteString(" if test (count $positional) -eq 0\n")
b.WriteString(" return 1\n")
b.WriteString(" end\n")
- b.WriteString(" if test (count $positional) -gt 2\n")
+ b.WriteString(" if test (count $positional) -ne 1\n")
b.WriteString(" return 1\n")
b.WriteString(" end\n")
b.WriteString(" switch $positional[1]\n")
- b.WriteString(" case info annotate start stop done priority tag modify denotate delete\n")
+ b.WriteString(" case ")
+ b.WriteString(strings.Join(askSingleSelectorCompletionCommands, " "))
+ b.WriteString("\n")
b.WriteString(" return 0\n")
b.WriteString(" case '*'\n")
b.WriteString(" return 1\n")
@@ -167,11 +210,11 @@ func writeFishDepUUIDContextFunction(b *strings.Builder) {
b.WriteString(" end\n")
b.WriteString(" switch $positional[2]\n")
b.WriteString(" case add rm\n")
- b.WriteString(" if test (count $positional) -le 4\n")
+ b.WriteString(" if test (count $positional) -eq 2 -o (count $positional) -eq 3\n")
b.WriteString(" return 0\n")
b.WriteString(" end\n")
b.WriteString(" case list\n")
- b.WriteString(" if test (count $positional) -le 3\n")
+ b.WriteString(" if test (count $positional) -eq 2\n")
b.WriteString(" return 0\n")
b.WriteString(" end\n")
b.WriteString(" case '*'\n")
diff --git a/internal/askcli/completion_test.go b/internal/askcli/completion_test.go
index 87a3ca9..14afdf5 100644
--- a/internal/askcli/completion_test.go
+++ b/internal/askcli/completion_test.go
@@ -35,6 +35,59 @@ func TestFishCompletion_IncludesCommandsAndExcludesExport(t *testing.T) {
}
}
+func TestFishSingleSelectorCompletionContext(t *testing.T) {
+ testCases := []struct {
+ name string
+ positional []string
+ want bool
+ }{
+ {name: "info expects selector", positional: []string{"info"}, want: true},
+ {name: "annotate expects selector", positional: []string{"annotate"}, want: true},
+ {name: "priority expects selector", positional: []string{"priority"}, want: true},
+ {name: "delete expects selector", positional: []string{"delete"}, want: true},
+ {name: "annotate stops after selector", positional: []string{"annotate", "0"}, want: false},
+ {name: "priority stops after selector", positional: []string{"priority", "0"}, want: false},
+ {name: "modify stops after selector", positional: []string{"modify", "0"}, want: false},
+ {name: "dep is not a single selector command", positional: []string{"dep"}, want: false},
+ {name: "empty positional", positional: nil, want: false},
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := fishSingleSelectorCompletionContext(tc.positional); got != tc.want {
+ t.Fatalf("fishSingleSelectorCompletionContext(%v) = %t, want %t", tc.positional, got, tc.want)
+ }
+ })
+ }
+}
+
+func TestFishDepSelectorCompletionContext(t *testing.T) {
+ testCases := []struct {
+ name string
+ positional []string
+ want bool
+ }{
+ {name: "dep add first selector", positional: []string{"dep", "add"}, want: true},
+ {name: "dep add second selector", positional: []string{"dep", "add", "0"}, want: true},
+ {name: "dep add stops after second selector", positional: []string{"dep", "add", "0", "1"}, want: false},
+ {name: "dep rm first selector", positional: []string{"dep", "rm"}, want: true},
+ {name: "dep rm second selector", positional: []string{"dep", "rm", "0"}, want: true},
+ {name: "dep rm stops after second selector", positional: []string{"dep", "rm", "0", "1"}, want: false},
+ {name: "dep list selector", positional: []string{"dep", "list"}, want: true},
+ {name: "dep list stops after selector", positional: []string{"dep", "list", "0"}, want: false},
+ {name: "dep unknown operation", positional: []string{"dep", "noop"}, want: false},
+ {name: "non dep command", positional: []string{"info"}, want: false},
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := fishDepSelectorCompletionContext(tc.positional); got != tc.want {
+ t.Fatalf("fishDepSelectorCompletionContext(%v) = %t, want %t", tc.positional, got, tc.want)
+ }
+ })
+ }
+}
+
func TestFishCompletionFor_EmbedsBinaryPath(t *testing.T) {
script := FishCompletionFor(`/tmp/ask "$HOME"`)
for _, line := range []string{