tui.go 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898
  1. package tui
  2. import (
  3. "context"
  4. "fmt"
  5. "log/slog"
  6. "strings"
  7. "github.com/charmbracelet/bubbles/cursor"
  8. "github.com/charmbracelet/bubbles/key"
  9. "github.com/charmbracelet/bubbles/spinner"
  10. tea "github.com/charmbracelet/bubbletea"
  11. "github.com/charmbracelet/lipgloss"
  12. "github.com/sst/opencode/internal/app"
  13. "github.com/sst/opencode/internal/config"
  14. "github.com/sst/opencode/internal/logging"
  15. "github.com/sst/opencode/internal/message"
  16. "github.com/sst/opencode/internal/permission"
  17. "github.com/sst/opencode/internal/pubsub"
  18. "github.com/sst/opencode/internal/session"
  19. "github.com/sst/opencode/internal/status"
  20. "github.com/sst/opencode/internal/tui/components/chat"
  21. "github.com/sst/opencode/internal/tui/components/core"
  22. "github.com/sst/opencode/internal/tui/components/dialog"
  23. "github.com/sst/opencode/internal/tui/components/logs"
  24. "github.com/sst/opencode/internal/tui/layout"
  25. "github.com/sst/opencode/internal/tui/page"
  26. "github.com/sst/opencode/internal/tui/state"
  27. "github.com/sst/opencode/internal/tui/util"
  28. )
  29. type keyMap struct {
  30. Logs key.Binding
  31. Quit key.Binding
  32. Help key.Binding
  33. SwitchSession key.Binding
  34. Commands key.Binding
  35. Filepicker key.Binding
  36. Models key.Binding
  37. SwitchTheme key.Binding
  38. }
  39. const (
  40. quitKey = "q"
  41. )
  42. var keys = keyMap{
  43. Logs: key.NewBinding(
  44. key.WithKeys("ctrl+l"),
  45. key.WithHelp("ctrl+l", "logs"),
  46. ),
  47. Quit: key.NewBinding(
  48. key.WithKeys("ctrl+c"),
  49. key.WithHelp("ctrl+c", "quit"),
  50. ),
  51. Help: key.NewBinding(
  52. key.WithKeys("ctrl+_"),
  53. key.WithHelp("ctrl+?", "toggle help"),
  54. ),
  55. SwitchSession: key.NewBinding(
  56. key.WithKeys("ctrl+s"),
  57. key.WithHelp("ctrl+s", "switch session"),
  58. ),
  59. Commands: key.NewBinding(
  60. key.WithKeys("ctrl+k"),
  61. key.WithHelp("ctrl+k", "commands"),
  62. ),
  63. Filepicker: key.NewBinding(
  64. key.WithKeys("ctrl+f"),
  65. key.WithHelp("ctrl+f", "select files to upload"),
  66. ),
  67. Models: key.NewBinding(
  68. key.WithKeys("ctrl+o"),
  69. key.WithHelp("ctrl+o", "model selection"),
  70. ),
  71. SwitchTheme: key.NewBinding(
  72. key.WithKeys("ctrl+t"),
  73. key.WithHelp("ctrl+t", "switch theme"),
  74. ),
  75. }
  76. var helpEsc = key.NewBinding(
  77. key.WithKeys("?"),
  78. key.WithHelp("?", "toggle help"),
  79. )
  80. var returnKey = key.NewBinding(
  81. key.WithKeys("esc"),
  82. key.WithHelp("esc", "close"),
  83. )
  84. var logsKeyReturnKey = key.NewBinding(
  85. key.WithKeys("esc", "backspace", quitKey),
  86. key.WithHelp("esc/q", "go back"),
  87. )
  88. type appModel struct {
  89. width, height int
  90. currentPage page.PageID
  91. previousPage page.PageID
  92. pages map[page.PageID]tea.Model
  93. loadedPages map[page.PageID]bool
  94. status core.StatusCmp
  95. app *app.App
  96. showPermissions bool
  97. permissions dialog.PermissionDialogCmp
  98. showHelp bool
  99. help dialog.HelpCmp
  100. showQuit bool
  101. quit dialog.QuitDialog
  102. showSessionDialog bool
  103. sessionDialog dialog.SessionDialog
  104. showCommandDialog bool
  105. commandDialog dialog.CommandDialog
  106. commands []dialog.Command
  107. showModelDialog bool
  108. modelDialog dialog.ModelDialog
  109. showInitDialog bool
  110. initDialog dialog.InitDialogCmp
  111. showFilepicker bool
  112. filepicker dialog.FilepickerCmp
  113. showThemeDialog bool
  114. themeDialog dialog.ThemeDialog
  115. showMultiArgumentsDialog bool
  116. multiArgumentsDialog dialog.MultiArgumentsDialogCmp
  117. }
  118. func (a appModel) Init() tea.Cmd {
  119. var cmds []tea.Cmd
  120. cmd := a.pages[a.currentPage].Init()
  121. a.loadedPages[a.currentPage] = true
  122. cmds = append(cmds, cmd)
  123. cmd = a.status.Init()
  124. cmds = append(cmds, cmd)
  125. cmd = a.quit.Init()
  126. cmds = append(cmds, cmd)
  127. cmd = a.help.Init()
  128. cmds = append(cmds, cmd)
  129. cmd = a.sessionDialog.Init()
  130. cmds = append(cmds, cmd)
  131. cmd = a.commandDialog.Init()
  132. cmds = append(cmds, cmd)
  133. cmd = a.modelDialog.Init()
  134. cmds = append(cmds, cmd)
  135. cmd = a.initDialog.Init()
  136. cmds = append(cmds, cmd)
  137. cmd = a.filepicker.Init()
  138. cmds = append(cmds, cmd)
  139. cmd = a.themeDialog.Init()
  140. cmds = append(cmds, cmd)
  141. // Check if we should show the init dialog
  142. cmds = append(cmds, func() tea.Msg {
  143. shouldShow, err := config.ShouldShowInitDialog()
  144. if err != nil {
  145. status.Error("Failed to check init status: " + err.Error())
  146. return nil
  147. }
  148. return dialog.ShowInitDialogMsg{Show: shouldShow}
  149. })
  150. return tea.Batch(cmds...)
  151. }
  152. func (a appModel) updateAllPages(msg tea.Msg) (tea.Model, tea.Cmd) {
  153. var cmds []tea.Cmd
  154. var cmd tea.Cmd
  155. for id, _ := range a.pages {
  156. a.pages[id], cmd = a.pages[id].Update(msg)
  157. cmds = append(cmds, cmd)
  158. }
  159. return a, tea.Batch(cmds...)
  160. }
  161. func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
  162. var cmds []tea.Cmd
  163. var cmd tea.Cmd
  164. switch msg := msg.(type) {
  165. case cursor.BlinkMsg:
  166. return a.updateAllPages(msg)
  167. case spinner.TickMsg:
  168. return a.updateAllPages(msg)
  169. case tea.WindowSizeMsg:
  170. msg.Height -= 2 // Make space for the status bar
  171. a.width, a.height = msg.Width, msg.Height
  172. s, _ := a.status.Update(msg)
  173. a.status = s.(core.StatusCmp)
  174. a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg)
  175. cmds = append(cmds, cmd)
  176. prm, permCmd := a.permissions.Update(msg)
  177. a.permissions = prm.(dialog.PermissionDialogCmp)
  178. cmds = append(cmds, permCmd)
  179. help, helpCmd := a.help.Update(msg)
  180. a.help = help.(dialog.HelpCmp)
  181. cmds = append(cmds, helpCmd)
  182. session, sessionCmd := a.sessionDialog.Update(msg)
  183. a.sessionDialog = session.(dialog.SessionDialog)
  184. cmds = append(cmds, sessionCmd)
  185. command, commandCmd := a.commandDialog.Update(msg)
  186. a.commandDialog = command.(dialog.CommandDialog)
  187. cmds = append(cmds, commandCmd)
  188. filepicker, filepickerCmd := a.filepicker.Update(msg)
  189. a.filepicker = filepicker.(dialog.FilepickerCmp)
  190. cmds = append(cmds, filepickerCmd)
  191. a.initDialog.SetSize(msg.Width, msg.Height)
  192. if a.showMultiArgumentsDialog {
  193. a.multiArgumentsDialog.SetSize(msg.Width, msg.Height)
  194. args, argsCmd := a.multiArgumentsDialog.Update(msg)
  195. a.multiArgumentsDialog = args.(dialog.MultiArgumentsDialogCmp)
  196. cmds = append(cmds, argsCmd, a.multiArgumentsDialog.Init())
  197. }
  198. return a, tea.Batch(cmds...)
  199. case pubsub.Event[permission.PermissionRequest]:
  200. a.showPermissions = true
  201. return a, a.permissions.SetPermissions(msg.Payload)
  202. case dialog.PermissionResponseMsg:
  203. var cmd tea.Cmd
  204. switch msg.Action {
  205. case dialog.PermissionAllow:
  206. a.app.Permissions.Grant(context.Background(), msg.Permission)
  207. case dialog.PermissionAllowForSession:
  208. a.app.Permissions.GrantPersistant(context.Background(), msg.Permission)
  209. case dialog.PermissionDeny:
  210. a.app.Permissions.Deny(context.Background(), msg.Permission)
  211. }
  212. a.showPermissions = false
  213. return a, cmd
  214. case page.PageChangeMsg:
  215. return a, a.moveToPage(msg.ID)
  216. case logs.LogsLoadedMsg:
  217. a.pages[page.LogsPage], cmd = a.pages[page.LogsPage].Update(msg)
  218. cmds = append(cmds, cmd)
  219. case state.SessionSelectedMsg:
  220. a.app.CurrentSession = msg
  221. return a.updateAllPages(msg)
  222. case pubsub.Event[session.Session]:
  223. if msg.Type == session.EventSessionUpdated {
  224. if a.app.CurrentSession.ID == msg.Payload.ID {
  225. a.app.CurrentSession = &msg.Payload
  226. }
  227. }
  228. case dialog.CloseQuitMsg:
  229. a.showQuit = false
  230. return a, nil
  231. case dialog.CloseSessionDialogMsg:
  232. a.showSessionDialog = false
  233. if msg.Session != nil {
  234. return a, util.CmdHandler(state.SessionSelectedMsg(msg.Session))
  235. }
  236. return a, nil
  237. case dialog.CloseCommandDialogMsg:
  238. a.showCommandDialog = false
  239. return a, nil
  240. case dialog.CloseThemeDialogMsg:
  241. a.showThemeDialog = false
  242. return a, nil
  243. case dialog.ThemeChangedMsg:
  244. a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg)
  245. a.showThemeDialog = false
  246. status.Info("Theme changed to: " + msg.ThemeName)
  247. return a, cmd
  248. case dialog.CloseModelDialogMsg:
  249. a.showModelDialog = false
  250. return a, nil
  251. case dialog.ModelSelectedMsg:
  252. a.showModelDialog = false
  253. model, err := a.app.PrimaryAgent.Update(config.AgentPrimary, msg.Model.ID)
  254. if err != nil {
  255. status.Error(err.Error())
  256. return a, nil
  257. }
  258. status.Info(fmt.Sprintf("Model changed to %s", model.Name))
  259. return a, nil
  260. case dialog.ShowInitDialogMsg:
  261. a.showInitDialog = msg.Show
  262. return a, nil
  263. case dialog.CloseInitDialogMsg:
  264. a.showInitDialog = false
  265. if msg.Initialize {
  266. // Run the initialization command
  267. for _, cmd := range a.commands {
  268. if cmd.ID == "init" {
  269. // Mark the project as initialized
  270. if err := config.MarkProjectInitialized(); err != nil {
  271. status.Error(err.Error())
  272. return a, nil
  273. }
  274. return a, cmd.Handler(cmd)
  275. }
  276. }
  277. } else {
  278. // Mark the project as initialized without running the command
  279. if err := config.MarkProjectInitialized(); err != nil {
  280. status.Error(err.Error())
  281. return a, nil
  282. }
  283. }
  284. return a, nil
  285. case dialog.CommandSelectedMsg:
  286. a.showCommandDialog = false
  287. // Execute the command handler if available
  288. if msg.Command.Handler != nil {
  289. return a, msg.Command.Handler(msg.Command)
  290. }
  291. status.Info("Command selected: " + msg.Command.Title)
  292. return a, nil
  293. case dialog.ShowMultiArgumentsDialogMsg:
  294. // Show multi-arguments dialog
  295. a.multiArgumentsDialog = dialog.NewMultiArgumentsDialogCmp(msg.CommandID, msg.Content, msg.ArgNames)
  296. a.showMultiArgumentsDialog = true
  297. return a, a.multiArgumentsDialog.Init()
  298. case dialog.CloseMultiArgumentsDialogMsg:
  299. // Close multi-arguments dialog
  300. a.showMultiArgumentsDialog = false
  301. // If submitted, replace all named arguments and run the command
  302. if msg.Submit {
  303. content := msg.Content
  304. // Replace each named argument with its value
  305. for name, value := range msg.Args {
  306. placeholder := "$" + name
  307. content = strings.ReplaceAll(content, placeholder, value)
  308. }
  309. // Execute the command with arguments
  310. return a, util.CmdHandler(dialog.CommandRunCustomMsg{
  311. Content: content,
  312. Args: msg.Args,
  313. })
  314. }
  315. return a, nil
  316. case tea.KeyMsg:
  317. // If multi-arguments dialog is open, let it handle the key press first
  318. if a.showMultiArgumentsDialog {
  319. args, cmd := a.multiArgumentsDialog.Update(msg)
  320. a.multiArgumentsDialog = args.(dialog.MultiArgumentsDialogCmp)
  321. return a, cmd
  322. }
  323. switch {
  324. case key.Matches(msg, keys.Quit):
  325. a.showQuit = !a.showQuit
  326. if a.showHelp {
  327. a.showHelp = false
  328. }
  329. if a.showSessionDialog {
  330. a.showSessionDialog = false
  331. }
  332. if a.showCommandDialog {
  333. a.showCommandDialog = false
  334. }
  335. if a.showFilepicker {
  336. a.showFilepicker = false
  337. a.filepicker.ToggleFilepicker(a.showFilepicker)
  338. }
  339. if a.showModelDialog {
  340. a.showModelDialog = false
  341. }
  342. if a.showMultiArgumentsDialog {
  343. a.showMultiArgumentsDialog = false
  344. }
  345. return a, nil
  346. case key.Matches(msg, keys.SwitchSession):
  347. if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showCommandDialog {
  348. // Load sessions and show the dialog
  349. sessions, err := a.app.Sessions.List(context.Background())
  350. if err != nil {
  351. status.Error(err.Error())
  352. return a, nil
  353. }
  354. if len(sessions) == 0 {
  355. status.Warn("No sessions available")
  356. return a, nil
  357. }
  358. a.sessionDialog.SetSessions(sessions)
  359. a.showSessionDialog = true
  360. return a, nil
  361. }
  362. return a, nil
  363. case key.Matches(msg, keys.Commands):
  364. if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showSessionDialog && !a.showThemeDialog && !a.showFilepicker {
  365. // Show commands dialog
  366. if len(a.commands) == 0 {
  367. status.Warn("No commands available")
  368. return a, nil
  369. }
  370. a.commandDialog.SetCommands(a.commands)
  371. a.showCommandDialog = true
  372. return a, nil
  373. }
  374. return a, nil
  375. case key.Matches(msg, keys.Models):
  376. if a.showModelDialog {
  377. a.showModelDialog = false
  378. return a, nil
  379. }
  380. if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showSessionDialog && !a.showCommandDialog {
  381. a.showModelDialog = true
  382. return a, nil
  383. }
  384. return a, nil
  385. case key.Matches(msg, keys.SwitchTheme):
  386. if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showSessionDialog && !a.showCommandDialog {
  387. a.showThemeDialog = true
  388. return a, a.themeDialog.Init()
  389. }
  390. return a, nil
  391. case key.Matches(msg, returnKey) || key.Matches(msg):
  392. if msg.String() == quitKey {
  393. if a.currentPage == page.LogsPage {
  394. return a, a.moveToPage(page.ChatPage)
  395. }
  396. } else if !a.filepicker.IsCWDFocused() {
  397. if a.showQuit {
  398. a.showQuit = !a.showQuit
  399. return a, nil
  400. }
  401. if a.showHelp {
  402. a.showHelp = !a.showHelp
  403. return a, nil
  404. }
  405. if a.showInitDialog {
  406. a.showInitDialog = false
  407. // Mark the project as initialized without running the command
  408. if err := config.MarkProjectInitialized(); err != nil {
  409. status.Error(err.Error())
  410. return a, nil
  411. }
  412. return a, nil
  413. }
  414. if a.showFilepicker {
  415. a.showFilepicker = false
  416. a.filepicker.ToggleFilepicker(a.showFilepicker)
  417. return a, nil
  418. }
  419. if a.currentPage == page.LogsPage {
  420. // Always allow returning from logs page, even when agent is busy
  421. return a, a.moveToPageUnconditional(page.ChatPage)
  422. }
  423. }
  424. case key.Matches(msg, keys.Logs):
  425. return a, a.moveToPage(page.LogsPage)
  426. case key.Matches(msg, keys.Help):
  427. if a.showQuit {
  428. return a, nil
  429. }
  430. a.showHelp = !a.showHelp
  431. return a, nil
  432. case key.Matches(msg, helpEsc):
  433. if a.app.PrimaryAgent.IsBusy() {
  434. if a.showQuit {
  435. return a, nil
  436. }
  437. a.showHelp = !a.showHelp
  438. return a, nil
  439. }
  440. case key.Matches(msg, keys.Filepicker):
  441. a.showFilepicker = !a.showFilepicker
  442. a.filepicker.ToggleFilepicker(a.showFilepicker)
  443. return a, nil
  444. }
  445. case pubsub.Event[logging.Log]:
  446. a.pages[page.LogsPage], cmd = a.pages[page.LogsPage].Update(msg)
  447. cmds = append(cmds, cmd)
  448. return a, tea.Batch(cmds...)
  449. case pubsub.Event[message.Message]:
  450. a.pages[page.ChatPage], cmd = a.pages[page.ChatPage].Update(msg)
  451. cmds = append(cmds, cmd)
  452. return a, tea.Batch(cmds...)
  453. default:
  454. f, filepickerCmd := a.filepicker.Update(msg)
  455. a.filepicker = f.(dialog.FilepickerCmp)
  456. cmds = append(cmds, filepickerCmd)
  457. }
  458. if a.showFilepicker {
  459. f, filepickerCmd := a.filepicker.Update(msg)
  460. a.filepicker = f.(dialog.FilepickerCmp)
  461. cmds = append(cmds, filepickerCmd)
  462. // Only block key messages send all other messages down
  463. if _, ok := msg.(tea.KeyMsg); ok {
  464. return a, tea.Batch(cmds...)
  465. }
  466. }
  467. if a.showQuit {
  468. q, quitCmd := a.quit.Update(msg)
  469. a.quit = q.(dialog.QuitDialog)
  470. cmds = append(cmds, quitCmd)
  471. // Only block key messages send all other messages down
  472. if _, ok := msg.(tea.KeyMsg); ok {
  473. return a, tea.Batch(cmds...)
  474. }
  475. }
  476. if a.showPermissions {
  477. d, permissionsCmd := a.permissions.Update(msg)
  478. a.permissions = d.(dialog.PermissionDialogCmp)
  479. cmds = append(cmds, permissionsCmd)
  480. // Only block key messages send all other messages down
  481. if _, ok := msg.(tea.KeyMsg); ok {
  482. return a, tea.Batch(cmds...)
  483. }
  484. }
  485. if a.showSessionDialog {
  486. d, sessionCmd := a.sessionDialog.Update(msg)
  487. a.sessionDialog = d.(dialog.SessionDialog)
  488. cmds = append(cmds, sessionCmd)
  489. // Only block key messages send all other messages down
  490. if _, ok := msg.(tea.KeyMsg); ok {
  491. return a, tea.Batch(cmds...)
  492. }
  493. }
  494. if a.showCommandDialog {
  495. d, commandCmd := a.commandDialog.Update(msg)
  496. a.commandDialog = d.(dialog.CommandDialog)
  497. cmds = append(cmds, commandCmd)
  498. // Only block key messages send all other messages down
  499. if _, ok := msg.(tea.KeyMsg); ok {
  500. return a, tea.Batch(cmds...)
  501. }
  502. }
  503. if a.showModelDialog {
  504. d, modelCmd := a.modelDialog.Update(msg)
  505. a.modelDialog = d.(dialog.ModelDialog)
  506. cmds = append(cmds, modelCmd)
  507. // Only block key messages send all other messages down
  508. if _, ok := msg.(tea.KeyMsg); ok {
  509. return a, tea.Batch(cmds...)
  510. }
  511. }
  512. if a.showInitDialog {
  513. d, initCmd := a.initDialog.Update(msg)
  514. a.initDialog = d.(dialog.InitDialogCmp)
  515. cmds = append(cmds, initCmd)
  516. // Only block key messages send all other messages down
  517. if _, ok := msg.(tea.KeyMsg); ok {
  518. return a, tea.Batch(cmds...)
  519. }
  520. }
  521. if a.showThemeDialog {
  522. d, themeCmd := a.themeDialog.Update(msg)
  523. a.themeDialog = d.(dialog.ThemeDialog)
  524. cmds = append(cmds, themeCmd)
  525. // Only block key messages send all other messages down
  526. if _, ok := msg.(tea.KeyMsg); ok {
  527. return a, tea.Batch(cmds...)
  528. }
  529. }
  530. s, cmd := a.status.Update(msg)
  531. cmds = append(cmds, cmd)
  532. a.status = s.(core.StatusCmp)
  533. a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg)
  534. cmds = append(cmds, cmd)
  535. return a, tea.Batch(cmds...)
  536. }
  537. // RegisterCommand adds a command to the command dialog
  538. func (a *appModel) RegisterCommand(cmd dialog.Command) {
  539. a.commands = append(a.commands, cmd)
  540. }
  541. func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
  542. // Allow navigating to logs page even when agent is busy
  543. if a.app.PrimaryAgent.IsBusy() && pageID != page.LogsPage {
  544. // Don't move to other pages if the agent is busy
  545. status.Warn("Agent is busy, please wait...")
  546. return nil
  547. }
  548. return a.moveToPageUnconditional(pageID)
  549. }
  550. // moveToPageUnconditional is like moveToPage but doesn't check if the agent is busy
  551. func (a *appModel) moveToPageUnconditional(pageID page.PageID) tea.Cmd {
  552. var cmds []tea.Cmd
  553. if _, ok := a.loadedPages[pageID]; !ok {
  554. cmd := a.pages[pageID].Init()
  555. cmds = append(cmds, cmd)
  556. a.loadedPages[pageID] = true
  557. }
  558. a.previousPage = a.currentPage
  559. a.currentPage = pageID
  560. if sizable, ok := a.pages[a.currentPage].(layout.Sizeable); ok {
  561. cmd := sizable.SetSize(a.width, a.height)
  562. cmds = append(cmds, cmd)
  563. }
  564. return tea.Batch(cmds...)
  565. }
  566. func (a appModel) View() string {
  567. components := []string{
  568. a.pages[a.currentPage].View(),
  569. }
  570. components = append(components, a.status.View())
  571. appView := lipgloss.JoinVertical(lipgloss.Top, components...)
  572. if a.showPermissions {
  573. overlay := a.permissions.View()
  574. row := lipgloss.Height(appView) / 2
  575. row -= lipgloss.Height(overlay) / 2
  576. col := lipgloss.Width(appView) / 2
  577. col -= lipgloss.Width(overlay) / 2
  578. appView = layout.PlaceOverlay(
  579. col,
  580. row,
  581. overlay,
  582. appView,
  583. true,
  584. )
  585. }
  586. if a.showFilepicker {
  587. overlay := a.filepicker.View()
  588. row := lipgloss.Height(appView) / 2
  589. row -= lipgloss.Height(overlay) / 2
  590. col := lipgloss.Width(appView) / 2
  591. col -= lipgloss.Width(overlay) / 2
  592. appView = layout.PlaceOverlay(
  593. col,
  594. row,
  595. overlay,
  596. appView,
  597. true,
  598. )
  599. }
  600. if !a.app.PrimaryAgent.IsBusy() {
  601. a.status.SetHelpWidgetMsg("ctrl+? help")
  602. } else {
  603. a.status.SetHelpWidgetMsg("? help")
  604. }
  605. if a.showHelp {
  606. bindings := layout.KeyMapToSlice(keys)
  607. if p, ok := a.pages[a.currentPage].(layout.Bindings); ok {
  608. bindings = append(bindings, p.BindingKeys()...)
  609. }
  610. if a.showPermissions {
  611. bindings = append(bindings, a.permissions.BindingKeys()...)
  612. }
  613. if a.currentPage == page.LogsPage {
  614. bindings = append(bindings, logsKeyReturnKey)
  615. }
  616. if !a.app.PrimaryAgent.IsBusy() {
  617. bindings = append(bindings, helpEsc)
  618. }
  619. a.help.SetBindings(bindings)
  620. overlay := a.help.View()
  621. row := lipgloss.Height(appView) / 2
  622. row -= lipgloss.Height(overlay) / 2
  623. col := lipgloss.Width(appView) / 2
  624. col -= lipgloss.Width(overlay) / 2
  625. appView = layout.PlaceOverlay(
  626. col,
  627. row,
  628. overlay,
  629. appView,
  630. true,
  631. )
  632. }
  633. if a.showQuit {
  634. overlay := a.quit.View()
  635. row := lipgloss.Height(appView) / 2
  636. row -= lipgloss.Height(overlay) / 2
  637. col := lipgloss.Width(appView) / 2
  638. col -= lipgloss.Width(overlay) / 2
  639. appView = layout.PlaceOverlay(
  640. col,
  641. row,
  642. overlay,
  643. appView,
  644. true,
  645. )
  646. }
  647. if a.showSessionDialog {
  648. overlay := a.sessionDialog.View()
  649. row := lipgloss.Height(appView) / 2
  650. row -= lipgloss.Height(overlay) / 2
  651. col := lipgloss.Width(appView) / 2
  652. col -= lipgloss.Width(overlay) / 2
  653. appView = layout.PlaceOverlay(
  654. col,
  655. row,
  656. overlay,
  657. appView,
  658. true,
  659. )
  660. }
  661. if a.showModelDialog {
  662. overlay := a.modelDialog.View()
  663. row := lipgloss.Height(appView) / 2
  664. row -= lipgloss.Height(overlay) / 2
  665. col := lipgloss.Width(appView) / 2
  666. col -= lipgloss.Width(overlay) / 2
  667. appView = layout.PlaceOverlay(
  668. col,
  669. row,
  670. overlay,
  671. appView,
  672. true,
  673. )
  674. }
  675. if a.showCommandDialog {
  676. overlay := a.commandDialog.View()
  677. row := lipgloss.Height(appView) / 2
  678. row -= lipgloss.Height(overlay) / 2
  679. col := lipgloss.Width(appView) / 2
  680. col -= lipgloss.Width(overlay) / 2
  681. appView = layout.PlaceOverlay(
  682. col,
  683. row,
  684. overlay,
  685. appView,
  686. true,
  687. )
  688. }
  689. if a.showInitDialog {
  690. overlay := a.initDialog.View()
  691. appView = layout.PlaceOverlay(
  692. a.width/2-lipgloss.Width(overlay)/2,
  693. a.height/2-lipgloss.Height(overlay)/2,
  694. overlay,
  695. appView,
  696. true,
  697. )
  698. }
  699. if a.showThemeDialog {
  700. overlay := a.themeDialog.View()
  701. row := lipgloss.Height(appView) / 2
  702. row -= lipgloss.Height(overlay) / 2
  703. col := lipgloss.Width(appView) / 2
  704. col -= lipgloss.Width(overlay) / 2
  705. appView = layout.PlaceOverlay(
  706. col,
  707. row,
  708. overlay,
  709. appView,
  710. true,
  711. )
  712. }
  713. if a.showMultiArgumentsDialog {
  714. overlay := a.multiArgumentsDialog.View()
  715. row := lipgloss.Height(appView) / 2
  716. row -= lipgloss.Height(overlay) / 2
  717. col := lipgloss.Width(appView) / 2
  718. col -= lipgloss.Width(overlay) / 2
  719. appView = layout.PlaceOverlay(
  720. col,
  721. row,
  722. overlay,
  723. appView,
  724. true,
  725. )
  726. }
  727. return appView
  728. }
  729. func New(app *app.App) tea.Model {
  730. startPage := page.ChatPage
  731. model := &appModel{
  732. currentPage: startPage,
  733. loadedPages: make(map[page.PageID]bool),
  734. status: core.NewStatusCmp(app),
  735. help: dialog.NewHelpCmp(),
  736. quit: dialog.NewQuitCmp(),
  737. sessionDialog: dialog.NewSessionDialogCmp(),
  738. commandDialog: dialog.NewCommandDialogCmp(),
  739. modelDialog: dialog.NewModelDialogCmp(),
  740. permissions: dialog.NewPermissionDialogCmp(),
  741. initDialog: dialog.NewInitDialogCmp(),
  742. themeDialog: dialog.NewThemeDialogCmp(),
  743. app: app,
  744. commands: []dialog.Command{},
  745. pages: map[page.PageID]tea.Model{
  746. page.ChatPage: page.NewChatPage(app),
  747. page.LogsPage: page.NewLogsPage(app),
  748. },
  749. filepicker: dialog.NewFilepickerCmp(app),
  750. }
  751. model.RegisterCommand(dialog.Command{
  752. ID: "init",
  753. Title: "Initialize Project",
  754. Description: "Create/Update the CONTEXT.md memory file",
  755. Handler: func(cmd dialog.Command) tea.Cmd {
  756. prompt := `Please analyze this codebase and create a CONTEXT.md file containing:
  757. 1. Build/lint/test commands - especially for running a single test
  758. 2. Code style guidelines including imports, formatting, types, naming conventions, error handling, etc.
  759. 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.
  760. If there's already a CONTEXT.md, improve it.
  761. If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md), make sure to include them.`
  762. return tea.Batch(
  763. util.CmdHandler(chat.SendMsg{
  764. Text: prompt,
  765. }),
  766. )
  767. },
  768. })
  769. model.RegisterCommand(dialog.Command{
  770. ID: "compact_conversation",
  771. Title: "Compact Conversation",
  772. Description: "Summarize the current session to save tokens",
  773. Handler: func(cmd dialog.Command) tea.Cmd {
  774. // Get the current session from the appModel
  775. if model.currentPage != page.ChatPage {
  776. status.Warn("Please navigate to a chat session first.")
  777. return nil
  778. }
  779. // Return a message that will be handled by the chat page
  780. status.Info("Compacting conversation...")
  781. return util.CmdHandler(state.CompactSessionMsg{})
  782. },
  783. })
  784. // Load custom commands
  785. customCommands, err := dialog.LoadCustomCommands()
  786. if err != nil {
  787. slog.Warn("Failed to load custom commands", "error", err)
  788. } else {
  789. for _, cmd := range customCommands {
  790. model.RegisterCommand(cmd)
  791. }
  792. }
  793. return model
  794. }