package tui import ( "context" "log/slog" "strings" "github.com/charmbracelet/bubbles/cursor" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/sst/opencode/internal/app" "github.com/sst/opencode/internal/components/core" "github.com/sst/opencode/internal/components/dialog" "github.com/sst/opencode/internal/layout" "github.com/sst/opencode/internal/page" "github.com/sst/opencode/internal/state" "github.com/sst/opencode/internal/status" "github.com/sst/opencode/internal/util" "github.com/sst/opencode/pkg/client" ) type keyMap struct { Quit key.Binding Help key.Binding SwitchSession key.Binding Commands key.Binding Filepicker key.Binding Models key.Binding SwitchTheme key.Binding Tools key.Binding } const ( quitKey = "q" ) var keys = keyMap{ Quit: key.NewBinding( key.WithKeys("ctrl+c"), key.WithHelp("ctrl+c", "quit"), ), Help: key.NewBinding( key.WithKeys("ctrl+_"), key.WithHelp("ctrl+?", "toggle help"), ), SwitchSession: key.NewBinding( key.WithKeys("ctrl+s"), key.WithHelp("ctrl+s", "switch session"), ), Commands: key.NewBinding( key.WithKeys("ctrl+k"), key.WithHelp("ctrl+k", "commands"), ), Filepicker: key.NewBinding( key.WithKeys("ctrl+f"), key.WithHelp("ctrl+f", "select files to upload"), ), Models: key.NewBinding( key.WithKeys("ctrl+o"), key.WithHelp("ctrl+o", "model selection"), ), SwitchTheme: key.NewBinding( key.WithKeys("ctrl+t"), key.WithHelp("ctrl+t", "switch theme"), ), Tools: key.NewBinding( key.WithKeys("f9"), key.WithHelp("f9", "show available tools"), ), } var helpEsc = key.NewBinding( key.WithKeys("?"), key.WithHelp("?", "toggle help"), ) var returnKey = key.NewBinding( key.WithKeys("esc"), key.WithHelp("esc", "close"), ) type appModel struct { width, height int currentPage page.PageID previousPage page.PageID pages map[page.PageID]tea.Model loadedPages map[page.PageID]bool status core.StatusCmp app *app.App showPermissions bool permissions dialog.PermissionDialogCmp showHelp bool help dialog.HelpCmp showQuit bool quit dialog.QuitDialog showSessionDialog bool sessionDialog dialog.SessionDialog showCommandDialog bool commandDialog dialog.CommandDialog commands []dialog.Command showModelDialog bool modelDialog dialog.ModelDialog showInitDialog bool initDialog dialog.InitDialogCmp showFilepicker bool filepicker dialog.FilepickerCmp showThemeDialog bool themeDialog dialog.ThemeDialog showMultiArgumentsDialog bool multiArgumentsDialog dialog.MultiArgumentsDialogCmp showToolsDialog bool toolsDialog dialog.ToolsDialog } func (a appModel) Init() tea.Cmd { var cmds []tea.Cmd cmd := a.pages[a.currentPage].Init() a.loadedPages[a.currentPage] = true cmds = append(cmds, cmd) cmd = a.status.Init() cmds = append(cmds, cmd) cmd = a.quit.Init() cmds = append(cmds, cmd) cmd = a.help.Init() cmds = append(cmds, cmd) cmd = a.sessionDialog.Init() cmds = append(cmds, cmd) cmd = a.commandDialog.Init() cmds = append(cmds, cmd) cmd = a.modelDialog.Init() cmds = append(cmds, cmd) cmd = a.initDialog.Init() cmds = append(cmds, cmd) cmd = a.filepicker.Init() cmds = append(cmds, cmd) cmd = a.themeDialog.Init() cmds = append(cmds, cmd) cmd = a.toolsDialog.Init() cmds = append(cmds, cmd) // Check if we should show the init dialog cmds = append(cmds, func() tea.Msg { shouldShow := a.app.Info.Git && a.app.Info.Time.Initialized == nil return dialog.ShowInitDialogMsg{Show: shouldShow} }) return tea.Batch(cmds...) } func (a appModel) updateAllPages(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd var cmd tea.Cmd for id := range a.pages { a.pages[id], cmd = a.pages[id].Update(msg) cmds = append(cmds, cmd) } s, cmd := a.status.Update(msg) cmds = append(cmds, cmd) a.status = s.(core.StatusCmp) return a, tea.Batch(cmds...) } func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd var cmd tea.Cmd switch msg := msg.(type) { case cursor.BlinkMsg: return a.updateAllPages(msg) case spinner.TickMsg: return a.updateAllPages(msg) case client.EventSessionUpdated: if msg.Properties.Info.Id == a.app.Session.Id { a.app.Session = &msg.Properties.Info return a.updateAllPages(state.StateUpdatedMsg{State: nil}) } case client.EventMessageUpdated: if msg.Properties.Info.Metadata.SessionID == a.app.Session.Id { for i, m := range a.app.Messages { if m.Id == msg.Properties.Info.Id { a.app.Messages[i] = msg.Properties.Info return a.updateAllPages(state.StateUpdatedMsg{State: nil}) } } a.app.Messages = append(a.app.Messages, msg.Properties.Info) return a.updateAllPages(state.StateUpdatedMsg{State: nil}) } case tea.WindowSizeMsg: msg.Height -= 2 // Make space for the status bar a.width, a.height = msg.Width, msg.Height s, _ := a.status.Update(msg) a.status = s.(core.StatusCmp) a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg) cmds = append(cmds, cmd) prm, permCmd := a.permissions.Update(msg) a.permissions = prm.(dialog.PermissionDialogCmp) cmds = append(cmds, permCmd) help, helpCmd := a.help.Update(msg) a.help = help.(dialog.HelpCmp) cmds = append(cmds, helpCmd) session, sessionCmd := a.sessionDialog.Update(msg) a.sessionDialog = session.(dialog.SessionDialog) cmds = append(cmds, sessionCmd) command, commandCmd := a.commandDialog.Update(msg) a.commandDialog = command.(dialog.CommandDialog) cmds = append(cmds, commandCmd) filepicker, filepickerCmd := a.filepicker.Update(msg) a.filepicker = filepicker.(dialog.FilepickerCmp) cmds = append(cmds, filepickerCmd) a.initDialog.SetSize(msg.Width, msg.Height) if a.showMultiArgumentsDialog { a.multiArgumentsDialog.SetSize(msg.Width, msg.Height) args, argsCmd := a.multiArgumentsDialog.Update(msg) a.multiArgumentsDialog = args.(dialog.MultiArgumentsDialogCmp) cmds = append(cmds, argsCmd, a.multiArgumentsDialog.Init()) } return a, tea.Batch(cmds...) // case pubsub.Event[permission.PermissionRequest]: // a.showPermissions = true // return a, a.permissions.SetPermissions(msg.Payload) case dialog.PermissionResponseMsg: // TODO: Permissions service not implemented in API yet // var cmd tea.Cmd // switch msg.Action { // case dialog.PermissionAllow: // a.app.Permissions.Grant(context.Background(), msg.Permission) // case dialog.PermissionAllowForSession: // a.app.Permissions.GrantPersistant(context.Background(), msg.Permission) // case dialog.PermissionDeny: // a.app.Permissions.Deny(context.Background(), msg.Permission) // } a.showPermissions = false return a, nil case page.PageChangeMsg: return a, a.moveToPage(msg.ID) case dialog.CloseQuitMsg: a.showQuit = false return a, nil case dialog.CloseSessionDialogMsg: a.showSessionDialog = false if msg.Session != nil { return a, util.CmdHandler(state.SessionSelectedMsg(msg.Session)) } return a, nil case state.SessionSelectedMsg: a.app.Session = msg a.app.Messages, _ = a.app.ListMessages(context.Background(), msg.Id) return a.updateAllPages(msg) case dialog.CloseModelDialogMsg: a.showModelDialog = false slog.Debug("closing model dialog", "msg", msg) if msg.Provider != nil && msg.Model != nil { return a, util.CmdHandler(state.ModelSelectedMsg{Provider: *msg.Provider, Model: *msg.Model}) } return a, nil case state.ModelSelectedMsg: a.app.Provider = &msg.Provider a.app.Model = &msg.Model a.app.Config.Provider = msg.Provider.Id a.app.Config.Model = msg.Model.Id a.app.SaveConfig() return a.updateAllPages(msg) case dialog.CloseCommandDialogMsg: a.showCommandDialog = false return a, nil case dialog.CloseThemeDialogMsg: a.showThemeDialog = false return a, nil case dialog.CloseToolsDialogMsg: a.showToolsDialog = false return a, nil case dialog.ShowToolsDialogMsg: a.showToolsDialog = msg.Show return a, nil case dialog.ThemeChangedMsg: a.app.Config.Theme = msg.ThemeName a.app.SaveConfig() a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg) a.showThemeDialog = false status.Info("Theme changed to: " + msg.ThemeName) return a, cmd case dialog.ShowInitDialogMsg: a.showInitDialog = msg.Show return a, nil case dialog.CloseInitDialogMsg: a.showInitDialog = false if msg.Initialize { return a, a.app.InitializeProject(context.Background()) } else { // Mark the project as initialized without running the command if err := a.app.MarkProjectInitialized(context.Background()); err != nil { status.Error(err.Error()) return a, nil } } return a, nil case dialog.CommandSelectedMsg: a.showCommandDialog = false // Execute the command handler if available if msg.Command.Handler != nil { return a, msg.Command.Handler(msg.Command) } status.Info("Command selected: " + msg.Command.Title) return a, nil case dialog.ShowMultiArgumentsDialogMsg: // Show multi-arguments dialog a.multiArgumentsDialog = dialog.NewMultiArgumentsDialogCmp(msg.CommandID, msg.Content, msg.ArgNames) a.showMultiArgumentsDialog = true return a, a.multiArgumentsDialog.Init() case dialog.CloseMultiArgumentsDialogMsg: // Close multi-arguments dialog a.showMultiArgumentsDialog = false // If submitted, replace all named arguments and run the command if msg.Submit { content := msg.Content // Replace each named argument with its value for name, value := range msg.Args { placeholder := "$" + name content = strings.ReplaceAll(content, placeholder, value) } // Execute the command with arguments return a, util.CmdHandler(dialog.CommandRunCustomMsg{ Content: content, Args: msg.Args, }) } return a, nil case tea.KeyMsg: // If multi-arguments dialog is open, let it handle the key press first if a.showMultiArgumentsDialog { args, cmd := a.multiArgumentsDialog.Update(msg) a.multiArgumentsDialog = args.(dialog.MultiArgumentsDialogCmp) return a, cmd } switch { case key.Matches(msg, keys.Quit): a.showQuit = !a.showQuit if a.showHelp { a.showHelp = false } if a.showSessionDialog { a.showSessionDialog = false } if a.showCommandDialog { a.showCommandDialog = false } if a.showFilepicker { a.showFilepicker = false a.filepicker.ToggleFilepicker(a.showFilepicker) a.app.SetFilepickerOpen(a.showFilepicker) } if a.showModelDialog { a.showModelDialog = false } if a.showMultiArgumentsDialog { a.showMultiArgumentsDialog = false } if a.showToolsDialog { a.showToolsDialog = false } return a, nil case key.Matches(msg, keys.SwitchSession): if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showCommandDialog { // Close other dialogs a.showToolsDialog = false a.showThemeDialog = false a.showModelDialog = false a.showFilepicker = false // Load sessions and show the dialog sessions, err := a.app.ListSessions(context.Background()) if err != nil { status.Error(err.Error()) return a, nil } if len(sessions) == 0 { status.Warn("No sessions available") return a, nil } a.sessionDialog.SetSessions(sessions) a.showSessionDialog = true return a, nil } return a, nil case key.Matches(msg, keys.Commands): if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showSessionDialog && !a.showThemeDialog && !a.showFilepicker { // Close other dialogs a.showToolsDialog = false a.showModelDialog = false // Show commands dialog if len(a.commands) == 0 { status.Warn("No commands available") return a, nil } a.commandDialog.SetCommands(a.commands) a.showCommandDialog = true return a, nil } return a, nil case key.Matches(msg, keys.Models): if a.showModelDialog { a.showModelDialog = false return a, nil } if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showSessionDialog && !a.showCommandDialog { // Close other dialogs a.showToolsDialog = false a.showThemeDialog = false a.showFilepicker = false // Load providers and show the dialog providers, err := a.app.ListProviders(context.Background()) if err != nil { status.Error(err.Error()) return a, nil } if len(providers) == 0 { status.Warn("No providers available") return a, nil } a.modelDialog.SetProviders(providers) a.showModelDialog = true return a, nil } return a, nil case key.Matches(msg, keys.SwitchTheme): if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showSessionDialog && !a.showCommandDialog { // Close other dialogs a.showToolsDialog = false a.showModelDialog = false a.showFilepicker = false a.showThemeDialog = true return a, a.themeDialog.Init() } return a, nil case key.Matches(msg, keys.Tools): // Check if any other dialog is open if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showSessionDialog && !a.showCommandDialog && !a.showThemeDialog && !a.showFilepicker && !a.showModelDialog && !a.showInitDialog && !a.showMultiArgumentsDialog { // Toggle tools dialog a.showToolsDialog = !a.showToolsDialog if a.showToolsDialog { // Get tool names dynamically toolNames := getAvailableToolNames(a.app) a.toolsDialog.SetTools(toolNames) } return a, nil } return a, nil case key.Matches(msg, returnKey) || key.Matches(msg): if !a.filepicker.IsCWDFocused() { if a.showToolsDialog { a.showToolsDialog = false return a, nil } if a.showQuit { a.showQuit = !a.showQuit return a, nil } if a.showHelp { a.showHelp = !a.showHelp return a, nil } if a.showInitDialog { a.showInitDialog = false // TODO: should we not ask again? // Mark the project as initialized without running the command // if err := config.MarkProjectInitialized(); err != nil { // status.Error(err.Error()) // return a, nil // } return a, nil } if a.showFilepicker { a.showFilepicker = false a.filepicker.ToggleFilepicker(a.showFilepicker) a.app.SetFilepickerOpen(a.showFilepicker) return a, nil } } case key.Matches(msg, keys.Help): if a.showQuit { return a, nil } a.showHelp = !a.showHelp // Close other dialogs if opening help if a.showHelp { a.showToolsDialog = false } return a, nil case key.Matches(msg, helpEsc): if a.app.IsBusy() { if a.showQuit { return a, nil } a.showHelp = !a.showHelp return a, nil } case key.Matches(msg, keys.Filepicker): // Toggle filepicker a.showFilepicker = !a.showFilepicker a.filepicker.ToggleFilepicker(a.showFilepicker) a.app.SetFilepickerOpen(a.showFilepicker) // Close other dialogs if opening filepicker if a.showFilepicker { a.showToolsDialog = false a.showThemeDialog = false a.showModelDialog = false a.showCommandDialog = false a.showSessionDialog = false } return a, nil } default: f, filepickerCmd := a.filepicker.Update(msg) a.filepicker = f.(dialog.FilepickerCmp) cmds = append(cmds, filepickerCmd) } if a.showFilepicker { f, filepickerCmd := a.filepicker.Update(msg) a.filepicker = f.(dialog.FilepickerCmp) cmds = append(cmds, filepickerCmd) // Only block key messages send all other messages down if _, ok := msg.(tea.KeyMsg); ok { return a, tea.Batch(cmds...) } } if a.showQuit { q, quitCmd := a.quit.Update(msg) a.quit = q.(dialog.QuitDialog) cmds = append(cmds, quitCmd) // Only block key messages send all other messages down if _, ok := msg.(tea.KeyMsg); ok { return a, tea.Batch(cmds...) } } if a.showPermissions { d, permissionsCmd := a.permissions.Update(msg) a.permissions = d.(dialog.PermissionDialogCmp) cmds = append(cmds, permissionsCmd) // Only block key messages send all other messages down if _, ok := msg.(tea.KeyMsg); ok { return a, tea.Batch(cmds...) } } if a.showSessionDialog { d, sessionCmd := a.sessionDialog.Update(msg) a.sessionDialog = d.(dialog.SessionDialog) cmds = append(cmds, sessionCmd) // Only block key messages send all other messages down if _, ok := msg.(tea.KeyMsg); ok { return a, tea.Batch(cmds...) } } if a.showCommandDialog { d, commandCmd := a.commandDialog.Update(msg) a.commandDialog = d.(dialog.CommandDialog) cmds = append(cmds, commandCmd) // Only block key messages send all other messages down if _, ok := msg.(tea.KeyMsg); ok { return a, tea.Batch(cmds...) } } if a.showModelDialog { d, modelCmd := a.modelDialog.Update(msg) a.modelDialog = d.(dialog.ModelDialog) cmds = append(cmds, modelCmd) // Only block key messages send all other messages down if _, ok := msg.(tea.KeyMsg); ok { return a, tea.Batch(cmds...) } } if a.showInitDialog { d, initCmd := a.initDialog.Update(msg) a.initDialog = d.(dialog.InitDialogCmp) cmds = append(cmds, initCmd) // Only block key messages send all other messages down if _, ok := msg.(tea.KeyMsg); ok { return a, tea.Batch(cmds...) } } if a.showThemeDialog { d, themeCmd := a.themeDialog.Update(msg) a.themeDialog = d.(dialog.ThemeDialog) cmds = append(cmds, themeCmd) // Only block key messages send all other messages down if _, ok := msg.(tea.KeyMsg); ok { return a, tea.Batch(cmds...) } } if a.showToolsDialog { d, toolsCmd := a.toolsDialog.Update(msg) a.toolsDialog = d.(dialog.ToolsDialog) cmds = append(cmds, toolsCmd) // Only block key messages send all other messages down if _, ok := msg.(tea.KeyMsg); ok { return a, tea.Batch(cmds...) } } s, cmd := a.status.Update(msg) cmds = append(cmds, cmd) a.status = s.(core.StatusCmp) a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg) cmds = append(cmds, cmd) return a, tea.Batch(cmds...) } // RegisterCommand adds a command to the command dialog func (a *appModel) RegisterCommand(cmd dialog.Command) { a.commands = append(a.commands, cmd) } // getAvailableToolNames returns a list of all available tool names func getAvailableToolNames(_ *app.App) []string { // TODO: Tools not implemented in API yet return []string{"Tools not available in API mode"} } func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd { var cmds []tea.Cmd if _, ok := a.loadedPages[pageID]; !ok { cmd := a.pages[pageID].Init() cmds = append(cmds, cmd) a.loadedPages[pageID] = true } a.previousPage = a.currentPage a.currentPage = pageID if sizable, ok := a.pages[a.currentPage].(layout.Sizeable); ok { cmd := sizable.SetSize(a.width, a.height) cmds = append(cmds, cmd) } return tea.Batch(cmds...) } func (a appModel) View() string { components := []string{ a.pages[a.currentPage].View(), } components = append(components, a.status.View()) appView := lipgloss.JoinVertical(lipgloss.Top, components...) if a.showPermissions { overlay := a.permissions.View() row := lipgloss.Height(appView) / 2 row -= lipgloss.Height(overlay) / 2 col := lipgloss.Width(appView) / 2 col -= lipgloss.Width(overlay) / 2 appView = layout.PlaceOverlay( col, row, overlay, appView, true, ) } if a.showFilepicker { overlay := a.filepicker.View() row := lipgloss.Height(appView) / 2 row -= lipgloss.Height(overlay) / 2 col := lipgloss.Width(appView) / 2 col -= lipgloss.Width(overlay) / 2 appView = layout.PlaceOverlay( col, row, overlay, appView, true, ) } if a.showHelp { bindings := layout.KeyMapToSlice(keys) if p, ok := a.pages[a.currentPage].(layout.Bindings); ok { bindings = append(bindings, p.BindingKeys()...) } if a.showPermissions { bindings = append(bindings, a.permissions.BindingKeys()...) } if !a.app.IsBusy() { bindings = append(bindings, helpEsc) } a.help.SetBindings(bindings) overlay := a.help.View() row := lipgloss.Height(appView) / 2 row -= lipgloss.Height(overlay) / 2 col := lipgloss.Width(appView) / 2 col -= lipgloss.Width(overlay) / 2 appView = layout.PlaceOverlay( col, row, overlay, appView, true, ) } if a.showQuit { overlay := a.quit.View() row := lipgloss.Height(appView) / 2 row -= lipgloss.Height(overlay) / 2 col := lipgloss.Width(appView) / 2 col -= lipgloss.Width(overlay) / 2 appView = layout.PlaceOverlay( col, row, overlay, appView, true, ) } if a.showSessionDialog { overlay := a.sessionDialog.View() row := lipgloss.Height(appView) / 2 row -= lipgloss.Height(overlay) / 2 col := lipgloss.Width(appView) / 2 col -= lipgloss.Width(overlay) / 2 appView = layout.PlaceOverlay( col, row, overlay, appView, true, ) } if a.showModelDialog { overlay := a.modelDialog.View() row := lipgloss.Height(appView) / 2 row -= lipgloss.Height(overlay) / 2 col := lipgloss.Width(appView) / 2 col -= lipgloss.Width(overlay) / 2 appView = layout.PlaceOverlay( col, row, overlay, appView, true, ) } if a.showCommandDialog { overlay := a.commandDialog.View() row := lipgloss.Height(appView) / 2 row -= lipgloss.Height(overlay) / 2 col := lipgloss.Width(appView) / 2 col -= lipgloss.Width(overlay) / 2 appView = layout.PlaceOverlay( col, row, overlay, appView, true, ) } if a.showInitDialog { overlay := a.initDialog.View() appView = layout.PlaceOverlay( a.width/2-lipgloss.Width(overlay)/2, a.height/2-lipgloss.Height(overlay)/2, overlay, appView, true, ) } if a.showThemeDialog { overlay := a.themeDialog.View() row := lipgloss.Height(appView) / 2 row -= lipgloss.Height(overlay) / 2 col := lipgloss.Width(appView) / 2 col -= lipgloss.Width(overlay) / 2 appView = layout.PlaceOverlay( col, row, overlay, appView, true, ) } if a.showMultiArgumentsDialog { overlay := a.multiArgumentsDialog.View() row := lipgloss.Height(appView) / 2 row -= lipgloss.Height(overlay) / 2 col := lipgloss.Width(appView) / 2 col -= lipgloss.Width(overlay) / 2 appView = layout.PlaceOverlay( col, row, overlay, appView, true, ) } if a.showToolsDialog { overlay := a.toolsDialog.View() row := lipgloss.Height(appView) / 2 row -= lipgloss.Height(overlay) / 2 col := lipgloss.Width(appView) / 2 col -= lipgloss.Width(overlay) / 2 appView = layout.PlaceOverlay( col, row, overlay, appView, true, ) } return appView } func NewModel(app *app.App) tea.Model { startPage := page.ChatPage model := &appModel{ currentPage: startPage, loadedPages: make(map[page.PageID]bool), status: core.NewStatusCmp(app), help: dialog.NewHelpCmp(), quit: dialog.NewQuitCmp(), sessionDialog: dialog.NewSessionDialogCmp(), commandDialog: dialog.NewCommandDialogCmp(), modelDialog: dialog.NewModelDialogCmp(app), permissions: dialog.NewPermissionDialogCmp(), initDialog: dialog.NewInitDialogCmp(), themeDialog: dialog.NewThemeDialogCmp(), toolsDialog: dialog.NewToolsDialogCmp(), app: app, commands: []dialog.Command{}, pages: map[page.PageID]tea.Model{ page.ChatPage: page.NewChatPage(app), }, filepicker: dialog.NewFilepickerCmp(app), } model.RegisterCommand(dialog.Command{ ID: "init", Title: "Initialize Project", Description: "Create/Update the AGENTS.md memory file", Handler: func(cmd dialog.Command) tea.Cmd { return app.InitializeProject(context.Background()) }, }) model.RegisterCommand(dialog.Command{ ID: "compact_conversation", Title: "Compact Conversation", Description: "Summarize the current session to save tokens", Handler: func(cmd dialog.Command) tea.Cmd { // Get the current session from the appModel if model.currentPage != page.ChatPage { status.Warn("Please navigate to a chat session first.") return nil } // Return a message that will be handled by the chat page status.Info("Compacting conversation...") return util.CmdHandler(state.CompactSessionMsg{}) }, }) // Load custom commands customCommands, err := dialog.LoadCustomCommands(app) if err != nil { slog.Warn("Failed to load custom commands", "error", err) } else { for _, cmd := range customCommands { model.RegisterCommand(cmd) } } return model }