tui.go 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534
  1. package tui
  2. import (
  3. "context"
  4. "fmt"
  5. "strings"
  6. "time"
  7. "github.com/charmbracelet/bubbles/v2/key"
  8. tea "github.com/charmbracelet/bubbletea/v2"
  9. "github.com/charmbracelet/crush/internal/app"
  10. "github.com/charmbracelet/crush/internal/config"
  11. "github.com/charmbracelet/crush/internal/llm/agent"
  12. "github.com/charmbracelet/crush/internal/permission"
  13. "github.com/charmbracelet/crush/internal/pubsub"
  14. cmpChat "github.com/charmbracelet/crush/internal/tui/components/chat"
  15. "github.com/charmbracelet/crush/internal/tui/components/chat/splash"
  16. "github.com/charmbracelet/crush/internal/tui/components/completions"
  17. "github.com/charmbracelet/crush/internal/tui/components/core"
  18. "github.com/charmbracelet/crush/internal/tui/components/core/layout"
  19. "github.com/charmbracelet/crush/internal/tui/components/core/status"
  20. "github.com/charmbracelet/crush/internal/tui/components/dialogs"
  21. "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
  22. "github.com/charmbracelet/crush/internal/tui/components/dialogs/compact"
  23. "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
  24. "github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
  25. "github.com/charmbracelet/crush/internal/tui/components/dialogs/permissions"
  26. "github.com/charmbracelet/crush/internal/tui/components/dialogs/quit"
  27. "github.com/charmbracelet/crush/internal/tui/components/dialogs/sessions"
  28. "github.com/charmbracelet/crush/internal/tui/page"
  29. "github.com/charmbracelet/crush/internal/tui/page/chat"
  30. "github.com/charmbracelet/crush/internal/tui/styles"
  31. "github.com/charmbracelet/crush/internal/tui/util"
  32. "github.com/charmbracelet/lipgloss/v2"
  33. )
  34. var lastMouseEvent time.Time
  35. func MouseEventFilter(m tea.Model, msg tea.Msg) tea.Msg {
  36. switch msg.(type) {
  37. case tea.MouseWheelMsg, tea.MouseMotionMsg:
  38. now := time.Now()
  39. // trackpad is sending too many requests
  40. if now.Sub(lastMouseEvent) < 5*time.Millisecond {
  41. return nil
  42. }
  43. lastMouseEvent = now
  44. }
  45. return msg
  46. }
  47. // appModel represents the main application model that manages pages, dialogs, and UI state.
  48. type appModel struct {
  49. wWidth, wHeight int // Window dimensions
  50. width, height int
  51. keyMap KeyMap
  52. currentPage page.PageID
  53. previousPage page.PageID
  54. pages map[page.PageID]util.Model
  55. loadedPages map[page.PageID]bool
  56. // Status
  57. status status.StatusCmp
  58. showingFullHelp bool
  59. app *app.App
  60. dialog dialogs.DialogCmp
  61. completions completions.Completions
  62. isConfigured bool
  63. // Chat Page Specific
  64. selectedSessionID string // The ID of the currently selected session
  65. }
  66. // Init initializes the application model and returns initial commands.
  67. func (a appModel) Init() tea.Cmd {
  68. var cmds []tea.Cmd
  69. cmd := a.pages[a.currentPage].Init()
  70. cmds = append(cmds, cmd)
  71. a.loadedPages[a.currentPage] = true
  72. cmd = a.status.Init()
  73. cmds = append(cmds, cmd)
  74. cmds = append(cmds, tea.EnableMouseAllMotion)
  75. return tea.Batch(cmds...)
  76. }
  77. // Update handles incoming messages and updates the application state.
  78. func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
  79. var cmds []tea.Cmd
  80. var cmd tea.Cmd
  81. a.isConfigured = config.HasInitialDataConfig()
  82. switch msg := msg.(type) {
  83. case tea.KeyboardEnhancementsMsg:
  84. for id, page := range a.pages {
  85. m, pageCmd := page.Update(msg)
  86. a.pages[id] = m.(util.Model)
  87. if pageCmd != nil {
  88. cmds = append(cmds, pageCmd)
  89. }
  90. }
  91. return a, tea.Batch(cmds...)
  92. case tea.WindowSizeMsg:
  93. a.wWidth, a.wHeight = msg.Width, msg.Height
  94. a.completions.Update(msg)
  95. return a, a.handleWindowResize(msg.Width, msg.Height)
  96. // Completions messages
  97. case completions.OpenCompletionsMsg, completions.FilterCompletionsMsg,
  98. completions.CloseCompletionsMsg, completions.RepositionCompletionsMsg:
  99. u, completionCmd := a.completions.Update(msg)
  100. a.completions = u.(completions.Completions)
  101. return a, completionCmd
  102. // Dialog messages
  103. case dialogs.OpenDialogMsg, dialogs.CloseDialogMsg:
  104. u, completionCmd := a.completions.Update(completions.CloseCompletionsMsg{})
  105. a.completions = u.(completions.Completions)
  106. u, dialogCmd := a.dialog.Update(msg)
  107. a.dialog = u.(dialogs.DialogCmp)
  108. return a, tea.Batch(completionCmd, dialogCmd)
  109. case commands.ShowArgumentsDialogMsg:
  110. return a, util.CmdHandler(
  111. dialogs.OpenDialogMsg{
  112. Model: commands.NewCommandArgumentsDialog(
  113. msg.CommandID,
  114. msg.Content,
  115. msg.ArgNames,
  116. ),
  117. },
  118. )
  119. // Page change messages
  120. case page.PageChangeMsg:
  121. return a, a.moveToPage(msg.ID)
  122. // Status Messages
  123. case util.InfoMsg, util.ClearStatusMsg:
  124. s, statusCmd := a.status.Update(msg)
  125. a.status = s.(status.StatusCmp)
  126. cmds = append(cmds, statusCmd)
  127. return a, tea.Batch(cmds...)
  128. // Session
  129. case cmpChat.SessionSelectedMsg:
  130. a.selectedSessionID = msg.ID
  131. case cmpChat.SessionClearedMsg:
  132. a.selectedSessionID = ""
  133. // Commands
  134. case commands.SwitchSessionsMsg:
  135. return a, func() tea.Msg {
  136. allSessions, _ := a.app.Sessions.List(context.Background())
  137. return dialogs.OpenDialogMsg{
  138. Model: sessions.NewSessionDialogCmp(allSessions, a.selectedSessionID),
  139. }
  140. }
  141. case commands.SwitchModelMsg:
  142. return a, util.CmdHandler(
  143. dialogs.OpenDialogMsg{
  144. Model: models.NewModelDialogCmp(),
  145. },
  146. )
  147. // Compact
  148. case commands.CompactMsg:
  149. return a, util.CmdHandler(dialogs.OpenDialogMsg{
  150. Model: compact.NewCompactDialogCmp(a.app.CoderAgent, msg.SessionID, true),
  151. })
  152. case commands.QuitMsg:
  153. return a, util.CmdHandler(dialogs.OpenDialogMsg{
  154. Model: quit.NewQuitDialog(),
  155. })
  156. case commands.ToggleHelpMsg:
  157. a.status.ToggleFullHelp()
  158. a.showingFullHelp = !a.showingFullHelp
  159. return a, a.handleWindowResize(a.wWidth, a.wHeight)
  160. // Model Switch
  161. case models.ModelSelectedMsg:
  162. config.Get().UpdatePreferredModel(msg.ModelType, msg.Model)
  163. // Update the agent with the new model/provider configuration
  164. if err := a.app.UpdateAgentModel(); err != nil {
  165. return a, util.ReportError(fmt.Errorf("model changed to %s but failed to update agent: %v", msg.Model.Model, err))
  166. }
  167. modelTypeName := "large"
  168. if msg.ModelType == config.SelectedModelTypeSmall {
  169. modelTypeName = "small"
  170. }
  171. return a, util.ReportInfo(fmt.Sprintf("%s model changed to %s", modelTypeName, msg.Model.Model))
  172. // File Picker
  173. case commands.OpenFilePickerMsg:
  174. if a.dialog.ActiveDialogID() == filepicker.FilePickerID {
  175. // If the commands dialog is already open, close it
  176. return a, util.CmdHandler(dialogs.CloseDialogMsg{})
  177. }
  178. return a, util.CmdHandler(dialogs.OpenDialogMsg{
  179. Model: filepicker.NewFilePickerCmp(a.app.Config().WorkingDir()),
  180. })
  181. // Permissions
  182. case pubsub.Event[permission.PermissionNotification]:
  183. // forward to page
  184. updated, cmd := a.pages[a.currentPage].Update(msg)
  185. a.pages[a.currentPage] = updated.(util.Model)
  186. return a, cmd
  187. case pubsub.Event[permission.PermissionRequest]:
  188. return a, util.CmdHandler(dialogs.OpenDialogMsg{
  189. Model: permissions.NewPermissionDialogCmp(msg.Payload),
  190. })
  191. case permissions.PermissionResponseMsg:
  192. switch msg.Action {
  193. case permissions.PermissionAllow:
  194. a.app.Permissions.Grant(msg.Permission)
  195. case permissions.PermissionAllowForSession:
  196. a.app.Permissions.GrantPersistent(msg.Permission)
  197. case permissions.PermissionDeny:
  198. a.app.Permissions.Deny(msg.Permission)
  199. }
  200. return a, nil
  201. // Agent Events
  202. case pubsub.Event[agent.AgentEvent]:
  203. payload := msg.Payload
  204. // Forward agent events to dialogs
  205. if a.dialog.HasDialogs() && a.dialog.ActiveDialogID() == compact.CompactDialogID {
  206. u, dialogCmd := a.dialog.Update(payload)
  207. a.dialog = u.(dialogs.DialogCmp)
  208. cmds = append(cmds, dialogCmd)
  209. }
  210. // Handle auto-compact logic
  211. if payload.Done && payload.Type == agent.AgentEventTypeResponse && a.selectedSessionID != "" {
  212. // Get current session to check token usage
  213. session, err := a.app.Sessions.Get(context.Background(), a.selectedSessionID)
  214. if err == nil {
  215. model := a.app.CoderAgent.Model()
  216. contextWindow := model.ContextWindow
  217. tokens := session.CompletionTokens + session.PromptTokens
  218. if (tokens >= int64(float64(contextWindow)*0.95)) && !config.Get().Options.DisableAutoSummarize { // Show compact confirmation dialog
  219. cmds = append(cmds, util.CmdHandler(dialogs.OpenDialogMsg{
  220. Model: compact.NewCompactDialogCmp(a.app.CoderAgent, a.selectedSessionID, false),
  221. }))
  222. }
  223. }
  224. }
  225. return a, tea.Batch(cmds...)
  226. case splash.OnboardingCompleteMsg:
  227. a.isConfigured = config.HasInitialDataConfig()
  228. updated, pageCmd := a.pages[a.currentPage].Update(msg)
  229. a.pages[a.currentPage] = updated.(util.Model)
  230. cmds = append(cmds, pageCmd)
  231. return a, tea.Batch(cmds...)
  232. // Key Press Messages
  233. case tea.KeyPressMsg:
  234. return a, a.handleKeyPressMsg(msg)
  235. case tea.MouseWheelMsg:
  236. if !a.dialog.HasDialogs() {
  237. updated, pageCmd := a.pages[a.currentPage].Update(msg)
  238. a.pages[a.currentPage] = updated.(util.Model)
  239. cmds = append(cmds, pageCmd)
  240. }
  241. return a, tea.Batch(cmds...)
  242. case tea.PasteMsg:
  243. if a.dialog.HasDialogs() {
  244. u, dialogCmd := a.dialog.Update(msg)
  245. a.dialog = u.(dialogs.DialogCmp)
  246. cmds = append(cmds, dialogCmd)
  247. } else {
  248. updated, pageCmd := a.pages[a.currentPage].Update(msg)
  249. a.pages[a.currentPage] = updated.(util.Model)
  250. cmds = append(cmds, pageCmd)
  251. }
  252. return a, tea.Batch(cmds...)
  253. }
  254. s, _ := a.status.Update(msg)
  255. a.status = s.(status.StatusCmp)
  256. updated, cmd := a.pages[a.currentPage].Update(msg)
  257. a.pages[a.currentPage] = updated.(util.Model)
  258. if a.dialog.HasDialogs() {
  259. u, dialogCmd := a.dialog.Update(msg)
  260. a.dialog = u.(dialogs.DialogCmp)
  261. cmds = append(cmds, dialogCmd)
  262. }
  263. cmds = append(cmds, cmd)
  264. return a, tea.Batch(cmds...)
  265. }
  266. // handleWindowResize processes window resize events and updates all components.
  267. func (a *appModel) handleWindowResize(width, height int) tea.Cmd {
  268. var cmds []tea.Cmd
  269. if a.showingFullHelp {
  270. height -= 5
  271. } else {
  272. height -= 2
  273. }
  274. a.width, a.height = width, height
  275. // Update status bar
  276. s, cmd := a.status.Update(tea.WindowSizeMsg{Width: width, Height: height})
  277. a.status = s.(status.StatusCmp)
  278. cmds = append(cmds, cmd)
  279. // Update the current page
  280. for p, page := range a.pages {
  281. updated, pageCmd := page.Update(tea.WindowSizeMsg{Width: width, Height: height})
  282. a.pages[p] = updated.(util.Model)
  283. cmds = append(cmds, pageCmd)
  284. }
  285. // Update the dialogs
  286. dialog, cmd := a.dialog.Update(tea.WindowSizeMsg{Width: width, Height: height})
  287. a.dialog = dialog.(dialogs.DialogCmp)
  288. cmds = append(cmds, cmd)
  289. return tea.Batch(cmds...)
  290. }
  291. // handleKeyPressMsg processes keyboard input and routes to appropriate handlers.
  292. func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
  293. if a.completions.Open() {
  294. // completions
  295. keyMap := a.completions.KeyMap()
  296. switch {
  297. case key.Matches(msg, keyMap.Up), key.Matches(msg, keyMap.Down),
  298. key.Matches(msg, keyMap.Select), key.Matches(msg, keyMap.Cancel),
  299. key.Matches(msg, keyMap.UpInsert), key.Matches(msg, keyMap.DownInsert):
  300. u, cmd := a.completions.Update(msg)
  301. a.completions = u.(completions.Completions)
  302. return cmd
  303. }
  304. }
  305. switch {
  306. // help
  307. case key.Matches(msg, a.keyMap.Help):
  308. a.status.ToggleFullHelp()
  309. a.showingFullHelp = !a.showingFullHelp
  310. return a.handleWindowResize(a.wWidth, a.wHeight)
  311. // dialogs
  312. case key.Matches(msg, a.keyMap.Quit):
  313. if a.dialog.ActiveDialogID() == quit.QuitDialogID {
  314. return tea.Quit
  315. }
  316. return util.CmdHandler(dialogs.OpenDialogMsg{
  317. Model: quit.NewQuitDialog(),
  318. })
  319. case key.Matches(msg, a.keyMap.Commands):
  320. // if the app is not configured show no commands
  321. if !a.isConfigured {
  322. return nil
  323. }
  324. if a.dialog.ActiveDialogID() == commands.CommandsDialogID {
  325. return util.CmdHandler(dialogs.CloseDialogMsg{})
  326. }
  327. if a.dialog.HasDialogs() {
  328. return nil
  329. }
  330. return util.CmdHandler(dialogs.OpenDialogMsg{
  331. Model: commands.NewCommandDialog(a.selectedSessionID),
  332. })
  333. case key.Matches(msg, a.keyMap.Sessions):
  334. // if the app is not configured show no sessions
  335. if !a.isConfigured {
  336. return nil
  337. }
  338. if a.dialog.ActiveDialogID() == sessions.SessionsDialogID {
  339. return util.CmdHandler(dialogs.CloseDialogMsg{})
  340. }
  341. if a.dialog.HasDialogs() && a.dialog.ActiveDialogID() != commands.CommandsDialogID {
  342. return nil
  343. }
  344. var cmds []tea.Cmd
  345. if a.dialog.ActiveDialogID() == commands.CommandsDialogID {
  346. // If the commands dialog is open, close it first
  347. cmds = append(cmds, util.CmdHandler(dialogs.CloseDialogMsg{}))
  348. }
  349. cmds = append(cmds,
  350. func() tea.Msg {
  351. allSessions, _ := a.app.Sessions.List(context.Background())
  352. return dialogs.OpenDialogMsg{
  353. Model: sessions.NewSessionDialogCmp(allSessions, a.selectedSessionID),
  354. }
  355. },
  356. )
  357. return tea.Sequence(cmds...)
  358. case key.Matches(msg, a.keyMap.Suspend):
  359. if a.app.CoderAgent.IsBusy() {
  360. return util.ReportWarn("Agent is busy, please wait...")
  361. }
  362. return tea.Suspend
  363. default:
  364. if a.dialog.HasDialogs() {
  365. u, dialogCmd := a.dialog.Update(msg)
  366. a.dialog = u.(dialogs.DialogCmp)
  367. return dialogCmd
  368. } else {
  369. updated, cmd := a.pages[a.currentPage].Update(msg)
  370. a.pages[a.currentPage] = updated.(util.Model)
  371. return cmd
  372. }
  373. }
  374. }
  375. // moveToPage handles navigation between different pages in the application.
  376. func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
  377. if a.app.CoderAgent.IsBusy() {
  378. // TODO: maybe remove this : For now we don't move to any page if the agent is busy
  379. return util.ReportWarn("Agent is busy, please wait...")
  380. }
  381. var cmds []tea.Cmd
  382. if _, ok := a.loadedPages[pageID]; !ok {
  383. cmd := a.pages[pageID].Init()
  384. cmds = append(cmds, cmd)
  385. a.loadedPages[pageID] = true
  386. }
  387. a.previousPage = a.currentPage
  388. a.currentPage = pageID
  389. if sizable, ok := a.pages[a.currentPage].(layout.Sizeable); ok {
  390. cmd := sizable.SetSize(a.width, a.height)
  391. cmds = append(cmds, cmd)
  392. }
  393. return tea.Batch(cmds...)
  394. }
  395. // View renders the complete application interface including pages, dialogs, and overlays.
  396. func (a *appModel) View() tea.View {
  397. var view tea.View
  398. t := styles.CurrentTheme()
  399. view.BackgroundColor = t.BgBase
  400. if a.wWidth < 25 || a.wHeight < 15 {
  401. view.Layer = lipgloss.NewCanvas(
  402. lipgloss.NewLayer(
  403. t.S().Base.Width(a.wWidth).Height(a.wHeight).
  404. Align(lipgloss.Center, lipgloss.Center).
  405. Render(
  406. t.S().Base.
  407. Padding(1, 4).
  408. Foreground(t.White).
  409. BorderStyle(lipgloss.RoundedBorder()).
  410. BorderForeground(t.Primary).
  411. Render("Window too small!"),
  412. ),
  413. ),
  414. )
  415. return view
  416. }
  417. page := a.pages[a.currentPage]
  418. if withHelp, ok := page.(core.KeyMapHelp); ok {
  419. a.status.SetKeyMap(withHelp.Help())
  420. }
  421. pageView := page.View()
  422. components := []string{
  423. pageView,
  424. }
  425. components = append(components, a.status.View())
  426. appView := lipgloss.JoinVertical(lipgloss.Top, components...)
  427. layers := []*lipgloss.Layer{
  428. lipgloss.NewLayer(appView),
  429. }
  430. if a.dialog.HasDialogs() {
  431. layers = append(
  432. layers,
  433. a.dialog.GetLayers()...,
  434. )
  435. }
  436. var cursor *tea.Cursor
  437. if v, ok := page.(util.Cursor); ok {
  438. cursor = v.Cursor()
  439. // Hide the cursor if it's positioned outside the textarea
  440. statusHeight := a.height - strings.Count(pageView, "\n") + 1
  441. if cursor != nil && cursor.Y+statusHeight+chat.EditorHeight-2 <= a.height { // 2 for the top and bottom app padding
  442. cursor = nil
  443. }
  444. }
  445. activeView := a.dialog.ActiveModel()
  446. if activeView != nil {
  447. cursor = nil // Reset cursor if a dialog is active unless it implements util.Cursor
  448. if v, ok := activeView.(util.Cursor); ok {
  449. cursor = v.Cursor()
  450. }
  451. }
  452. if a.completions.Open() && cursor != nil {
  453. cmp := a.completions.View()
  454. x, y := a.completions.Position()
  455. layers = append(
  456. layers,
  457. lipgloss.NewLayer(cmp).X(x).Y(y),
  458. )
  459. }
  460. canvas := lipgloss.NewCanvas(
  461. layers...,
  462. )
  463. view.Layer = canvas
  464. view.Cursor = cursor
  465. return view
  466. }
  467. // New creates and initializes a new TUI application model.
  468. func New(app *app.App) tea.Model {
  469. chatPage := chat.New(app)
  470. keyMap := DefaultKeyMap()
  471. keyMap.pageBindings = chatPage.Bindings()
  472. model := &appModel{
  473. currentPage: chat.ChatPageID,
  474. app: app,
  475. status: status.NewStatusCmp(),
  476. loadedPages: make(map[page.PageID]bool),
  477. keyMap: keyMap,
  478. pages: map[page.PageID]util.Model{
  479. chat.ChatPageID: chatPage,
  480. },
  481. dialog: dialogs.NewDialogCmp(),
  482. completions: completions.New(),
  483. }
  484. return model
  485. }