tui.go 26 KB

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