opencode/internal/tui/tui.go
2025-05-28 15:36:36 -05:00

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
}