tui.go 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675
  1. package tui
  2. import (
  3. "context"
  4. "fmt"
  5. "math/rand"
  6. "slices"
  7. "strings"
  8. "time"
  9. "github.com/charmbracelet/bubbles/v2/key"
  10. tea "github.com/charmbracelet/bubbletea/v2"
  11. "github.com/charmbracelet/crush/internal/agent/tools/mcp"
  12. "github.com/charmbracelet/crush/internal/app"
  13. "github.com/charmbracelet/crush/internal/config"
  14. "github.com/charmbracelet/crush/internal/event"
  15. "github.com/charmbracelet/crush/internal/permission"
  16. "github.com/charmbracelet/crush/internal/pubsub"
  17. cmpChat "github.com/charmbracelet/crush/internal/tui/components/chat"
  18. "github.com/charmbracelet/crush/internal/tui/components/chat/splash"
  19. "github.com/charmbracelet/crush/internal/tui/components/completions"
  20. "github.com/charmbracelet/crush/internal/tui/components/core"
  21. "github.com/charmbracelet/crush/internal/tui/components/core/layout"
  22. "github.com/charmbracelet/crush/internal/tui/components/core/status"
  23. "github.com/charmbracelet/crush/internal/tui/components/dialogs"
  24. "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
  25. "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
  26. "github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
  27. "github.com/charmbracelet/crush/internal/tui/components/dialogs/permissions"
  28. "github.com/charmbracelet/crush/internal/tui/components/dialogs/quit"
  29. "github.com/charmbracelet/crush/internal/tui/components/dialogs/sessions"
  30. "github.com/charmbracelet/crush/internal/tui/page"
  31. "github.com/charmbracelet/crush/internal/tui/page/chat"
  32. "github.com/charmbracelet/crush/internal/tui/styles"
  33. "github.com/charmbracelet/crush/internal/tui/util"
  34. "github.com/charmbracelet/lipgloss/v2"
  35. "golang.org/x/text/cases"
  36. "golang.org/x/text/language"
  37. )
  38. var lastMouseEvent time.Time
  39. func MouseEventFilter(m tea.Model, msg tea.Msg) tea.Msg {
  40. switch msg.(type) {
  41. case tea.MouseWheelMsg, tea.MouseMotionMsg:
  42. now := time.Now()
  43. // trackpad is sending too many requests
  44. if now.Sub(lastMouseEvent) < 15*time.Millisecond {
  45. return nil
  46. }
  47. lastMouseEvent = now
  48. }
  49. return msg
  50. }
  51. // appModel represents the main application model that manages pages, dialogs, and UI state.
  52. type appModel struct {
  53. wWidth, wHeight int // Window dimensions
  54. width, height int
  55. keyMap KeyMap
  56. currentPage page.PageID
  57. previousPage page.PageID
  58. pages map[page.PageID]util.Model
  59. loadedPages map[page.PageID]bool
  60. // Status
  61. status status.StatusCmp
  62. showingFullHelp bool
  63. app *app.App
  64. dialog dialogs.DialogCmp
  65. completions completions.Completions
  66. isConfigured bool
  67. // Chat Page Specific
  68. selectedSessionID string // The ID of the currently selected session
  69. // sendProgressBar instructs the TUI to send progress bar updates to the
  70. // terminal.
  71. sendProgressBar bool
  72. // QueryVersion instructs the TUI to query for the terminal version when it
  73. // starts.
  74. QueryVersion bool
  75. }
  76. // Init initializes the application model and returns initial commands.
  77. func (a appModel) Init() tea.Cmd {
  78. item, ok := a.pages[a.currentPage]
  79. if !ok {
  80. return nil
  81. }
  82. var cmds []tea.Cmd
  83. cmd := item.Init()
  84. cmds = append(cmds, cmd)
  85. a.loadedPages[a.currentPage] = true
  86. cmd = a.status.Init()
  87. cmds = append(cmds, cmd)
  88. if a.QueryVersion {
  89. cmds = append(cmds, tea.RequestTerminalVersion)
  90. }
  91. return tea.Batch(cmds...)
  92. }
  93. // Update handles incoming messages and updates the application state.
  94. func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
  95. var cmds []tea.Cmd
  96. var cmd tea.Cmd
  97. a.isConfigured = config.HasInitialDataConfig()
  98. switch msg := msg.(type) {
  99. case tea.EnvMsg:
  100. // Is this Windows Terminal?
  101. if !a.sendProgressBar {
  102. a.sendProgressBar = slices.Contains(msg, "WT_SESSION")
  103. }
  104. case tea.TerminalVersionMsg:
  105. termVersion := strings.ToLower(string(msg))
  106. // Only enable progress bar for the following terminals.
  107. if !a.sendProgressBar {
  108. a.sendProgressBar = strings.Contains(termVersion, "ghostty")
  109. }
  110. return a, nil
  111. case tea.KeyboardEnhancementsMsg:
  112. for id, page := range a.pages {
  113. m, pageCmd := page.Update(msg)
  114. a.pages[id] = m
  115. if pageCmd != nil {
  116. cmds = append(cmds, pageCmd)
  117. }
  118. }
  119. return a, tea.Batch(cmds...)
  120. case tea.WindowSizeMsg:
  121. a.wWidth, a.wHeight = msg.Width, msg.Height
  122. a.completions.Update(msg)
  123. return a, a.handleWindowResize(msg.Width, msg.Height)
  124. case pubsub.Event[mcp.Event]:
  125. switch msg.Payload.Type {
  126. case mcp.EventStateChanged:
  127. return a, a.handleStateChanged(context.Background())
  128. case mcp.EventPromptsListChanged:
  129. return a, handleMCPPromptsEvent(context.Background(), msg.Payload.Name)
  130. case mcp.EventToolsListChanged:
  131. return a, handleMCPToolsEvent(context.Background(), msg.Payload.Name)
  132. }
  133. // Completions messages
  134. case completions.OpenCompletionsMsg, completions.FilterCompletionsMsg,
  135. completions.CloseCompletionsMsg, completions.RepositionCompletionsMsg:
  136. u, completionCmd := a.completions.Update(msg)
  137. if model, ok := u.(completions.Completions); ok {
  138. a.completions = model
  139. }
  140. return a, completionCmd
  141. // Dialog messages
  142. case dialogs.OpenDialogMsg, dialogs.CloseDialogMsg:
  143. u, completionCmd := a.completions.Update(completions.CloseCompletionsMsg{})
  144. a.completions = u.(completions.Completions)
  145. u, dialogCmd := a.dialog.Update(msg)
  146. a.dialog = u.(dialogs.DialogCmp)
  147. return a, tea.Batch(completionCmd, dialogCmd)
  148. case commands.ShowArgumentsDialogMsg:
  149. var args []commands.Argument
  150. for _, arg := range msg.ArgNames {
  151. args = append(args, commands.Argument{
  152. Name: arg,
  153. Title: cases.Title(language.English).String(arg),
  154. Required: true,
  155. })
  156. }
  157. return a, util.CmdHandler(
  158. dialogs.OpenDialogMsg{
  159. Model: commands.NewCommandArgumentsDialog(
  160. msg.CommandID,
  161. msg.CommandID,
  162. msg.CommandID,
  163. msg.Description,
  164. args,
  165. msg.OnSubmit,
  166. ),
  167. },
  168. )
  169. case commands.ShowMCPPromptArgumentsDialogMsg:
  170. args := make([]commands.Argument, 0, len(msg.Prompt.Arguments))
  171. for _, arg := range msg.Prompt.Arguments {
  172. args = append(args, commands.Argument(*arg))
  173. }
  174. dialog := commands.NewCommandArgumentsDialog(
  175. msg.Prompt.Name,
  176. msg.Prompt.Title,
  177. msg.Prompt.Name,
  178. msg.Prompt.Description,
  179. args,
  180. msg.OnSubmit,
  181. )
  182. return a, util.CmdHandler(
  183. dialogs.OpenDialogMsg{
  184. Model: dialog,
  185. },
  186. )
  187. // Page change messages
  188. case page.PageChangeMsg:
  189. return a, a.moveToPage(msg.ID)
  190. // Status Messages
  191. case util.InfoMsg, util.ClearStatusMsg:
  192. s, statusCmd := a.status.Update(msg)
  193. a.status = s.(status.StatusCmp)
  194. cmds = append(cmds, statusCmd)
  195. return a, tea.Batch(cmds...)
  196. // Session
  197. case cmpChat.SessionSelectedMsg:
  198. a.selectedSessionID = msg.ID
  199. case cmpChat.SessionClearedMsg:
  200. a.selectedSessionID = ""
  201. // Commands
  202. case commands.SwitchSessionsMsg:
  203. return a, func() tea.Msg {
  204. allSessions, _ := a.app.Sessions.List(context.Background())
  205. return dialogs.OpenDialogMsg{
  206. Model: sessions.NewSessionDialogCmp(allSessions, a.selectedSessionID),
  207. }
  208. }
  209. case commands.SwitchModelMsg:
  210. return a, util.CmdHandler(
  211. dialogs.OpenDialogMsg{
  212. Model: models.NewModelDialogCmp(),
  213. },
  214. )
  215. // Compact
  216. case commands.CompactMsg:
  217. return a, func() tea.Msg {
  218. err := a.app.AgentCoordinator.Summarize(context.Background(), msg.SessionID)
  219. if err != nil {
  220. return util.ReportError(err)()
  221. }
  222. return nil
  223. }
  224. case commands.QuitMsg:
  225. return a, util.CmdHandler(dialogs.OpenDialogMsg{
  226. Model: quit.NewQuitDialog(),
  227. })
  228. case commands.ToggleYoloModeMsg:
  229. a.app.Permissions.SetSkipRequests(!a.app.Permissions.SkipRequests())
  230. case commands.ToggleHelpMsg:
  231. a.status.ToggleFullHelp()
  232. a.showingFullHelp = !a.showingFullHelp
  233. return a, a.handleWindowResize(a.wWidth, a.wHeight)
  234. // Model Switch
  235. case models.ModelSelectedMsg:
  236. if a.app.AgentCoordinator.IsBusy() {
  237. return a, util.ReportWarn("Agent is busy, please wait...")
  238. }
  239. config.Get().UpdatePreferredModel(msg.ModelType, msg.Model)
  240. go a.app.UpdateAgentModel(context.TODO())
  241. modelTypeName := "large"
  242. if msg.ModelType == config.SelectedModelTypeSmall {
  243. modelTypeName = "small"
  244. }
  245. return a, util.ReportInfo(fmt.Sprintf("%s model changed to %s", modelTypeName, msg.Model.Model))
  246. // File Picker
  247. case commands.OpenFilePickerMsg:
  248. event.FilePickerOpened()
  249. if a.dialog.ActiveDialogID() == filepicker.FilePickerID {
  250. // If the commands dialog is already open, close it
  251. return a, util.CmdHandler(dialogs.CloseDialogMsg{})
  252. }
  253. return a, util.CmdHandler(dialogs.OpenDialogMsg{
  254. Model: filepicker.NewFilePickerCmp(a.app.Config().WorkingDir()),
  255. })
  256. // Permissions
  257. case pubsub.Event[permission.PermissionNotification]:
  258. item, ok := a.pages[a.currentPage]
  259. if !ok {
  260. return a, nil
  261. }
  262. // Forward to view.
  263. updated, itemCmd := item.Update(msg)
  264. a.pages[a.currentPage] = updated
  265. return a, itemCmd
  266. case pubsub.Event[permission.PermissionRequest]:
  267. return a, util.CmdHandler(dialogs.OpenDialogMsg{
  268. Model: permissions.NewPermissionDialogCmp(msg.Payload, &permissions.Options{
  269. DiffMode: config.Get().Options.TUI.DiffMode,
  270. }),
  271. })
  272. case permissions.PermissionResponseMsg:
  273. switch msg.Action {
  274. case permissions.PermissionAllow:
  275. a.app.Permissions.Grant(msg.Permission)
  276. case permissions.PermissionAllowForSession:
  277. a.app.Permissions.GrantPersistent(msg.Permission)
  278. case permissions.PermissionDeny:
  279. a.app.Permissions.Deny(msg.Permission)
  280. }
  281. return a, nil
  282. case splash.OnboardingCompleteMsg:
  283. item, ok := a.pages[a.currentPage]
  284. if !ok {
  285. return a, nil
  286. }
  287. a.isConfigured = config.HasInitialDataConfig()
  288. updated, pageCmd := item.Update(msg)
  289. a.pages[a.currentPage] = updated
  290. cmds = append(cmds, pageCmd)
  291. return a, tea.Batch(cmds...)
  292. case tea.KeyPressMsg:
  293. return a, a.handleKeyPressMsg(msg)
  294. case tea.MouseWheelMsg:
  295. if a.dialog.HasDialogs() {
  296. u, dialogCmd := a.dialog.Update(msg)
  297. a.dialog = u.(dialogs.DialogCmp)
  298. cmds = append(cmds, dialogCmd)
  299. } else {
  300. item, ok := a.pages[a.currentPage]
  301. if !ok {
  302. return a, nil
  303. }
  304. updated, pageCmd := item.Update(msg)
  305. a.pages[a.currentPage] = updated
  306. cmds = append(cmds, pageCmd)
  307. }
  308. return a, tea.Batch(cmds...)
  309. case tea.PasteMsg:
  310. if a.dialog.HasDialogs() {
  311. u, dialogCmd := a.dialog.Update(msg)
  312. if model, ok := u.(dialogs.DialogCmp); ok {
  313. a.dialog = model
  314. }
  315. cmds = append(cmds, dialogCmd)
  316. } else {
  317. item, ok := a.pages[a.currentPage]
  318. if !ok {
  319. return a, nil
  320. }
  321. updated, pageCmd := item.Update(msg)
  322. a.pages[a.currentPage] = updated
  323. cmds = append(cmds, pageCmd)
  324. }
  325. return a, tea.Batch(cmds...)
  326. }
  327. s, _ := a.status.Update(msg)
  328. a.status = s.(status.StatusCmp)
  329. item, ok := a.pages[a.currentPage]
  330. if !ok {
  331. return a, nil
  332. }
  333. updated, cmd := item.Update(msg)
  334. a.pages[a.currentPage] = updated
  335. if a.dialog.HasDialogs() {
  336. u, dialogCmd := a.dialog.Update(msg)
  337. if model, ok := u.(dialogs.DialogCmp); ok {
  338. a.dialog = model
  339. }
  340. cmds = append(cmds, dialogCmd)
  341. }
  342. cmds = append(cmds, cmd)
  343. return a, tea.Batch(cmds...)
  344. }
  345. // handleWindowResize processes window resize events and updates all components.
  346. func (a *appModel) handleWindowResize(width, height int) tea.Cmd {
  347. var cmds []tea.Cmd
  348. // TODO: clean up these magic numbers.
  349. if a.showingFullHelp {
  350. height -= 5
  351. } else {
  352. height -= 2
  353. }
  354. a.width, a.height = width, height
  355. // Update status bar
  356. s, cmd := a.status.Update(tea.WindowSizeMsg{Width: width, Height: height})
  357. if model, ok := s.(status.StatusCmp); ok {
  358. a.status = model
  359. }
  360. cmds = append(cmds, cmd)
  361. // Update the current view.
  362. for p, page := range a.pages {
  363. updated, pageCmd := page.Update(tea.WindowSizeMsg{Width: width, Height: height})
  364. a.pages[p] = updated
  365. cmds = append(cmds, pageCmd)
  366. }
  367. // Update the dialogs
  368. dialog, cmd := a.dialog.Update(tea.WindowSizeMsg{Width: width, Height: height})
  369. if model, ok := dialog.(dialogs.DialogCmp); ok {
  370. a.dialog = model
  371. }
  372. cmds = append(cmds, cmd)
  373. return tea.Batch(cmds...)
  374. }
  375. // handleKeyPressMsg processes keyboard input and routes to appropriate handlers.
  376. func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
  377. // Check this first as the user should be able to quit no matter what.
  378. if key.Matches(msg, a.keyMap.Quit) {
  379. if a.dialog.ActiveDialogID() == quit.QuitDialogID {
  380. return tea.Quit
  381. }
  382. return util.CmdHandler(dialogs.OpenDialogMsg{
  383. Model: quit.NewQuitDialog(),
  384. })
  385. }
  386. if a.completions.Open() {
  387. // completions
  388. keyMap := a.completions.KeyMap()
  389. switch {
  390. case key.Matches(msg, keyMap.Up), key.Matches(msg, keyMap.Down),
  391. key.Matches(msg, keyMap.Select), key.Matches(msg, keyMap.Cancel),
  392. key.Matches(msg, keyMap.UpInsert), key.Matches(msg, keyMap.DownInsert):
  393. u, cmd := a.completions.Update(msg)
  394. a.completions = u.(completions.Completions)
  395. return cmd
  396. }
  397. }
  398. if a.dialog.HasDialogs() {
  399. u, dialogCmd := a.dialog.Update(msg)
  400. a.dialog = u.(dialogs.DialogCmp)
  401. return dialogCmd
  402. }
  403. switch {
  404. // help
  405. case key.Matches(msg, a.keyMap.Help):
  406. a.status.ToggleFullHelp()
  407. a.showingFullHelp = !a.showingFullHelp
  408. return a.handleWindowResize(a.wWidth, a.wHeight)
  409. // dialogs
  410. case key.Matches(msg, a.keyMap.Commands):
  411. // if the app is not configured show no commands
  412. if !a.isConfigured {
  413. return nil
  414. }
  415. if a.dialog.ActiveDialogID() == commands.CommandsDialogID {
  416. return util.CmdHandler(dialogs.CloseDialogMsg{})
  417. }
  418. if a.dialog.HasDialogs() {
  419. return nil
  420. }
  421. return util.CmdHandler(dialogs.OpenDialogMsg{
  422. Model: commands.NewCommandDialog(a.selectedSessionID),
  423. })
  424. case key.Matches(msg, a.keyMap.Sessions):
  425. // if the app is not configured show no sessions
  426. if !a.isConfigured {
  427. return nil
  428. }
  429. if a.dialog.ActiveDialogID() == sessions.SessionsDialogID {
  430. return util.CmdHandler(dialogs.CloseDialogMsg{})
  431. }
  432. if a.dialog.HasDialogs() && a.dialog.ActiveDialogID() != commands.CommandsDialogID {
  433. return nil
  434. }
  435. var cmds []tea.Cmd
  436. if a.dialog.ActiveDialogID() == commands.CommandsDialogID {
  437. // If the commands dialog is open, close it first
  438. cmds = append(cmds, util.CmdHandler(dialogs.CloseDialogMsg{}))
  439. }
  440. cmds = append(cmds,
  441. func() tea.Msg {
  442. allSessions, _ := a.app.Sessions.List(context.Background())
  443. return dialogs.OpenDialogMsg{
  444. Model: sessions.NewSessionDialogCmp(allSessions, a.selectedSessionID),
  445. }
  446. },
  447. )
  448. return tea.Sequence(cmds...)
  449. case key.Matches(msg, a.keyMap.Suspend):
  450. if a.app.AgentCoordinator != nil && a.app.AgentCoordinator.IsBusy() {
  451. return util.ReportWarn("Agent is busy, please wait...")
  452. }
  453. return tea.Suspend
  454. default:
  455. item, ok := a.pages[a.currentPage]
  456. if !ok {
  457. return nil
  458. }
  459. updated, cmd := item.Update(msg)
  460. a.pages[a.currentPage] = updated
  461. return cmd
  462. }
  463. }
  464. // moveToPage handles navigation between different pages in the application.
  465. func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
  466. if a.app.AgentCoordinator.IsBusy() {
  467. // TODO: maybe remove this : For now we don't move to any page if the agent is busy
  468. return util.ReportWarn("Agent is busy, please wait...")
  469. }
  470. var cmds []tea.Cmd
  471. if _, ok := a.loadedPages[pageID]; !ok {
  472. cmd := a.pages[pageID].Init()
  473. cmds = append(cmds, cmd)
  474. a.loadedPages[pageID] = true
  475. }
  476. a.previousPage = a.currentPage
  477. a.currentPage = pageID
  478. if sizable, ok := a.pages[a.currentPage].(layout.Sizeable); ok {
  479. cmd := sizable.SetSize(a.width, a.height)
  480. cmds = append(cmds, cmd)
  481. }
  482. return tea.Batch(cmds...)
  483. }
  484. // View renders the complete application interface including pages, dialogs, and overlays.
  485. func (a *appModel) View() tea.View {
  486. var view tea.View
  487. view.AltScreen = true
  488. t := styles.CurrentTheme()
  489. view.BackgroundColor = t.BgBase
  490. if a.wWidth < 25 || a.wHeight < 15 {
  491. view.Layer = lipgloss.NewCanvas(
  492. lipgloss.NewLayer(
  493. t.S().Base.Width(a.wWidth).Height(a.wHeight).
  494. Align(lipgloss.Center, lipgloss.Center).
  495. Render(
  496. t.S().Base.
  497. Padding(1, 4).
  498. Foreground(t.White).
  499. BorderStyle(lipgloss.RoundedBorder()).
  500. BorderForeground(t.Primary).
  501. Render("Window too small!"),
  502. ),
  503. ),
  504. )
  505. return view
  506. }
  507. page := a.pages[a.currentPage]
  508. if withHelp, ok := page.(core.KeyMapHelp); ok {
  509. a.status.SetKeyMap(withHelp.Help())
  510. }
  511. pageView := page.View()
  512. components := []string{
  513. pageView,
  514. }
  515. components = append(components, a.status.View())
  516. appView := lipgloss.JoinVertical(lipgloss.Top, components...)
  517. layers := []*lipgloss.Layer{
  518. lipgloss.NewLayer(appView),
  519. }
  520. if a.dialog.HasDialogs() {
  521. layers = append(
  522. layers,
  523. a.dialog.GetLayers()...,
  524. )
  525. }
  526. var cursor *tea.Cursor
  527. if v, ok := page.(util.Cursor); ok {
  528. cursor = v.Cursor()
  529. // Hide the cursor if it's positioned outside the textarea
  530. statusHeight := a.height - strings.Count(pageView, "\n") + 1
  531. if cursor != nil && cursor.Y+statusHeight+chat.EditorHeight-2 <= a.height { // 2 for the top and bottom app padding
  532. cursor = nil
  533. }
  534. }
  535. activeView := a.dialog.ActiveModel()
  536. if activeView != nil {
  537. cursor = nil // Reset cursor if a dialog is active unless it implements util.Cursor
  538. if v, ok := activeView.(util.Cursor); ok {
  539. cursor = v.Cursor()
  540. }
  541. }
  542. if a.completions.Open() && cursor != nil {
  543. cmp := a.completions.View()
  544. x, y := a.completions.Position()
  545. layers = append(
  546. layers,
  547. lipgloss.NewLayer(cmp).X(x).Y(y),
  548. )
  549. }
  550. canvas := lipgloss.NewCanvas(
  551. layers...,
  552. )
  553. view.Layer = canvas
  554. view.Cursor = cursor
  555. view.MouseMode = tea.MouseModeCellMotion
  556. if a.sendProgressBar && a.app != nil && a.app.AgentCoordinator != nil && a.app.AgentCoordinator.IsBusy() {
  557. // HACK: use a random percentage to prevent ghostty from hiding it
  558. // after a timeout.
  559. view.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
  560. }
  561. return view
  562. }
  563. func (a *appModel) handleStateChanged(ctx context.Context) tea.Cmd {
  564. return func() tea.Msg {
  565. a.app.UpdateAgentModel(ctx)
  566. return nil
  567. }
  568. }
  569. func handleMCPPromptsEvent(ctx context.Context, name string) tea.Cmd {
  570. return func() tea.Msg {
  571. mcp.RefreshPrompts(ctx, name)
  572. return nil
  573. }
  574. }
  575. func handleMCPToolsEvent(ctx context.Context, name string) tea.Cmd {
  576. return func() tea.Msg {
  577. mcp.RefreshTools(ctx, name)
  578. return nil
  579. }
  580. }
  581. // New creates and initializes a new TUI application model.
  582. func New(app *app.App) *appModel {
  583. chatPage := chat.New(app)
  584. keyMap := DefaultKeyMap()
  585. keyMap.pageBindings = chatPage.Bindings()
  586. model := &appModel{
  587. currentPage: chat.ChatPageID,
  588. app: app,
  589. status: status.NewStatusCmp(),
  590. loadedPages: make(map[page.PageID]bool),
  591. keyMap: keyMap,
  592. pages: map[page.PageID]util.Model{
  593. chat.ChatPageID: chatPage,
  594. },
  595. dialog: dialogs.NewDialogCmp(),
  596. completions: completions.New(),
  597. }
  598. return model
  599. }