|
|
@@ -0,0 +1,1023 @@
|
|
|
+package tui
|
|
|
+
|
|
|
+import (
|
|
|
+ "context"
|
|
|
+ "fmt"
|
|
|
+ "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/config"
|
|
|
+ "github.com/sst/opencode/internal/llm/agent"
|
|
|
+ "github.com/sst/opencode/internal/logging"
|
|
|
+ "github.com/sst/opencode/internal/message"
|
|
|
+ "github.com/sst/opencode/internal/permission"
|
|
|
+ "github.com/sst/opencode/internal/pubsub"
|
|
|
+ "github.com/sst/opencode/internal/session"
|
|
|
+ "github.com/sst/opencode/internal/status"
|
|
|
+ "github.com/sst/opencode/internal/tui/components/chat"
|
|
|
+ "github.com/sst/opencode/internal/tui/components/core"
|
|
|
+ "github.com/sst/opencode/internal/tui/components/dialog"
|
|
|
+ "github.com/sst/opencode/internal/tui/components/logs"
|
|
|
+ "github.com/sst/opencode/internal/tui/layout"
|
|
|
+ "github.com/sst/opencode/internal/tui/page"
|
|
|
+ "github.com/sst/opencode/internal/tui/state"
|
|
|
+ "github.com/sst/opencode/internal/tui/util"
|
|
|
+ "github.com/sst/opencode/pkg/client"
|
|
|
+)
|
|
|
+
|
|
|
+type keyMap struct {
|
|
|
+ Logs key.Binding
|
|
|
+ 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{
|
|
|
+ Logs: key.NewBinding(
|
|
|
+ key.WithKeys("ctrl+l"),
|
|
|
+ key.WithHelp("ctrl+l", "logs"),
|
|
|
+ ),
|
|
|
+
|
|
|
+ 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"),
|
|
|
+)
|
|
|
+
|
|
|
+var logsKeyReturnKey = key.NewBinding(
|
|
|
+ key.WithKeys("esc", "backspace", quitKey),
|
|
|
+ key.WithHelp("esc/q", "go back"),
|
|
|
+)
|
|
|
+
|
|
|
+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, err := config.ShouldShowInitDialog()
|
|
|
+ if err != nil {
|
|
|
+ status.Error("Failed to check init status: " + err.Error())
|
|
|
+ return 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)
|
|
|
+ }
|
|
|
+ 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 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:
|
|
|
+ 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, cmd
|
|
|
+
|
|
|
+ case page.PageChangeMsg:
|
|
|
+ return a, a.moveToPage(msg.ID)
|
|
|
+
|
|
|
+ case logs.LogsLoadedMsg:
|
|
|
+ a.pages[page.LogsPage], cmd = a.pages[page.LogsPage].Update(msg)
|
|
|
+ cmds = append(cmds, cmd)
|
|
|
+
|
|
|
+ case state.SessionSelectedMsg:
|
|
|
+ a.app.CurrentSession = msg
|
|
|
+ return a.updateAllPages(msg)
|
|
|
+
|
|
|
+ case pubsub.Event[session.Session]:
|
|
|
+ if msg.Type == session.EventSessionUpdated {
|
|
|
+ if a.app.CurrentSession.ID == msg.Payload.ID {
|
|
|
+ a.app.CurrentSession = &msg.Payload
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ 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 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.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.CloseModelDialogMsg:
|
|
|
+ a.showModelDialog = false
|
|
|
+ return a, nil
|
|
|
+
|
|
|
+ case dialog.ModelSelectedMsg:
|
|
|
+ a.showModelDialog = false
|
|
|
+
|
|
|
+ model, err := a.app.PrimaryAgent.Update(config.AgentPrimary, msg.Model.ID)
|
|
|
+ if err != nil {
|
|
|
+ status.Error(err.Error())
|
|
|
+ return a, nil
|
|
|
+ }
|
|
|
+
|
|
|
+ status.Info(fmt.Sprintf("Model changed to %s", model.Name))
|
|
|
+ return a, nil
|
|
|
+
|
|
|
+ case dialog.ShowInitDialogMsg:
|
|
|
+ a.showInitDialog = msg.Show
|
|
|
+ return a, nil
|
|
|
+
|
|
|
+ case dialog.CloseInitDialogMsg:
|
|
|
+ a.showInitDialog = false
|
|
|
+ if msg.Initialize {
|
|
|
+ // Run the initialization command
|
|
|
+ for _, cmd := range a.commands {
|
|
|
+ if cmd.ID == "init" {
|
|
|
+ // Mark the project as initialized
|
|
|
+ if err := config.MarkProjectInitialized(); err != nil {
|
|
|
+ status.Error(err.Error())
|
|
|
+ return a, nil
|
|
|
+ }
|
|
|
+ return a, cmd.Handler(cmd)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ // 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
|
|
|
+
|
|
|
+ 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)
|
|
|
+ }
|
|
|
+ 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.Sessions.List(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
|
|
|
+
|
|
|
+ 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 msg.String() == quitKey {
|
|
|
+ if a.currentPage == page.LogsPage {
|
|
|
+ return a, a.moveToPage(page.ChatPage)
|
|
|
+ }
|
|
|
+ } else 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
|
|
|
+ // 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)
|
|
|
+ return a, nil
|
|
|
+ }
|
|
|
+ if a.currentPage == page.LogsPage {
|
|
|
+ // Always allow returning from logs page, even when agent is busy
|
|
|
+ return a, a.moveToPageUnconditional(page.ChatPage)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ case key.Matches(msg, keys.Logs):
|
|
|
+ return a, a.moveToPage(page.LogsPage)
|
|
|
+ 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.PrimaryAgent.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)
|
|
|
+
|
|
|
+ // 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
|
|
|
+ }
|
|
|
+
|
|
|
+ case pubsub.Event[logging.Log]:
|
|
|
+ a.pages[page.LogsPage], cmd = a.pages[page.LogsPage].Update(msg)
|
|
|
+ cmds = append(cmds, cmd)
|
|
|
+ return a, tea.Batch(cmds...)
|
|
|
+
|
|
|
+ case pubsub.Event[message.Message]:
|
|
|
+ a.pages[page.ChatPage], cmd = a.pages[page.ChatPage].Update(msg)
|
|
|
+ cmds = append(cmds, cmd)
|
|
|
+ return a, tea.Batch(cmds...)
|
|
|
+
|
|
|
+ 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.App) []string {
|
|
|
+ // Get primary agent tools (which already include MCP tools)
|
|
|
+ allTools := agent.PrimaryAgentTools(
|
|
|
+ app.Permissions,
|
|
|
+ app.Sessions,
|
|
|
+ app.Messages,
|
|
|
+ app.History,
|
|
|
+ app.LSPClients,
|
|
|
+ )
|
|
|
+
|
|
|
+ // Extract tool names
|
|
|
+ var toolNames []string
|
|
|
+ for _, tool := range allTools {
|
|
|
+ toolNames = append(toolNames, tool.Info().Name)
|
|
|
+ }
|
|
|
+
|
|
|
+ return toolNames
|
|
|
+}
|
|
|
+
|
|
|
+func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
|
|
|
+ // Allow navigating to logs page even when agent is busy
|
|
|
+ if a.app.PrimaryAgent.IsBusy() && pageID != page.LogsPage {
|
|
|
+ // Don't move to other pages if the agent is busy
|
|
|
+ status.Warn("Agent is busy, please wait...")
|
|
|
+ return nil
|
|
|
+ }
|
|
|
+
|
|
|
+ return a.moveToPageUnconditional(pageID)
|
|
|
+}
|
|
|
+
|
|
|
+// moveToPageUnconditional is like moveToPage but doesn't check if the agent is busy
|
|
|
+func (a *appModel) moveToPageUnconditional(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.app.PrimaryAgent.IsBusy() {
|
|
|
+ a.status.SetHelpWidgetMsg("ctrl+? help")
|
|
|
+ } else {
|
|
|
+ a.status.SetHelpWidgetMsg("? help")
|
|
|
+ }
|
|
|
+
|
|
|
+ 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.currentPage == page.LogsPage {
|
|
|
+ bindings = append(bindings, logsKeyReturnKey)
|
|
|
+ }
|
|
|
+ if !a.app.PrimaryAgent.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 New(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(),
|
|
|
+ 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),
|
|
|
+ page.LogsPage: page.NewLogsPage(app),
|
|
|
+ },
|
|
|
+ filepicker: dialog.NewFilepickerCmp(app),
|
|
|
+ }
|
|
|
+
|
|
|
+ model.RegisterCommand(dialog.Command{
|
|
|
+ ID: "init",
|
|
|
+ Title: "Initialize Project",
|
|
|
+ Description: "Create/Update the CONTEXT.md memory file",
|
|
|
+ Handler: func(cmd dialog.Command) tea.Cmd {
|
|
|
+ prompt := `Please analyze this codebase and create a CONTEXT.md file containing:
|
|
|
+1. Build/lint/test commands - especially for running a single test
|
|
|
+2. Code style guidelines including imports, formatting, types, naming conventions, error handling, etc.
|
|
|
+
|
|
|
+The file you create will be given to agentic coding agents (such as yourself) that operate in this repository. Make it about 20 lines long.
|
|
|
+If there's already a CONTEXT.md, improve it.
|
|
|
+If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md), make sure to include them.`
|
|
|
+ return tea.Batch(
|
|
|
+ util.CmdHandler(chat.SendMsg{
|
|
|
+ Text: prompt,
|
|
|
+ }),
|
|
|
+ )
|
|
|
+ },
|
|
|
+ })
|
|
|
+
|
|
|
+ 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()
|
|
|
+ if err != nil {
|
|
|
+ slog.Warn("Failed to load custom commands", "error", err)
|
|
|
+ } else {
|
|
|
+ for _, cmd := range customCommands {
|
|
|
+ model.RegisterCommand(cmd)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return model
|
|
|
+}
|