diff --git a/pkg/tui/commands/commands.go b/pkg/tui/commands/commands.go index d5d5bf459..e69f758a6 100644 --- a/pkg/tui/commands/commands.go +++ b/pkg/tui/commands/commands.go @@ -33,6 +33,9 @@ type Item struct { SlashCommand string Execute ExecuteFunc Hidden bool // Hidden commands work as slash commands but don't appear in the palette + // Immediate marks that the command can be executed immediately as it does not + // interrupt any ongoing stream. + Immediate bool } func builtInSessionCommands() []Item { @@ -43,6 +46,7 @@ func builtInSessionCommands() []Item { SlashCommand: "/clear", Description: "Clear the current tab and start a new session", Category: "Session", + Immediate: true, Execute: func(string) tea.Cmd { return core.CmdHandler(messages.ClearSessionMsg{}) }, @@ -53,6 +57,7 @@ func builtInSessionCommands() []Item { SlashCommand: "/attach", Description: "Attach a file to your message (usage: /attach [path])", Category: "Session", + Immediate: true, Execute: func(arg string) tea.Cmd { return core.CmdHandler(messages.AttachFileMsg{FilePath: arg}) }, @@ -63,6 +68,7 @@ func builtInSessionCommands() []Item { SlashCommand: "/compact", Description: "Summarize the current conversation (usage: /compact [additional instructions])", Category: "Session", + Immediate: true, Execute: func(arg string) tea.Cmd { return core.CmdHandler(messages.CompactSessionMsg{AdditionalPrompt: arg}) }, @@ -73,6 +79,7 @@ func builtInSessionCommands() []Item { SlashCommand: "/copy", Description: "Copy the current conversation to the clipboard", Category: "Session", + Immediate: true, Execute: func(string) tea.Cmd { return core.CmdHandler(messages.CopySessionToClipboardMsg{}) }, @@ -83,6 +90,7 @@ func builtInSessionCommands() []Item { SlashCommand: "/copy-last", Description: "Copy the last assistant message to the clipboard", Category: "Session", + Immediate: true, Execute: func(string) tea.Cmd { return core.CmdHandler(messages.CopyLastResponseToClipboardMsg{}) }, @@ -93,6 +101,7 @@ func builtInSessionCommands() []Item { SlashCommand: "/cost", Description: "Show detailed cost breakdown for this session", Category: "Session", + Immediate: true, Execute: func(string) tea.Cmd { return core.CmdHandler(messages.ShowCostDialogMsg{}) }, @@ -103,6 +112,7 @@ func builtInSessionCommands() []Item { SlashCommand: "/eval", Description: "Create an evaluation report (usage: /eval [filename])", Category: "Session", + Immediate: true, Execute: func(arg string) tea.Cmd { return core.CmdHandler(messages.EvalSessionMsg{Filename: arg}) }, @@ -113,6 +123,7 @@ func builtInSessionCommands() []Item { SlashCommand: "/exit", Description: "Exit the application", Category: "Session", + Immediate: true, Execute: func(string) tea.Cmd { return core.CmdHandler(messages.ExitSessionMsg{}) }, @@ -123,6 +134,7 @@ func builtInSessionCommands() []Item { SlashCommand: "/quit", Description: "Quit the application (alias for /exit)", Category: "Session", + Immediate: true, Execute: func(string) tea.Cmd { return core.CmdHandler(messages.ExitSessionMsg{}) }, @@ -134,6 +146,7 @@ func builtInSessionCommands() []Item { Hidden: true, Description: "Quit the application (alias for /exit)", Category: "Session", + Immediate: true, Execute: func(string) tea.Cmd { return core.CmdHandler(messages.ExitSessionMsg{}) }, @@ -144,6 +157,7 @@ func builtInSessionCommands() []Item { SlashCommand: "/export", Description: "Export the session as HTML (usage: /export [filename])", Category: "Session", + Immediate: true, Execute: func(arg string) tea.Cmd { return core.CmdHandler(messages.ExportSessionMsg{Filename: arg}) }, @@ -154,6 +168,7 @@ func builtInSessionCommands() []Item { SlashCommand: "/model", Description: "Change the model for the current agent", Category: "Session", + Immediate: true, Execute: func(string) tea.Cmd { return core.CmdHandler(messages.OpenModelPickerMsg{}) }, @@ -164,6 +179,7 @@ func builtInSessionCommands() []Item { SlashCommand: "/new", Description: "Start a new conversation", Category: "Session", + Immediate: true, Execute: func(string) tea.Cmd { return core.CmdHandler(messages.NewSessionMsg{}) }, @@ -174,6 +190,7 @@ func builtInSessionCommands() []Item { SlashCommand: "/permissions", Description: "Show tool permission rules for this session", Category: "Session", + Immediate: true, Execute: func(string) tea.Cmd { return core.CmdHandler(messages.ShowPermissionsDialogMsg{}) }, @@ -184,6 +201,7 @@ func builtInSessionCommands() []Item { SlashCommand: "/sessions", Description: "Browse and load past sessions", Category: "Session", + Immediate: true, Execute: func(string) tea.Cmd { return core.CmdHandler(messages.OpenSessionBrowserMsg{}) }, @@ -194,6 +212,7 @@ func builtInSessionCommands() []Item { SlashCommand: "/shell", Description: "Start a shell", Category: "Session", + Immediate: true, Execute: func(string) tea.Cmd { return core.CmdHandler(messages.StartShellMsg{}) }, @@ -204,6 +223,7 @@ func builtInSessionCommands() []Item { SlashCommand: "/star", Description: "Toggle star on current session", Category: "Session", + Immediate: true, Execute: func(string) tea.Cmd { return core.CmdHandler(messages.ToggleSessionStarMsg{}) }, @@ -215,6 +235,7 @@ func builtInSessionCommands() []Item { SlashCommand: "/tools", Description: "Show all tools available to the current agent", Category: "Session", + Immediate: true, Execute: func(string) tea.Cmd { return core.CmdHandler(messages.ShowToolsDialogMsg{}) }, @@ -225,6 +246,7 @@ func builtInSessionCommands() []Item { SlashCommand: "/title", Description: "Set or regenerate session title (usage: /title [new title])", Category: "Session", + Immediate: true, Execute: func(arg string) tea.Cmd { arg = strings.TrimSpace(arg) if arg == "" { @@ -241,6 +263,7 @@ func builtInSessionCommands() []Item { SlashCommand: "/yolo", Description: "Toggle automatic approval of tool calls", Category: "Session", + Immediate: true, Execute: func(string) tea.Cmd { return core.CmdHandler(messages.ToggleYoloMsg{}) }, @@ -263,6 +286,7 @@ func builtInSettingsCommands() []Item { SlashCommand: "/split-diff", Description: "Toggle split diff view mode", Category: "Settings", + Immediate: true, Execute: func(string) tea.Cmd { return core.CmdHandler(messages.ToggleSplitDiffMsg{}) }, @@ -273,6 +297,7 @@ func builtInSettingsCommands() []Item { SlashCommand: "/theme", Description: "Change the color theme", Category: "Settings", + Immediate: true, Execute: func(string) tea.Cmd { return core.CmdHandler(messages.OpenThemePickerMsg{}) }, @@ -478,10 +503,17 @@ func BuildCommandCategories(ctx context.Context, application *app.App) []Categor return categories } -// ParseSlashCommand checks if the input matches a known slash command and returns -// the tea.Cmd to execute it. Returns nil if not a slash command or not recognized. -// This function only handles built-in session commands, not agent commands or MCP prompts. -func ParseSlashCommand(input string) tea.Cmd { +type Parser struct { + categories []Category +} + +func NewParser(categories ...Category) *Parser { + return &Parser{ + categories: categories, + } +} + +func (p *Parser) Parse(input string) tea.Cmd { if input == "" || input[0] != '/' { return nil } @@ -489,16 +521,12 @@ func ParseSlashCommand(input string) tea.Cmd { // Split into command and argument cmd, arg, _ := strings.Cut(input, " ") - // Search through built-in commands - for _, item := range builtInSessionCommands() { - if item.SlashCommand == cmd { - return item.Execute(arg) - } - } - - for _, item := range builtInSettingsCommands() { - if item.SlashCommand == cmd { - return item.Execute(arg) + // Search through all categories and commands + for _, category := range p.categories { + for _, item := range category.Commands { + if item.SlashCommand == cmd && item.Immediate { + return item.Execute(arg) + } } } diff --git a/pkg/tui/commands/commands_test.go b/pkg/tui/commands/commands_test.go index 3113b8e4a..522384c5b 100644 --- a/pkg/tui/commands/commands_test.go +++ b/pkg/tui/commands/commands_test.go @@ -9,13 +9,21 @@ import ( "github.com/docker/docker-agent/pkg/tui/messages" ) +func newTestParser() *Parser { + return NewParser( + Category{Name: "Session", Commands: builtInSessionCommands()}, + Category{Name: "Settings", Commands: builtInSettingsCommands()}, + ) +} + func TestParseSlashCommand_Title(t *testing.T) { t.Parallel() + parser := newTestParser() t.Run("title with argument sets title", func(t *testing.T) { t.Parallel() - cmd := ParseSlashCommand("/title My Custom Title") + cmd := parser.Parse("/title My Custom Title") require.NotNil(t, cmd, "should return a command for /title with argument") // Execute the command and check the message type @@ -28,7 +36,7 @@ func TestParseSlashCommand_Title(t *testing.T) { t.Run("title without argument regenerates", func(t *testing.T) { t.Parallel() - cmd := ParseSlashCommand("/title") + cmd := parser.Parse("/title") require.NotNil(t, cmd, "should return a command for /title without argument") // Execute the command and check the message type @@ -40,7 +48,7 @@ func TestParseSlashCommand_Title(t *testing.T) { t.Run("title with only whitespace regenerates", func(t *testing.T) { t.Parallel() - cmd := ParseSlashCommand("/title ") + cmd := parser.Parse("/title ") require.NotNil(t, cmd, "should return a command for /title with whitespace") // Execute the command and check the message type @@ -52,10 +60,11 @@ func TestParseSlashCommand_Title(t *testing.T) { func TestParseSlashCommand_OtherCommands(t *testing.T) { t.Parallel() + parser := newTestParser() t.Run("exit command", func(t *testing.T) { t.Parallel() - cmd := ParseSlashCommand("/exit") + cmd := parser.Parse("/exit") require.NotNil(t, cmd) msg := cmd() _, ok := msg.(messages.ExitSessionMsg) @@ -64,7 +73,7 @@ func TestParseSlashCommand_OtherCommands(t *testing.T) { t.Run("new command", func(t *testing.T) { t.Parallel() - cmd := ParseSlashCommand("/new") + cmd := parser.Parse("/new") require.NotNil(t, cmd) msg := cmd() _, ok := msg.(messages.NewSessionMsg) @@ -73,7 +82,7 @@ func TestParseSlashCommand_OtherCommands(t *testing.T) { t.Run("clear command", func(t *testing.T) { t.Parallel() - cmd := ParseSlashCommand("/clear") + cmd := parser.Parse("/clear") require.NotNil(t, cmd) msg := cmd() _, ok := msg.(messages.ClearSessionMsg) @@ -82,7 +91,7 @@ func TestParseSlashCommand_OtherCommands(t *testing.T) { t.Run("star command", func(t *testing.T) { t.Parallel() - cmd := ParseSlashCommand("/star") + cmd := parser.Parse("/star") require.NotNil(t, cmd) msg := cmd() _, ok := msg.(messages.ToggleSessionStarMsg) @@ -91,29 +100,30 @@ func TestParseSlashCommand_OtherCommands(t *testing.T) { t.Run("unknown command returns nil", func(t *testing.T) { t.Parallel() - cmd := ParseSlashCommand("/unknown") + cmd := parser.Parse("/unknown") assert.Nil(t, cmd) }) t.Run("non-slash input returns nil", func(t *testing.T) { t.Parallel() - cmd := ParseSlashCommand("hello world") + cmd := parser.Parse("hello world") assert.Nil(t, cmd) }) t.Run("empty input returns nil", func(t *testing.T) { t.Parallel() - cmd := ParseSlashCommand("") + cmd := parser.Parse("") assert.Nil(t, cmd) }) } func TestParseSlashCommand_Compact(t *testing.T) { t.Parallel() + parser := newTestParser() t.Run("compact without argument", func(t *testing.T) { t.Parallel() - cmd := ParseSlashCommand("/compact") + cmd := parser.Parse("/compact") require.NotNil(t, cmd) msg := cmd() compactMsg, ok := msg.(messages.CompactSessionMsg) @@ -123,7 +133,7 @@ func TestParseSlashCommand_Compact(t *testing.T) { t.Run("compact with argument", func(t *testing.T) { t.Parallel() - cmd := ParseSlashCommand("/compact focus on the API design") + cmd := parser.Parse("/compact focus on the API design") require.NotNil(t, cmd) msg := cmd() compactMsg, ok := msg.(messages.CompactSessionMsg) diff --git a/pkg/tui/components/editor/completions/command.go b/pkg/tui/components/editor/completions/command.go index fe628d3a3..1c1c32cae 100644 --- a/pkg/tui/components/editor/completions/command.go +++ b/pkg/tui/components/editor/completions/command.go @@ -1,22 +1,20 @@ package completions import ( - "context" "slices" "strings" - "github.com/docker/docker-agent/pkg/app" "github.com/docker/docker-agent/pkg/tui/commands" "github.com/docker/docker-agent/pkg/tui/components/completion" ) type commandCompletion struct { - app *app.App + categories []commands.Category } -func NewCommandCompletion(a *app.App) Completion { +func NewCommandCompletion(categories []commands.Category) Completion { return &commandCompletion{ - app: a, + categories: categories, } } @@ -35,7 +33,7 @@ func (c *commandCompletion) Trigger() string { func (c *commandCompletion) Items() []completion.Item { var items []completion.Item - for _, cmd := range commands.BuildCommandCategories(context.Background(), c.app) { + for _, cmd := range c.categories { for _, command := range cmd.Commands { items = append(items, completion.Item{ Label: command.Label, diff --git a/pkg/tui/components/editor/completions/completion.go b/pkg/tui/components/editor/completions/completion.go index e07e2e522..822fb6491 100644 --- a/pkg/tui/components/editor/completions/completion.go +++ b/pkg/tui/components/editor/completions/completion.go @@ -3,7 +3,6 @@ package completions import ( "context" - "github.com/docker/docker-agent/pkg/app" "github.com/docker/docker-agent/pkg/tui/components/completion" ) @@ -27,10 +26,3 @@ type AsyncLoader interface { // It returns a channel that receives the items when loading is complete. LoadItemsAsync(ctx context.Context) <-chan []completion.Item } - -func Completions(a *app.App) []Completion { - return []Completion{ - NewCommandCompletion(a), - NewFileCompletion(), - } -} diff --git a/pkg/tui/components/editor/editor.go b/pkg/tui/components/editor/editor.go index d9528d68f..c7c9fc45e 100644 --- a/pkg/tui/components/editor/editor.go +++ b/pkg/tui/components/editor/editor.go @@ -20,7 +20,6 @@ import ( "github.com/mattn/go-runewidth" "github.com/rivo/uniseg" - "github.com/docker/docker-agent/pkg/app" "github.com/docker/docker-agent/pkg/history" "github.com/docker/docker-agent/pkg/paths" "github.com/docker/docker-agent/pkg/tui/components/completion" @@ -157,8 +156,18 @@ type editor struct { searchInput textinput.Model } +// Option configures the Editor. +type Option func(*editor) + +// WithCompletions sets the available completions for the editor. +func WithCompletions(comps ...completions.Completion) Option { + return func(e *editor) { + e.completions = comps + } +} + // New creates a new editor component -func New(a *app.App, hist *history.History) Editor { +func New(hist *history.History, opts ...Option) Editor { ta := textarea.New() ta.SetStyles(styles.InputStyle) ta.Placeholder = "Type your message here…" @@ -185,11 +194,15 @@ func New(a *app.App, hist *history.History) Editor { textarea: ta, searchInput: si, hist: hist, - completions: completions.Completions(a), keyboardEnhancementsSupported: false, banner: newAttachmentBanner(), } + // Apply options + for _, opt := range opts { + opt(e) + } + e.configureNewlineKeybinding() return e diff --git a/pkg/tui/components/editor/historysearch_test.go b/pkg/tui/components/editor/historysearch_test.go index edea318ae..7294f1b55 100644 --- a/pkg/tui/components/editor/historysearch_test.go +++ b/pkg/tui/components/editor/historysearch_test.go @@ -7,7 +7,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/docker/docker-agent/pkg/app" "github.com/docker/docker-agent/pkg/history" ) @@ -30,7 +29,7 @@ func TestHistorySearch(t *testing.T) { require.NoError(t, h.Add(msg)) } - e := New(&app.App{}, h).(*editor) + e := New(h).(*editor) e.textarea.SetWidth(80) return e } diff --git a/pkg/tui/components/editor/suggestion_test.go b/pkg/tui/components/editor/suggestion_test.go index 47044bb7c..ece63a415 100644 --- a/pkg/tui/components/editor/suggestion_test.go +++ b/pkg/tui/components/editor/suggestion_test.go @@ -7,7 +7,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/docker/docker-agent/pkg/app" "github.com/docker/docker-agent/pkg/history" ) @@ -62,8 +61,7 @@ func TestApplySuggestionOverlay(t *testing.T) { // Create a simple editor for testing hist, _ := history.New() - a := &app.App{} // minimal app for testing - e := New(a, hist).(*editor) + e := New(hist).(*editor) // Set up textarea with test content e.textarea.SetValue(tt.textareaText) @@ -97,8 +95,7 @@ func TestApplySuggestionOverlayScrolledView(t *testing.T) { t.Parallel() hist, _ := history.New() - a := &app.App{} - e := New(a, hist).(*editor) + e := New(hist).(*editor) // Set up a multi-line textarea that's scrolled content := "line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\ncurrent" @@ -195,8 +192,7 @@ func TestApplySuggestionOverlayWithVariousLineCount(t *testing.T) { t.Parallel() hist, _ := history.New() - a := &app.App{} - e := New(a, hist).(*editor) + e := New(hist).(*editor) e.textarea.SetValue(tt.textareaText) e.textarea.SetWidth(80) @@ -274,8 +270,7 @@ func TestApplySuggestionOverlayWithMultiLineSuggestion(t *testing.T) { t.Parallel() hist, _ := history.New() - a := &app.App{} - e := New(a, hist).(*editor) + e := New(hist).(*editor) e.textarea.SetValue(tt.textareaText) e.textarea.SetWidth(80) @@ -389,8 +384,7 @@ func TestIsCursorAtEnd(t *testing.T) { t.Parallel() hist, _ := history.New() - a := &app.App{} - e := New(a, hist).(*editor) + e := New(hist).(*editor) e.textarea.SetValue(tt.value) e.textarea.SetWidth(tt.width) @@ -461,8 +455,7 @@ func TestMultiLineSuggestionWithSmallEditor(t *testing.T) { t.Parallel() hist, _ := history.New() - a := &app.App{} - e := New(a, hist).(*editor) + e := New(hist).(*editor) // Set up editor with minimal height e.textarea.SetValue("A") @@ -496,8 +489,7 @@ func TestLongSuggestionWrapping(t *testing.T) { t.Parallel() hist, _ := history.New() - a := &app.App{} - e := New(a, hist).(*editor) + e := New(hist).(*editor) // Set up editor with narrow width e.textarea.SetValue("L") diff --git a/pkg/tui/core/core.go b/pkg/tui/core/core.go index d2083997d..d9a4301ce 100644 --- a/pkg/tui/core/core.go +++ b/pkg/tui/core/core.go @@ -1,6 +1,8 @@ package core import ( + "fmt" + "charm.land/bubbles/v2/help" "charm.land/bubbles/v2/key" tea "charm.land/bubbletea/v2" @@ -39,3 +41,32 @@ func CmdHandler(msg tea.Msg) tea.Cmd { return msg } } + +// Resolve retrieves a dependency of type T from the given tea.Model. +// +// This function provides a type-safe way to access dependencies (such as *app.App, +// *service.SessionState, chat.Page, or editor.Editor) from a model that implements +// the Resolve(any) any method. The model acts as a dependency provider, allowing +// command handlers and other components to access shared state without tight coupling. +// +// Usage: +// +// app := core.Resolve[*app.App](model) +// sessionState := core.Resolve[*service.SessionState](model) +// chatPage := core.Resolve[chat.Page](model) +// +// Panics if the model does not implement Resolve or cannot provide the requested type. +// +// # Experimental +// +// Notice: This function is EXPERIMENTAL and may be changed or removed in a +// later release. +func Resolve[T any](m tea.Model) T { + if r, ok := m.(interface{ Resolve(any) any }); ok { + if v := r.Resolve(any((*T)(nil))); v != nil { + return v.(T) + } + panic(fmt.Sprintf("tui/core: model cannot provide type %T", *new(T))) + } + panic("tui/core: model does not implement Resolve(any) any") +} diff --git a/pkg/tui/page/chat/chat.go b/pkg/tui/page/chat/chat.go index f6c261599..5b6d7fb0b 100644 --- a/pkg/tui/page/chat/chat.go +++ b/pkg/tui/page/chat/chat.go @@ -164,6 +164,9 @@ type chatPage struct { app *app.App + // Command parser for handling slash commands in the editor + commandParser *commands.Parser + // Sidebar drag state isDraggingSidebar bool // True while dragging the sidebar resize handle sidebarDragStartX int // X position when drag started @@ -313,11 +316,12 @@ func getEditorDisplayNameFromEnv(visual, editorEnv string) string { // New creates a new chat page func New(a *app.App, sessionState *service.SessionState, opts ...PageOption) Page { p := &chatPage{ - sidebar: sidebar.New(sessionState), - messages: messages.New(sessionState), - app: a, - keyMap: defaultKeyMap(), - sessionState: sessionState, + sidebar: sidebar.New(sessionState), + messages: messages.New(sessionState), + app: a, + keyMap: defaultKeyMap(), + commandParser: commands.NewParser(), + sessionState: sessionState, } for _, opt := range opts { @@ -337,6 +341,13 @@ func WithLeanMode() PageOption { } } +// WithCommandParser injects a command parser for handling slash commands in the editor. +func WithCommandParser(p *commands.Parser) PageOption { + return func(cp *chatPage) { + cp.commandParser = p + } +} + // Init initializes the chat page func (p *chatPage) Init() tea.Cmd { var cmds []tea.Cmd @@ -665,7 +676,7 @@ func (p *chatPage) handleSendMsg(msg msgtypes.SendMsg) (layout.Model, tea.Cmd) { // Predefined slash commands (e.g., /yolo, /exit, /compact) execute immediately // even while the agent is working - they're UI commands that don't interrupt the stream. // Custom agent commands (defined in config) should still be queued. - if commands.ParseSlashCommand(msg.Content) != nil { + if p.commandParser.Parse(msg.Content) != nil { cmd := p.processMessage(msg) return p, cmd } @@ -873,7 +884,7 @@ func (p *chatPage) syncQueueToSidebar() { func (p *chatPage) processMessage(msg msgtypes.SendMsg) tea.Cmd { // Handle slash commands (e.g., /eval, /compact, /exit) BEFORE cancelling any ongoing stream. // These are UI commands that shouldn't interrupt the running agent. - if cmd := commands.ParseSlashCommand(msg.Content); cmd != nil { + if cmd := p.commandParser.Parse(msg.Content); cmd != nil { return cmd } diff --git a/pkg/tui/tui.go b/pkg/tui/tui.go index 2ed89fcf5..f211270e5 100644 --- a/pkg/tui/tui.go +++ b/pkg/tui/tui.go @@ -27,6 +27,7 @@ import ( "github.com/docker/docker-agent/pkg/tui/commands" "github.com/docker/docker-agent/pkg/tui/components/completion" "github.com/docker/docker-agent/pkg/tui/components/editor" + "github.com/docker/docker-agent/pkg/tui/components/editor/completions" "github.com/docker/docker-agent/pkg/tui/components/notification" "github.com/docker/docker-agent/pkg/tui/components/spinner" "github.com/docker/docker-agent/pkg/tui/components/statusbar" @@ -159,6 +160,9 @@ type appModel struct { // leanMode enables a simplified TUI with minimal chrome. leanMode bool + + // buildCommandCategories is a function that returns the list of command categories. + buildCommandCategories func(context.Context, tea.Model) []commands.Category } // Option configures the TUI. @@ -172,6 +176,23 @@ func WithLeanMode() Option { } } +// WithCommandBuilder builds the command categories shown in the command +// palette from the given function. It overrides the default command category +// builder. To include the default commands, the given function should call +// commands.BuildCommandCategories and merge the result with its own. +// +// The tea.Model passed to the builder function must not be accessed during +// the build call itself - it should only be captured for use within command +// Execute functions. There is no guarantee that the tea.Model holds all +// dependencies during the build phase, which may cause [core.Resolve] to panic. +func WithCommandBuilder( + fn func(context.Context, tea.Model) []commands.Category, +) Option { + return func(m *appModel) { + m.buildCommandCategories = fn + } +} + // New creates a new Model. func New(ctx context.Context, spawner SessionSpawner, initialApp *app.App, initialWorkingDir string, cleanup func(), opts ...Option) tea.Model { // Initialize supervisor @@ -196,19 +217,20 @@ func New(ctx context.Context, spawner SessionSpawner, initialApp *app.App, initi } initialSessionState := service.NewSessionState(initialApp.Session()) - initialEditor := editor.New(initialApp, historyStore) sessID := initialApp.Session().ID m := &appModel{ + buildCommandCategories: func(ctx context.Context, _ tea.Model) []commands.Category { + return commands.BuildCommandCategories(ctx, initialApp) + }, supervisor: sv, tabBar: tb, tuiStore: ts, chatPages: map[string]chat.Page{}, + editors: map[string]editor.Editor{}, sessionStates: map[string]*service.SessionState{sessID: initialSessionState}, - editors: map[string]editor.Editor{sessID: initialEditor}, application: initialApp, sessionState: initialSessionState, - editor: initialEditor, history: historyStore, pendingRestores: make(map[string]string), pendingSidebarCollapsed: make(map[string]bool), @@ -227,6 +249,11 @@ func New(ctx context.Context, spawner SessionSpawner, initialApp *app.App, initi opt(m) } + // Create initial editor (after options are applied so command builder is set) + initialEditor := editor.New(historyStore, m.editorOpts()...) + m.editors[sessID] = initialEditor + m.editor = initialEditor + // Create initial chat page (after options are applied so leanMode is set) initialChatPage := chat.New(initialApp, initialSessionState, m.chatPageOpts()...) m.chatPages[sessID] = initialChatPage @@ -261,6 +288,23 @@ func New(ctx context.Context, spawner SessionSpawner, initialApp *app.App, initi return m } +// Resolve implements dependency resolution for the appModel. +// See core.Resolve for additional information. +func (m *appModel) Resolve(v any) any { + switch v.(type) { + case **app.App: + return m.application + case **service.SessionState: + return m.sessionState + case *chat.Page: + return m.chatPage + case *editor.Editor: + return m.editor + } + + return nil +} + // SetProgram sets the tea.Program for the supervisor to send routed messages. func (m *appModel) SetProgram(p *tea.Program) { m.program = p @@ -280,23 +324,40 @@ func (m *appModel) reapplyKeyboardEnhancements() { m.editor = editorModel.(editor.Editor) } +func (m *appModel) commandCategories() []commands.Category { + return m.buildCommandCategories(context.Background(), m) +} + // chatPageOpts returns the chat.PageOption slice derived from the current // appModel configuration (e.g. lean mode). func (m *appModel) chatPageOpts() []chat.PageOption { - var opts []chat.PageOption + opts := []chat.PageOption{ + chat.WithCommandParser(commands.NewParser(m.commandCategories()...)), + } + if m.leanMode { opts = append(opts, chat.WithLeanMode()) } return opts } +// editorOpts returns the editor.Option slice derived from the current appModel. +func (m *appModel) editorOpts() []editor.Option { + return []editor.Option{ + editor.WithCompletions( + completions.NewCommandCompletion(m.commandCategories()), + completions.NewFileCompletion(), + ), + } +} + // initSessionComponents creates a new chat page, session state, and editor for // the given app and stores them in the per-session maps under tabID. The active // convenience pointers (m.chatPage, m.sessionState, m.editor) are also updated. func (m *appModel) initSessionComponents(tabID string, a *app.App, sess *session.Session) { ss := service.NewSessionState(sess) cp := chat.New(a, ss, m.chatPageOpts()...) - ed := editor.New(a, m.history) + ed := editor.New(m.history, m.editorOpts()...) m.chatPages[tabID] = cp m.sessionStates[tabID] = ss @@ -1747,7 +1808,7 @@ func (m *appModel) handleKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { return m, tea.Suspend case key.Matches(msg, key.NewBinding(key.WithKeys("ctrl+k"))): - categories := commands.BuildCommandCategories(context.Background(), m.application) + categories := m.commandCategories() return m, core.CmdHandler(dialog.OpenDialogMsg{ Model: dialog.NewCommandPaletteDialog(categories), })