tui.go 22 KB

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