tui.go 22 KB

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