opencode/internal/tui/tui.go
2025-05-13 10:02:39 -05:00

877 lines
22 KiB
Go

package tui
import (
"context"
"fmt"
"log/slog"
"strings"
"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/logging"
"github.com/sst/opencode/internal/message"
"github.com/sst/opencode/internal/permission"
"github.com/sst/opencode/internal/pubsub"
"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/util"
)
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
}
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"),
),
}
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
showArgumentsDialog bool
argumentsDialog dialog.ArgumentsDialogCmp
}
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)
// 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 spinner.TickMsg:
return a.updateAllPages(msg)
case tea.WindowSizeMsg:
msg.Height -= 1 // 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.showArgumentsDialog {
a.argumentsDialog.SetSize(msg.Width, msg.Height)
args, argsCmd := a.argumentsDialog.Update(msg)
a.argumentsDialog = args.(dialog.ArgumentsDialogCmp)
cmds = append(cmds, argsCmd, a.argumentsDialog.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 dialog.CloseQuitMsg:
a.showQuit = false
return a, nil
case dialog.CloseSessionDialogMsg:
a.showSessionDialog = false
return a, nil
case dialog.CloseCommandDialogMsg:
a.showCommandDialog = false
return a, nil
case dialog.CloseThemeDialogMsg:
a.showThemeDialog = false
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 chat.SessionSelectedMsg:
a.sessionDialog.SetSelectedSession(msg.ID)
case dialog.SessionSelectedMsg:
a.showSessionDialog = false
if a.currentPage == page.ChatPage {
return a, util.CmdHandler(chat.SessionSelectedMsg(msg.Session))
}
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.ShowArgumentsDialogMsg:
// Show arguments dialog
a.argumentsDialog = dialog.NewArgumentsDialogCmp(msg.CommandID, msg.Content)
a.showArgumentsDialog = true
return a, a.argumentsDialog.Init()
case dialog.CloseArgumentsDialogMsg:
// Close arguments dialog
a.showArgumentsDialog = false
// If submitted, replace $ARGUMENTS and run the command
if msg.Submit {
// Replace $ARGUMENTS with the provided arguments
content := strings.ReplaceAll(msg.Content, "$ARGUMENTS", msg.Arguments)
// Execute the command with arguments
return a, util.CmdHandler(dialog.CommandRunCustomMsg{
Content: content,
})
}
return a, nil
case tea.KeyMsg:
// If arguments dialog is open, let it handle the key press first
if a.showArgumentsDialog {
args, cmd := a.argumentsDialog.Update(msg)
a.argumentsDialog = args.(dialog.ArgumentsDialogCmp)
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.showArgumentsDialog {
a.showArgumentsDialog = false
}
return a, nil
case key.Matches(msg, keys.SwitchSession):
if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showCommandDialog {
// 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 {
// 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 {
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 {
a.showThemeDialog = true
return a, a.themeDialog.Init()
}
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.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
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):
a.showFilepicker = !a.showFilepicker
a.filepicker.ToggleFilepicker(a.showFilepicker)
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...)
}
}
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)
}
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.showArgumentsDialog {
overlay := a.argumentsDialog.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.LSPClients),
help: dialog.NewHelpCmp(),
quit: dialog.NewQuitCmp(),
sessionDialog: dialog.NewSessionDialogCmp(),
commandDialog: dialog.NewCommandDialogCmp(),
modelDialog: dialog.NewModelDialogCmp(),
permissions: dialog.NewPermissionDialogCmp(),
initDialog: dialog.NewInitDialogCmp(),
themeDialog: dialog.NewThemeDialogCmp(),
app: app,
commands: []dialog.Command{},
pages: map[page.PageID]tea.Model{
page.ChatPage: page.NewChatPage(app),
page.LogsPage: page.NewLogsPage(),
},
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(chat.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
}