tui.go 25 KB

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