tui.go 26 KB

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