tui.go 25 KB

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