Skip to content

Commit fa3d752

Browse files
committed
feat(tui): add argument auto-completion for /toolset-restart
Pressing Tab after "/toolset-restart " opens a completion popup listing the current agent's toolsets. Non-restartable toolsets are shown but dimmed and annotated "(not restartable)", and cannot be selected. The completion opens only on Tab, never auto-opening on space. The argument-completion hook (ArgumentCandidate/CompleteArgument on commands.Item plus the ArgumentCompleter source interface) is command-agnostic so it can be reused for other commands (e.g. /effort, /drop) later, though only /toolset-restart is wired up here.
1 parent eba9421 commit fa3d752

18 files changed

Lines changed: 883 additions & 13 deletions

pkg/runtime/runtime.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1139,6 +1139,7 @@ func toolsetStatusFor(ts tools.ToolSet) tools.ToolsetStatus {
11391139
// earlier if Start failed.
11401140
status.State = lifecycleStateForUnsupervised(ts)
11411141
}
1142+
_, status.Restartable = tools.As[tools.Restartable](ts)
11421143
status.Name = nameFor(ts, status.Description)
11431144
return status
11441145
}

pkg/runtime/toolsetstatus_test.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,23 @@ func TestToolsetStatusFor_NoStatableMeansReady(t *testing.T) {
5959
assert.Equal(t, 0, got.RestartCount)
6060
require.NoError(t, got.LastError)
6161
assert.Equal(t, "filesystem", got.Description)
62+
assert.False(t, got.Restartable, "toolset without Restart() must report Restartable=false")
63+
}
64+
65+
func TestToolsetStatusFor_RestartableReportsTrue(t *testing.T) {
66+
t.Parallel()
67+
68+
ts := &restartableToolset{desc: "mcp(stdio cmd=foo)", state: lifecycle.StateInfo{State: lifecycle.StateReady}}
69+
got := toolsetStatusFor(ts)
70+
assert.True(t, got.Restartable)
71+
}
72+
73+
func TestToolsetStatusFor_NonRestartableReportsFalse(t *testing.T) {
74+
t.Parallel()
75+
76+
ts := &statefulToolset{desc: "filesystem", info: lifecycle.StateInfo{State: lifecycle.StateReady}}
77+
got := toolsetStatusFor(ts)
78+
assert.False(t, got.Restartable)
6279
}
6380

6481
// TestToolsetStatusFor_UnwrapsStartable verifies the inner Statable is

pkg/tools/status.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,4 +43,9 @@ type ToolsetStatus struct {
4343
// RestartCount is the number of supervisor restarts since the last
4444
// successful Ready transition. Zero for toolsets without a supervisor.
4545
RestartCount int
46+
// Restartable reports whether the toolset implements Restartable
47+
// (typically supervisor-backed MCP/LSP toolsets). Surfaces such as the
48+
// /toolset-restart completion popup use this to annotate, not hide,
49+
// entries that cannot actually be restarted.
50+
Restartable bool
4651
}

pkg/tui/commands/commands.go

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package commands
22

33
import (
4+
"cmp"
45
"context"
56
"fmt"
67
"slices"
@@ -11,6 +12,7 @@ import (
1112
"github.com/docker/docker-agent/pkg/app"
1213
"github.com/docker/docker-agent/pkg/config/types"
1314
"github.com/docker/docker-agent/pkg/feedback"
15+
"github.com/docker/docker-agent/pkg/tools"
1416
mcptools "github.com/docker/docker-agent/pkg/tools/mcp"
1517
"github.com/docker/docker-agent/pkg/tui/components/toolcommon"
1618
"github.com/docker/docker-agent/pkg/tui/core"
@@ -26,6 +28,18 @@ type Category struct {
2628
Commands []Item
2729
}
2830

31+
// ArgumentCandidate is one completable value for a command's argument,
32+
// e.g. a toolset name for /toolset-restart. It is intentionally free of any
33+
// completion-UI or app-domain types so pkg/tui/commands stays decoupled from
34+
// both pkg/tui/components/completion and pkg/app.
35+
type ArgumentCandidate struct {
36+
Label string
37+
Description string
38+
// Disabled marks a candidate that is shown for context but cannot be
39+
// submitted (e.g. a non-restartable toolset for /toolset-restart).
40+
Disabled bool
41+
}
42+
2943
// Item represents a single command in the palette
3044
type Item struct {
3145
ID string
@@ -38,6 +52,11 @@ type Item struct {
3852
// Immediate marks commands that should run as soon as they are submitted
3953
// instead of being treated as ordinary queued chat input.
4054
Immediate bool
55+
// CompleteArgument, when set, returns the candidates for this command's
56+
// argument. Called lazily at completion-popup-open time so results
57+
// reflect current runtime state (e.g. toolset lifecycle). Nil for
58+
// commands with no argument completion.
59+
CompleteArgument func() []ArgumentCandidate
4160
}
4261

4362
func builtInSessionCommands() []Item {
@@ -577,13 +596,72 @@ func newMCPPromptItem(promptName string, promptInfo mcptools.PromptInfo) Item {
577596
}
578597
}
579598

599+
// toolsetStatusSource is the minimal surface toolsetRestartCandidates needs.
600+
// *app.App satisfies it; tests can supply a stub instead of constructing a
601+
// full application.
602+
type toolsetStatusSource interface {
603+
CurrentAgentToolsetStatuses() []tools.ToolsetStatus
604+
}
605+
606+
// toolsetRestartCandidates returns one ArgumentCandidate per toolset of the
607+
// current agent, in declaration order, deduplicated by name (preferring the
608+
// restartable entry when duplicate names disagree — display names are not
609+
// guaranteed unique, mirroring runtime.RestartToolset's own matching).
610+
// Non-restartable toolsets are marked Disabled rather than omitted, so the
611+
// popup teaches users the full toolset inventory and why a restart isn't
612+
// offered for some of them.
613+
func toolsetRestartCandidates(source toolsetStatusSource) []ArgumentCandidate {
614+
statuses := source.CurrentAgentToolsetStatuses()
615+
byName := make(map[string]tools.ToolsetStatus, len(statuses))
616+
order := make([]string, 0, len(statuses))
617+
for _, s := range statuses {
618+
if s.Name == "" {
619+
continue
620+
}
621+
if existing, ok := byName[s.Name]; !ok {
622+
byName[s.Name] = s
623+
order = append(order, s.Name)
624+
} else if !existing.Restartable && s.Restartable {
625+
byName[s.Name] = s
626+
}
627+
}
628+
629+
candidates := make([]ArgumentCandidate, 0, len(order))
630+
for _, name := range order {
631+
s := byName[name]
632+
candidates = append(candidates, ArgumentCandidate{
633+
Label: s.Name,
634+
Description: cmp.Or(s.Kind, "Built-in") + " · " + s.State.String(),
635+
Disabled: !s.Restartable,
636+
})
637+
}
638+
return candidates
639+
}
640+
641+
// attachToolsetRestartCompletion wires the toolset-name argument completer
642+
// onto the /toolset-restart item. Attached post-hoc (rather than inline in
643+
// builtInSessionCommands) so that function stays free of any status-source
644+
// dependency.
645+
func attachToolsetRestartCompletion(items []Item, source toolsetStatusSource) {
646+
for i := range items {
647+
if items[i].ID != "session.toolset.restart" {
648+
continue
649+
}
650+
items[i].CompleteArgument = func() []ArgumentCandidate {
651+
return toolsetRestartCandidates(source)
652+
}
653+
return
654+
}
655+
}
656+
580657
// BuildCommandCategories builds the list of command categories for the command palette
581658
func BuildCommandCategories(ctx context.Context, application *app.App) []Category {
582659
// Get session commands and filter based on model capabilities
583660
sessionCommands := builtInSessionCommands()
584661
if !application.SnapshotsEnabled() {
585662
sessionCommands = removeByIDs(sessionCommands, snapshotCommandIDs)
586663
}
664+
attachToolsetRestartCompletion(sessionCommands, application)
587665

588666
categories := []Category{
589667
{

pkg/tui/commands/commands_test.go

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import (
66
"github.com/stretchr/testify/assert"
77
"github.com/stretchr/testify/require"
88

9+
"github.com/docker/docker-agent/pkg/tools"
10+
"github.com/docker/docker-agent/pkg/tools/lifecycle"
911
"github.com/docker/docker-agent/pkg/tui/messages"
1012
)
1113

@@ -258,3 +260,81 @@ func TestRemoveByIDsDropsSnapshotCommands(t *testing.T) {
258260
assert.Nil(t, parser.Parse("/snapshots"))
259261
require.NotNil(t, parser.Parse("/exit"))
260262
}
263+
264+
// stubToolsetStatusSource is a minimal toolsetStatusSource for exercising
265+
// toolsetRestartCandidates without a real *app.App.
266+
type stubToolsetStatusSource struct {
267+
statuses []tools.ToolsetStatus
268+
}
269+
270+
func (s stubToolsetStatusSource) CurrentAgentToolsetStatuses() []tools.ToolsetStatus {
271+
return s.statuses
272+
}
273+
274+
func TestToolsetRestartCandidates_AllShownWithDisabledFlag(t *testing.T) {
275+
t.Parallel()
276+
277+
source := stubToolsetStatusSource{statuses: []tools.ToolsetStatus{
278+
{Name: "github", Kind: "MCP", State: lifecycle.StateReady, Restartable: true},
279+
{Name: "filesystem", State: lifecycle.StateReady, Restartable: false},
280+
}}
281+
282+
candidates := toolsetRestartCandidates(source)
283+
require.Len(t, candidates, 2)
284+
285+
assert.Equal(t, "github", candidates[0].Label)
286+
assert.False(t, candidates[0].Disabled)
287+
assert.Contains(t, candidates[0].Description, "MCP")
288+
289+
assert.Equal(t, "filesystem", candidates[1].Label)
290+
assert.True(t, candidates[1].Disabled, "non-restartable toolsets must be shown, not hidden")
291+
assert.Contains(t, candidates[1].Description, "Built-in")
292+
}
293+
294+
func TestToolsetRestartCandidates_DedupePrefersRestartable(t *testing.T) {
295+
t.Parallel()
296+
297+
source := stubToolsetStatusSource{statuses: []tools.ToolsetStatus{
298+
{Name: "dup", State: lifecycle.StateStopped, Restartable: false},
299+
{Name: "dup", State: lifecycle.StateReady, Restartable: true},
300+
}}
301+
302+
candidates := toolsetRestartCandidates(source)
303+
require.Len(t, candidates, 1, "duplicate names must be deduplicated")
304+
assert.False(t, candidates[0].Disabled, "the restartable entry must win over the non-restartable duplicate")
305+
}
306+
307+
func TestToolsetRestartCandidates_EmptyNameSkipped(t *testing.T) {
308+
t.Parallel()
309+
310+
source := stubToolsetStatusSource{statuses: []tools.ToolsetStatus{{Name: "", Restartable: true}}}
311+
assert.Empty(t, toolsetRestartCandidates(source))
312+
}
313+
314+
func TestAttachToolsetRestartCompletion(t *testing.T) {
315+
t.Parallel()
316+
317+
items := builtInSessionCommands()
318+
source := stubToolsetStatusSource{statuses: []tools.ToolsetStatus{
319+
{Name: "github", Restartable: true},
320+
}}
321+
attachToolsetRestartCompletion(items, source)
322+
323+
var restart, other *Item
324+
for i := range items {
325+
switch items[i].ID {
326+
case "session.toolset.restart":
327+
restart = &items[i]
328+
case "session.exit":
329+
other = &items[i]
330+
}
331+
}
332+
require.NotNil(t, restart)
333+
require.NotNil(t, restart.CompleteArgument, "the restart item must get a completer")
334+
candidates := restart.CompleteArgument()
335+
require.Len(t, candidates, 1)
336+
assert.Equal(t, "github", candidates[0].Label)
337+
338+
require.NotNil(t, other)
339+
assert.Nil(t, other.CompleteArgument, "commands without a provider keep a nil CompleteArgument")
340+
}

pkg/tui/components/completion/completion.go

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@ type Item struct {
3434
Value string
3535
Execute func() tea.Cmd
3636
Pinned bool // Pinned items always appear at the top, in original order
37+
// Disabled marks an item that is shown for context but cannot be
38+
// submitted (e.g. a non-restartable toolset for /toolset-restart).
39+
// Enter/Tab are a no-op on it and it never drives the ghost-suggestion
40+
// preview; cursor movement over it is still allowed.
41+
Disabled bool
3742
}
3843

3944
type OpenMsg struct {
@@ -262,11 +267,17 @@ func (c *manager) Update(msg tea.Msg) (layout.Model, tea.Cmd) {
262267
return c, cmd
263268

264269
case key.Matches(msg, c.keyMap.Enter):
265-
c.visible = false
266270
if len(c.filteredItems) == 0 || c.selected >= len(c.filteredItems) {
271+
c.visible = false
267272
return c, core.CmdHandler(ClosedMsg{})
268273
}
269274
selectedItem := c.filteredItems[c.selected]
275+
if selectedItem.Disabled {
276+
// No-op: the dimmed style already signals why this entry
277+
// can't be submitted. Keep the popup open.
278+
return c, nil
279+
}
280+
c.visible = false
270281
return c, tea.Sequence(
271282
core.CmdHandler(SelectedMsg{
272283
Value: selectedItem.Value,
@@ -276,11 +287,15 @@ func (c *manager) Update(msg tea.Msg) (layout.Model, tea.Cmd) {
276287
core.CmdHandler(ClosedMsg{}),
277288
)
278289
case key.Matches(msg, c.keyMap.Tab):
279-
c.visible = false
280290
if len(c.filteredItems) == 0 || c.selected >= len(c.filteredItems) {
291+
c.visible = false
281292
return c, core.CmdHandler(ClosedMsg{})
282293
}
283294
selectedItem := c.filteredItems[c.selected]
295+
if selectedItem.Disabled {
296+
return c, nil
297+
}
298+
c.visible = false
284299
return c, tea.Sequence(
285300
core.CmdHandler(SelectedMsg{
286301
Value: selectedItem.Value,
@@ -339,7 +354,14 @@ func (c *manager) View() string {
339354

340355
itemStyle := styles.CompletionNormalStyle
341356
descStyle := styles.CompletionDescStyle
342-
if isSelected {
357+
switch {
358+
case item.Disabled && isSelected:
359+
itemStyle = styles.CompletionDisabledSelectedStyle
360+
descStyle = styles.CompletionDisabledDescStyle
361+
case item.Disabled:
362+
itemStyle = styles.CompletionDisabledStyle
363+
descStyle = styles.CompletionDisabledDescStyle
364+
case isSelected:
343365
itemStyle = styles.CompletionSelectedStyle
344366
descStyle = styles.CompletionSelectedDescStyle
345367
}
@@ -376,12 +398,18 @@ func (c *manager) GetLayers() []*lipgloss.Layer {
376398
}
377399
}
378400

379-
// notifySelectionChanged sends a SelectionChangedMsg with the currently selected item's value
401+
// notifySelectionChanged sends a SelectionChangedMsg with the currently selected item's value.
402+
// Disabled items report an empty value so the editor's ghost-suggestion
403+
// preview shows nothing for entries that can't be submitted.
380404
func (c *manager) notifySelectionChanged() tea.Cmd {
381405
if len(c.filteredItems) == 0 || c.selected >= len(c.filteredItems) {
382406
return core.CmdHandler(SelectionChangedMsg{Value: ""})
383407
}
384-
return core.CmdHandler(SelectionChangedMsg{Value: c.filteredItems[c.selected].Value})
408+
selectedItem := c.filteredItems[c.selected]
409+
if selectedItem.Disabled {
410+
return core.CmdHandler(SelectionChangedMsg{Value: ""})
411+
}
412+
return core.CmdHandler(SelectionChangedMsg{Value: selectedItem.Value})
385413
}
386414

387415
func (c *manager) filterItems(query string) {

0 commit comments

Comments
 (0)