tui.go 15 KB

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