mirror of
https://github.com/sst/opencode.git
synced 2025-08-04 13:30:52 +00:00
1043 lines
27 KiB
Go
1043 lines
27 KiB
Go
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/config"
|
|
"github.com/sst/opencode/internal/tui/app"
|
|
|
|
"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/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 {
|
|
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, 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:
|
|
// 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 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
|
|
}
|
|
}
|
|
|
|
// Handle SSE events from the TypeScript backend
|
|
case *client.EventStorageWrite:
|
|
slog.Debug("Received SSE event", "key", msg.Key)
|
|
|
|
// Create a deep copy of the state to avoid mutation issues
|
|
newState := deepCopyState(a.app.State)
|
|
|
|
// Split the key and traverse/create the nested structure
|
|
splits := strings.Split(msg.Key, "/")
|
|
current := newState
|
|
|
|
for i, part := range splits {
|
|
if i == len(splits)-1 {
|
|
// Last part - set the value
|
|
current[part] = msg.Content
|
|
} else {
|
|
// Intermediate parts - ensure map exists
|
|
if _, exists := current[part]; !exists {
|
|
current[part] = make(map[string]any)
|
|
}
|
|
|
|
// Navigate to the next level
|
|
nextLevel, ok := current[part].(map[string]any)
|
|
if !ok {
|
|
// If it's not a map, replace it with a new map
|
|
current[part] = make(map[string]any)
|
|
nextLevel = current[part].(map[string]any)
|
|
}
|
|
current = nextLevel
|
|
}
|
|
}
|
|
|
|
// Update the app state
|
|
a.app.State = newState
|
|
|
|
// Trigger UI update by updating all pages with the new state
|
|
return a.updateAllPages(state.StateUpdatedMsg{State: newState})
|
|
|
|
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
|
|
|
|
// TODO: Agent model update not implemented in API yet
|
|
// 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))
|
|
status.Info("Model selection not implemented in API yet")
|
|
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)
|
|
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.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 !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)
|
|
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.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)
|
|
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
|
|
}
|
|
|
|
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 {
|
|
// TODO: Tools not implemented in API yet
|
|
return []string{"Tools not available in API mode"}
|
|
/*
|
|
// 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 {
|
|
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.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
|
|
}
|
|
|
|
// deepCopyState creates a deep copy of a map[string]any
|
|
func deepCopyState(src map[string]any) map[string]any {
|
|
if src == nil {
|
|
return nil
|
|
}
|
|
|
|
dst := make(map[string]any, len(src))
|
|
for k, v := range src {
|
|
switch val := v.(type) {
|
|
case map[string]any:
|
|
// Recursively copy nested maps
|
|
dst[k] = deepCopyState(val)
|
|
default:
|
|
// For other types, just copy the value
|
|
// Note: This is still a shallow copy for slices/arrays
|
|
dst[k] = v
|
|
}
|
|
}
|
|
return dst
|
|
}
|
|
|
|
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),
|
|
},
|
|
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
|
|
}
|