Skip to content

Commit 9fc1e0b

Browse files
committed
feat: Allow to extend and customize TUI with additional commands
1 parent f711e4e commit 9fc1e0b

File tree

12 files changed

+219
-73
lines changed

12 files changed

+219
-73
lines changed

MR.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
**Title:** session: add WithID option for custom session IDs
2+
3+
**Description:**
4+
5+
Allow setting the session ID explicitly instead of relying on the default UUID generation. In some circumstances the session ID needs to be prefixed, or comes from an external source.

PR.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
**Title:** rag/treesitter: add nocgo build support
2+
3+
**Description:**
4+
5+
I'm building a CLI tool that uses docker-agent but doesn't need RAG functionality. Currently, the tree-sitter dependency forces CGO even when RAG isn't used, which causes Go stdlib and other 3rd party packages to also be built with CGO.
6+
7+
This PR adds a `!cgo` build variant for the treesitter package. Applications that don't use RAG can now build with `CGO_ENABLED=0`. If RAG is used without CGO, it returns a clear error explaining the requirement.

pkg/tui/commands/commands.go

Lines changed: 42 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ type Item struct {
3333
SlashCommand string
3434
Execute ExecuteFunc
3535
Hidden bool // Hidden commands work as slash commands but don't appear in the palette
36+
// Immediate marks that the command can be executed immediately as it does not
37+
// interrupt any ongoing stream.
38+
Immediate bool
3639
}
3740

3841
func builtInSessionCommands() []Item {
@@ -43,6 +46,7 @@ func builtInSessionCommands() []Item {
4346
SlashCommand: "/clear",
4447
Description: "Clear the current tab and start a new session",
4548
Category: "Session",
49+
Immediate: true,
4650
Execute: func(string) tea.Cmd {
4751
return core.CmdHandler(messages.ClearSessionMsg{})
4852
},
@@ -53,6 +57,7 @@ func builtInSessionCommands() []Item {
5357
SlashCommand: "/attach",
5458
Description: "Attach a file to your message (usage: /attach [path])",
5559
Category: "Session",
60+
Immediate: true,
5661
Execute: func(arg string) tea.Cmd {
5762
return core.CmdHandler(messages.AttachFileMsg{FilePath: arg})
5863
},
@@ -63,6 +68,7 @@ func builtInSessionCommands() []Item {
6368
SlashCommand: "/compact",
6469
Description: "Summarize the current conversation (usage: /compact [additional instructions])",
6570
Category: "Session",
71+
Immediate: true,
6672
Execute: func(arg string) tea.Cmd {
6773
return core.CmdHandler(messages.CompactSessionMsg{AdditionalPrompt: arg})
6874
},
@@ -73,6 +79,7 @@ func builtInSessionCommands() []Item {
7379
SlashCommand: "/copy",
7480
Description: "Copy the current conversation to the clipboard",
7581
Category: "Session",
82+
Immediate: true,
7683
Execute: func(string) tea.Cmd {
7784
return core.CmdHandler(messages.CopySessionToClipboardMsg{})
7885
},
@@ -83,6 +90,7 @@ func builtInSessionCommands() []Item {
8390
SlashCommand: "/copy-last",
8491
Description: "Copy the last assistant message to the clipboard",
8592
Category: "Session",
93+
Immediate: true,
8694
Execute: func(string) tea.Cmd {
8795
return core.CmdHandler(messages.CopyLastResponseToClipboardMsg{})
8896
},
@@ -93,6 +101,7 @@ func builtInSessionCommands() []Item {
93101
SlashCommand: "/cost",
94102
Description: "Show detailed cost breakdown for this session",
95103
Category: "Session",
104+
Immediate: true,
96105
Execute: func(string) tea.Cmd {
97106
return core.CmdHandler(messages.ShowCostDialogMsg{})
98107
},
@@ -103,6 +112,7 @@ func builtInSessionCommands() []Item {
103112
SlashCommand: "/eval",
104113
Description: "Create an evaluation report (usage: /eval [filename])",
105114
Category: "Session",
115+
Immediate: true,
106116
Execute: func(arg string) tea.Cmd {
107117
return core.CmdHandler(messages.EvalSessionMsg{Filename: arg})
108118
},
@@ -113,6 +123,7 @@ func builtInSessionCommands() []Item {
113123
SlashCommand: "/exit",
114124
Description: "Exit the application",
115125
Category: "Session",
126+
Immediate: true,
116127
Execute: func(string) tea.Cmd {
117128
return core.CmdHandler(messages.ExitSessionMsg{})
118129
},
@@ -123,6 +134,7 @@ func builtInSessionCommands() []Item {
123134
SlashCommand: "/quit",
124135
Description: "Quit the application (alias for /exit)",
125136
Category: "Session",
137+
Immediate: true,
126138
Execute: func(string) tea.Cmd {
127139
return core.CmdHandler(messages.ExitSessionMsg{})
128140
},
@@ -134,6 +146,7 @@ func builtInSessionCommands() []Item {
134146
Hidden: true,
135147
Description: "Quit the application (alias for /exit)",
136148
Category: "Session",
149+
Immediate: true,
137150
Execute: func(string) tea.Cmd {
138151
return core.CmdHandler(messages.ExitSessionMsg{})
139152
},
@@ -144,6 +157,7 @@ func builtInSessionCommands() []Item {
144157
SlashCommand: "/export",
145158
Description: "Export the session as HTML (usage: /export [filename])",
146159
Category: "Session",
160+
Immediate: true,
147161
Execute: func(arg string) tea.Cmd {
148162
return core.CmdHandler(messages.ExportSessionMsg{Filename: arg})
149163
},
@@ -154,6 +168,7 @@ func builtInSessionCommands() []Item {
154168
SlashCommand: "/model",
155169
Description: "Change the model for the current agent",
156170
Category: "Session",
171+
Immediate: true,
157172
Execute: func(string) tea.Cmd {
158173
return core.CmdHandler(messages.OpenModelPickerMsg{})
159174
},
@@ -164,6 +179,7 @@ func builtInSessionCommands() []Item {
164179
SlashCommand: "/new",
165180
Description: "Start a new conversation",
166181
Category: "Session",
182+
Immediate: true,
167183
Execute: func(string) tea.Cmd {
168184
return core.CmdHandler(messages.NewSessionMsg{})
169185
},
@@ -174,6 +190,7 @@ func builtInSessionCommands() []Item {
174190
SlashCommand: "/permissions",
175191
Description: "Show tool permission rules for this session",
176192
Category: "Session",
193+
Immediate: true,
177194
Execute: func(string) tea.Cmd {
178195
return core.CmdHandler(messages.ShowPermissionsDialogMsg{})
179196
},
@@ -184,6 +201,7 @@ func builtInSessionCommands() []Item {
184201
SlashCommand: "/sessions",
185202
Description: "Browse and load past sessions",
186203
Category: "Session",
204+
Immediate: true,
187205
Execute: func(string) tea.Cmd {
188206
return core.CmdHandler(messages.OpenSessionBrowserMsg{})
189207
},
@@ -194,6 +212,7 @@ func builtInSessionCommands() []Item {
194212
SlashCommand: "/shell",
195213
Description: "Start a shell",
196214
Category: "Session",
215+
Immediate: true,
197216
Execute: func(string) tea.Cmd {
198217
return core.CmdHandler(messages.StartShellMsg{})
199218
},
@@ -204,6 +223,7 @@ func builtInSessionCommands() []Item {
204223
SlashCommand: "/star",
205224
Description: "Toggle star on current session",
206225
Category: "Session",
226+
Immediate: true,
207227
Execute: func(string) tea.Cmd {
208228
return core.CmdHandler(messages.ToggleSessionStarMsg{})
209229
},
@@ -215,6 +235,7 @@ func builtInSessionCommands() []Item {
215235
SlashCommand: "/tools",
216236
Description: "Show all tools available to the current agent",
217237
Category: "Session",
238+
Immediate: true,
218239
Execute: func(string) tea.Cmd {
219240
return core.CmdHandler(messages.ShowToolsDialogMsg{})
220241
},
@@ -225,6 +246,7 @@ func builtInSessionCommands() []Item {
225246
SlashCommand: "/title",
226247
Description: "Set or regenerate session title (usage: /title [new title])",
227248
Category: "Session",
249+
Immediate: true,
228250
Execute: func(arg string) tea.Cmd {
229251
arg = strings.TrimSpace(arg)
230252
if arg == "" {
@@ -241,6 +263,7 @@ func builtInSessionCommands() []Item {
241263
SlashCommand: "/yolo",
242264
Description: "Toggle automatic approval of tool calls",
243265
Category: "Session",
266+
Immediate: true,
244267
Execute: func(string) tea.Cmd {
245268
return core.CmdHandler(messages.ToggleYoloMsg{})
246269
},
@@ -263,6 +286,7 @@ func builtInSettingsCommands() []Item {
263286
SlashCommand: "/split-diff",
264287
Description: "Toggle split diff view mode",
265288
Category: "Settings",
289+
Immediate: true,
266290
Execute: func(string) tea.Cmd {
267291
return core.CmdHandler(messages.ToggleSplitDiffMsg{})
268292
},
@@ -273,6 +297,7 @@ func builtInSettingsCommands() []Item {
273297
SlashCommand: "/theme",
274298
Description: "Change the color theme",
275299
Category: "Settings",
300+
Immediate: true,
276301
Execute: func(string) tea.Cmd {
277302
return core.CmdHandler(messages.OpenThemePickerMsg{})
278303
},
@@ -478,27 +503,30 @@ func BuildCommandCategories(ctx context.Context, application *app.App) []Categor
478503
return categories
479504
}
480505

481-
// ParseSlashCommand checks if the input matches a known slash command and returns
482-
// the tea.Cmd to execute it. Returns nil if not a slash command or not recognized.
483-
// This function only handles built-in session commands, not agent commands or MCP prompts.
484-
func ParseSlashCommand(input string) tea.Cmd {
506+
type Parser struct {
507+
categories []Category
508+
}
509+
510+
func NewParser(categories ...Category) *Parser {
511+
return &Parser{
512+
categories: categories,
513+
}
514+
}
515+
516+
func (p *Parser) Parse(input string) tea.Cmd {
485517
if input == "" || input[0] != '/' {
486518
return nil
487519
}
488520

489521
// Split into command and argument
490522
cmd, arg, _ := strings.Cut(input, " ")
491523

492-
// Search through built-in commands
493-
for _, item := range builtInSessionCommands() {
494-
if item.SlashCommand == cmd {
495-
return item.Execute(arg)
496-
}
497-
}
498-
499-
for _, item := range builtInSettingsCommands() {
500-
if item.SlashCommand == cmd {
501-
return item.Execute(arg)
524+
// Search through all categories and commands
525+
for _, category := range p.categories {
526+
for _, item := range category.Commands {
527+
if item.SlashCommand == cmd && item.Immediate {
528+
return item.Execute(arg)
529+
}
502530
}
503531
}
504532

pkg/tui/commands/commands_test.go

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,21 @@ import (
99
"github.com/docker/docker-agent/pkg/tui/messages"
1010
)
1111

12+
func newTestParser() *Parser {
13+
return NewParser(
14+
Category{Name: "Session", Commands: builtInSessionCommands()},
15+
Category{Name: "Settings", Commands: builtInSettingsCommands()},
16+
)
17+
}
18+
1219
func TestParseSlashCommand_Title(t *testing.T) {
1320
t.Parallel()
21+
parser := newTestParser()
1422

1523
t.Run("title with argument sets title", func(t *testing.T) {
1624
t.Parallel()
1725

18-
cmd := ParseSlashCommand("/title My Custom Title")
26+
cmd := parser.Parse("/title My Custom Title")
1927
require.NotNil(t, cmd, "should return a command for /title with argument")
2028

2129
// Execute the command and check the message type
@@ -28,7 +36,7 @@ func TestParseSlashCommand_Title(t *testing.T) {
2836
t.Run("title without argument regenerates", func(t *testing.T) {
2937
t.Parallel()
3038

31-
cmd := ParseSlashCommand("/title")
39+
cmd := parser.Parse("/title")
3240
require.NotNil(t, cmd, "should return a command for /title without argument")
3341

3442
// Execute the command and check the message type
@@ -40,7 +48,7 @@ func TestParseSlashCommand_Title(t *testing.T) {
4048
t.Run("title with only whitespace regenerates", func(t *testing.T) {
4149
t.Parallel()
4250

43-
cmd := ParseSlashCommand("/title ")
51+
cmd := parser.Parse("/title ")
4452
require.NotNil(t, cmd, "should return a command for /title with whitespace")
4553

4654
// Execute the command and check the message type
@@ -52,10 +60,11 @@ func TestParseSlashCommand_Title(t *testing.T) {
5260

5361
func TestParseSlashCommand_OtherCommands(t *testing.T) {
5462
t.Parallel()
63+
parser := newTestParser()
5564

5665
t.Run("exit command", func(t *testing.T) {
5766
t.Parallel()
58-
cmd := ParseSlashCommand("/exit")
67+
cmd := parser.Parse("/exit")
5968
require.NotNil(t, cmd)
6069
msg := cmd()
6170
_, ok := msg.(messages.ExitSessionMsg)
@@ -64,7 +73,7 @@ func TestParseSlashCommand_OtherCommands(t *testing.T) {
6473

6574
t.Run("new command", func(t *testing.T) {
6675
t.Parallel()
67-
cmd := ParseSlashCommand("/new")
76+
cmd := parser.Parse("/new")
6877
require.NotNil(t, cmd)
6978
msg := cmd()
7079
_, ok := msg.(messages.NewSessionMsg)
@@ -73,7 +82,7 @@ func TestParseSlashCommand_OtherCommands(t *testing.T) {
7382

7483
t.Run("clear command", func(t *testing.T) {
7584
t.Parallel()
76-
cmd := ParseSlashCommand("/clear")
85+
cmd := parser.Parse("/clear")
7786
require.NotNil(t, cmd)
7887
msg := cmd()
7988
_, ok := msg.(messages.ClearSessionMsg)
@@ -82,7 +91,7 @@ func TestParseSlashCommand_OtherCommands(t *testing.T) {
8291

8392
t.Run("star command", func(t *testing.T) {
8493
t.Parallel()
85-
cmd := ParseSlashCommand("/star")
94+
cmd := parser.Parse("/star")
8695
require.NotNil(t, cmd)
8796
msg := cmd()
8897
_, ok := msg.(messages.ToggleSessionStarMsg)
@@ -91,29 +100,30 @@ func TestParseSlashCommand_OtherCommands(t *testing.T) {
91100

92101
t.Run("unknown command returns nil", func(t *testing.T) {
93102
t.Parallel()
94-
cmd := ParseSlashCommand("/unknown")
103+
cmd := parser.Parse("/unknown")
95104
assert.Nil(t, cmd)
96105
})
97106

98107
t.Run("non-slash input returns nil", func(t *testing.T) {
99108
t.Parallel()
100-
cmd := ParseSlashCommand("hello world")
109+
cmd := parser.Parse("hello world")
101110
assert.Nil(t, cmd)
102111
})
103112

104113
t.Run("empty input returns nil", func(t *testing.T) {
105114
t.Parallel()
106-
cmd := ParseSlashCommand("")
115+
cmd := parser.Parse("")
107116
assert.Nil(t, cmd)
108117
})
109118
}
110119

111120
func TestParseSlashCommand_Compact(t *testing.T) {
112121
t.Parallel()
122+
parser := newTestParser()
113123

114124
t.Run("compact without argument", func(t *testing.T) {
115125
t.Parallel()
116-
cmd := ParseSlashCommand("/compact")
126+
cmd := parser.Parse("/compact")
117127
require.NotNil(t, cmd)
118128
msg := cmd()
119129
compactMsg, ok := msg.(messages.CompactSessionMsg)
@@ -123,7 +133,7 @@ func TestParseSlashCommand_Compact(t *testing.T) {
123133

124134
t.Run("compact with argument", func(t *testing.T) {
125135
t.Parallel()
126-
cmd := ParseSlashCommand("/compact focus on the API design")
136+
cmd := parser.Parse("/compact focus on the API design")
127137
require.NotNil(t, cmd)
128138
msg := cmd()
129139
compactMsg, ok := msg.(messages.CompactSessionMsg)

0 commit comments

Comments
 (0)