mirror of
https://github.com/sst/opencode.git
synced 2025-08-04 13:30:52 +00:00
877 lines
22 KiB
Go
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
|
|
}
|