tui.go 21 KB

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