tui.go 26 KB

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