1package model
2
3import (
4 "bytes"
5 "context"
6 "errors"
7 "fmt"
8 "image"
9 "log/slog"
10 "math/rand"
11 "net/http"
12 "os"
13 "path/filepath"
14 "regexp"
15 "slices"
16 "strconv"
17 "strings"
18 "time"
19
20 "charm.land/bubbles/v2/help"
21 "charm.land/bubbles/v2/key"
22 "charm.land/bubbles/v2/spinner"
23 "charm.land/bubbles/v2/textarea"
24 tea "charm.land/bubbletea/v2"
25 "charm.land/lipgloss/v2"
26 "github.com/charmbracelet/catwalk/pkg/catwalk"
27 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
28 "github.com/charmbracelet/crush/internal/app"
29 "github.com/charmbracelet/crush/internal/commands"
30 "github.com/charmbracelet/crush/internal/config"
31 "github.com/charmbracelet/crush/internal/filetracker"
32 "github.com/charmbracelet/crush/internal/history"
33 "github.com/charmbracelet/crush/internal/message"
34 "github.com/charmbracelet/crush/internal/permission"
35 "github.com/charmbracelet/crush/internal/pubsub"
36 "github.com/charmbracelet/crush/internal/session"
37 "github.com/charmbracelet/crush/internal/ui/anim"
38 "github.com/charmbracelet/crush/internal/ui/attachments"
39 "github.com/charmbracelet/crush/internal/ui/chat"
40 "github.com/charmbracelet/crush/internal/ui/common"
41 "github.com/charmbracelet/crush/internal/ui/completions"
42 "github.com/charmbracelet/crush/internal/ui/dialog"
43 timage "github.com/charmbracelet/crush/internal/ui/image"
44 "github.com/charmbracelet/crush/internal/ui/logo"
45 "github.com/charmbracelet/crush/internal/ui/styles"
46 "github.com/charmbracelet/crush/internal/uiutil"
47 "github.com/charmbracelet/crush/internal/version"
48 uv "github.com/charmbracelet/ultraviolet"
49 "github.com/charmbracelet/ultraviolet/screen"
50 "github.com/charmbracelet/x/editor"
51)
52
53// Compact mode breakpoints.
54const (
55 compactModeWidthBreakpoint = 120
56 compactModeHeightBreakpoint = 30
57)
58
59// Session details panel max height.
60const sessionDetailsMaxHeight = 20
61
62// uiFocusState represents the current focus state of the UI.
63type uiFocusState uint8
64
65// Possible uiFocusState values.
66const (
67 uiFocusNone uiFocusState = iota
68 uiFocusEditor
69 uiFocusMain
70)
71
72type uiState uint8
73
74// Possible uiState values.
75const (
76 uiConfigure uiState = iota
77 uiInitialize
78 uiLanding
79 uiChat
80)
81
82type openEditorMsg struct {
83 Text string
84}
85
86type (
87 // cancelTimerExpiredMsg is sent when the cancel timer expires.
88 cancelTimerExpiredMsg struct{}
89 // userCommandsLoadedMsg is sent when user commands are loaded.
90 userCommandsLoadedMsg struct {
91 Commands []commands.CustomCommand
92 }
93 // mcpPromptsLoadedMsg is sent when mcp prompts are loaded.
94 mcpPromptsLoadedMsg struct {
95 Prompts []commands.MCPPrompt
96 }
97 // sendMessageMsg is sent to send a message.
98 // currently only used for mcp prompts.
99 sendMessageMsg struct {
100 Content string
101 Attachments []message.Attachment
102 }
103
104 // closeDialogMsg is sent to close the current dialog.
105 closeDialogMsg struct{}
106)
107
108// UI represents the main user interface model.
109type UI struct {
110 com *common.Common
111 session *session.Session
112 sessionFiles []SessionFile
113
114 lastUserMessageTime int64
115
116 // The width and height of the terminal in cells.
117 width int
118 height int
119 layout layout
120
121 focus uiFocusState
122 state uiState
123
124 keyMap KeyMap
125 keyenh tea.KeyboardEnhancementsMsg
126
127 dialog *dialog.Overlay
128 status *Status
129
130 // isCanceling tracks whether the user has pressed escape once to cancel.
131 isCanceling bool
132
133 // header is the last cached header logo
134 header string
135
136 // sendProgressBar instructs the TUI to send progress bar updates to the
137 // terminal.
138 sendProgressBar bool
139
140 // QueryVersion instructs the TUI to query for the terminal version when it
141 // starts.
142 QueryVersion bool
143
144 // Editor components
145 textarea textarea.Model
146
147 // Attachment list
148 attachments *attachments.Attachments
149
150 readyPlaceholder string
151 workingPlaceholder string
152
153 // Completions state
154 completions *completions.Completions
155 completionsOpen bool
156 completionsStartIndex int
157 completionsQuery string
158 completionsPositionStart image.Point // x,y where user typed '@'
159
160 // Chat components
161 chat *Chat
162
163 // onboarding state
164 onboarding struct {
165 yesInitializeSelected bool
166 }
167
168 // lsp
169 lspStates map[string]app.LSPClientInfo
170
171 // mcp
172 mcpStates map[string]mcp.ClientInfo
173
174 // sidebarLogo keeps a cached version of the sidebar sidebarLogo.
175 sidebarLogo string
176
177 // imgCaps stores the terminal image capabilities.
178 imgCaps timage.Capabilities
179
180 // custom commands & mcp commands
181 customCommands []commands.CustomCommand
182 mcpPrompts []commands.MCPPrompt
183
184 // forceCompactMode tracks whether compact mode is forced by user toggle
185 forceCompactMode bool
186
187 // isCompact tracks whether we're currently in compact layout mode (either
188 // by user toggle or auto-switch based on window size)
189 isCompact bool
190
191 // detailsOpen tracks whether the details panel is open (in compact mode)
192 detailsOpen bool
193
194 // pills state
195 pillsExpanded bool
196 focusedPillSection pillSection
197 promptQueue int
198 pillsView string
199
200 // Todo spinner
201 todoSpinner spinner.Model
202 todoIsSpinning bool
203}
204
205// New creates a new instance of the [UI] model.
206func New(com *common.Common) *UI {
207 // Editor components
208 ta := textarea.New()
209 ta.SetStyles(com.Styles.TextArea)
210 ta.ShowLineNumbers = false
211 ta.CharLimit = -1
212 ta.SetVirtualCursor(false)
213 ta.Focus()
214
215 ch := NewChat(com)
216
217 keyMap := DefaultKeyMap()
218
219 // Completions component
220 comp := completions.New(
221 com.Styles.Completions.Normal,
222 com.Styles.Completions.Focused,
223 com.Styles.Completions.Match,
224 )
225
226 todoSpinner := spinner.New(
227 spinner.WithSpinner(spinner.MiniDot),
228 spinner.WithStyle(com.Styles.Pills.TodoSpinner),
229 )
230
231 // Attachments component
232 attachments := attachments.New(
233 attachments.NewRenderer(
234 com.Styles.Attachments.Normal,
235 com.Styles.Attachments.Deleting,
236 com.Styles.Attachments.Image,
237 com.Styles.Attachments.Text,
238 ),
239 attachments.Keymap{
240 DeleteMode: keyMap.Editor.AttachmentDeleteMode,
241 DeleteAll: keyMap.Editor.DeleteAllAttachments,
242 Escape: keyMap.Editor.Escape,
243 },
244 )
245
246 ui := &UI{
247 com: com,
248 dialog: dialog.NewOverlay(),
249 keyMap: keyMap,
250 focus: uiFocusNone,
251 state: uiConfigure,
252 textarea: ta,
253 chat: ch,
254 completions: comp,
255 attachments: attachments,
256 todoSpinner: todoSpinner,
257 }
258
259 status := NewStatus(com, ui)
260
261 // set onboarding state defaults
262 ui.onboarding.yesInitializeSelected = true
263
264 // If no provider is configured show the user the provider list
265 if !com.Config().IsConfigured() {
266 ui.state = uiConfigure
267 // if the project needs initialization show the user the question
268 } else if n, _ := config.ProjectNeedsInitialization(); n {
269 ui.state = uiInitialize
270 // otherwise go to the landing UI
271 } else {
272 ui.state = uiLanding
273 ui.focus = uiFocusEditor
274 }
275
276 ui.setEditorPrompt(false)
277 ui.randomizePlaceholders()
278 ui.textarea.Placeholder = ui.readyPlaceholder
279 ui.status = status
280
281 // Initialize compact mode from config
282 ui.forceCompactMode = com.Config().Options.TUI.CompactMode
283
284 return ui
285}
286
287// Init initializes the UI model.
288func (m *UI) Init() tea.Cmd {
289 var cmds []tea.Cmd
290 if m.QueryVersion {
291 cmds = append(cmds, tea.RequestTerminalVersion)
292 }
293 // load the user commands async
294 cmds = append(cmds, m.loadCustomCommands())
295 return tea.Batch(cmds...)
296}
297
298// loadCustomCommands loads the custom commands asynchronously.
299func (m *UI) loadCustomCommands() tea.Cmd {
300 return func() tea.Msg {
301 customCommands, err := commands.LoadCustomCommands(m.com.Config())
302 if err != nil {
303 slog.Error("failed to load custom commands", "error", err)
304 }
305 return userCommandsLoadedMsg{Commands: customCommands}
306 }
307}
308
309// loadMCPrompts loads the MCP prompts asynchronously.
310func (m *UI) loadMCPrompts() tea.Cmd {
311 return func() tea.Msg {
312 prompts, err := commands.LoadMCPPrompts()
313 if err != nil {
314 slog.Error("failed to load mcp prompts", "error", err)
315 }
316 if prompts == nil {
317 // flag them as loaded even if there is none or an error
318 prompts = []commands.MCPPrompt{}
319 }
320 return mcpPromptsLoadedMsg{Prompts: prompts}
321 }
322}
323
324// Update handles updates to the UI model.
325func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
326 var cmds []tea.Cmd
327 if m.hasSession() && m.isAgentBusy() {
328 queueSize := m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID)
329 if queueSize != m.promptQueue {
330 m.promptQueue = queueSize
331 m.updateLayoutAndSize()
332 }
333 }
334 switch msg := msg.(type) {
335 case tea.EnvMsg:
336 // Is this Windows Terminal?
337 if !m.sendProgressBar {
338 m.sendProgressBar = slices.Contains(msg, "WT_SESSION")
339 }
340 m.imgCaps.Env = uv.Environ(msg)
341 // XXX: Right now, we're using the same logic to determine image
342 // support. Terminals like Apple Terminal and possibly others might
343 // bleed characters when querying for Kitty graphics via APC escape
344 // sequences.
345 cmds = append(cmds, timage.RequestCapabilities(m.imgCaps.Env))
346 case loadSessionMsg:
347 m.state = uiChat
348 if m.forceCompactMode {
349 m.isCompact = true
350 }
351 m.session = msg.session
352 m.sessionFiles = msg.files
353 msgs, err := m.com.App.Messages.List(context.Background(), m.session.ID)
354 if err != nil {
355 cmds = append(cmds, uiutil.ReportError(err))
356 break
357 }
358 if cmd := m.setSessionMessages(msgs); cmd != nil {
359 cmds = append(cmds, cmd)
360 }
361 if hasInProgressTodo(m.session.Todos) {
362 // only start spinner if there is an in-progress todo
363 if m.isAgentBusy() {
364 m.todoIsSpinning = true
365 cmds = append(cmds, m.todoSpinner.Tick)
366 }
367 m.updateLayoutAndSize()
368 }
369
370 case sendMessageMsg:
371 cmds = append(cmds, m.sendMessage(msg.Content, msg.Attachments...))
372
373 case userCommandsLoadedMsg:
374 m.customCommands = msg.Commands
375 dia := m.dialog.Dialog(dialog.CommandsID)
376 if dia == nil {
377 break
378 }
379
380 commands, ok := dia.(*dialog.Commands)
381 if ok {
382 commands.SetCustomCommands(m.customCommands)
383 }
384 case mcpPromptsLoadedMsg:
385 m.mcpPrompts = msg.Prompts
386 dia := m.dialog.Dialog(dialog.CommandsID)
387 if dia == nil {
388 break
389 }
390
391 commands, ok := dia.(*dialog.Commands)
392 if ok {
393 commands.SetMCPPrompts(m.mcpPrompts)
394 }
395
396 case closeDialogMsg:
397 m.dialog.CloseFrontDialog()
398
399 case pubsub.Event[session.Session]:
400 if m.session != nil && msg.Payload.ID == m.session.ID {
401 prevHasInProgress := hasInProgressTodo(m.session.Todos)
402 m.session = &msg.Payload
403 if !prevHasInProgress && hasInProgressTodo(m.session.Todos) {
404 m.todoIsSpinning = true
405 cmds = append(cmds, m.todoSpinner.Tick)
406 m.updateLayoutAndSize()
407 }
408 }
409 case pubsub.Event[message.Message]:
410 // Check if this is a child session message for an agent tool.
411 if m.session == nil {
412 break
413 }
414 if msg.Payload.SessionID != m.session.ID {
415 // This might be a child session message from an agent tool.
416 if cmd := m.handleChildSessionMessage(msg); cmd != nil {
417 cmds = append(cmds, cmd)
418 }
419 break
420 }
421 switch msg.Type {
422 case pubsub.CreatedEvent:
423 cmds = append(cmds, m.appendSessionMessage(msg.Payload))
424 case pubsub.UpdatedEvent:
425 cmds = append(cmds, m.updateSessionMessage(msg.Payload))
426 case pubsub.DeletedEvent:
427 m.chat.RemoveMessage(msg.Payload.ID)
428 }
429 // start the spinner if there is a new message
430 if hasInProgressTodo(m.session.Todos) && m.isAgentBusy() && !m.todoIsSpinning {
431 m.todoIsSpinning = true
432 cmds = append(cmds, m.todoSpinner.Tick)
433 }
434 // stop the spinner if the agent is not busy anymore
435 if m.todoIsSpinning && !m.isAgentBusy() {
436 m.todoIsSpinning = false
437 }
438 // there is a number of things that could change the pills here so we want to re-render
439 m.renderPills()
440 case pubsub.Event[history.File]:
441 cmds = append(cmds, m.handleFileEvent(msg.Payload))
442 case pubsub.Event[app.LSPEvent]:
443 m.lspStates = app.GetLSPStates()
444 case pubsub.Event[mcp.Event]:
445 m.mcpStates = mcp.GetStates()
446 // check if all mcps are initialized
447 initialized := true
448 for _, state := range m.mcpStates {
449 if state.State == mcp.StateStarting {
450 initialized = false
451 break
452 }
453 }
454 if initialized && m.mcpPrompts == nil {
455 cmds = append(cmds, m.loadMCPrompts())
456 }
457 case pubsub.Event[permission.PermissionRequest]:
458 if cmd := m.openPermissionsDialog(msg.Payload); cmd != nil {
459 cmds = append(cmds, cmd)
460 }
461 case pubsub.Event[permission.PermissionNotification]:
462 m.handlePermissionNotification(msg.Payload)
463 case cancelTimerExpiredMsg:
464 m.isCanceling = false
465 case tea.TerminalVersionMsg:
466 termVersion := strings.ToLower(msg.Name)
467 // Only enable progress bar for the following terminals.
468 if !m.sendProgressBar {
469 m.sendProgressBar = strings.Contains(termVersion, "ghostty")
470 }
471 return m, nil
472 case tea.WindowSizeMsg:
473 m.width, m.height = msg.Width, msg.Height
474 m.handleCompactMode(m.width, m.height)
475 m.updateLayoutAndSize()
476 // XXX: We need to store cell dimensions for image rendering.
477 m.imgCaps.Columns, m.imgCaps.Rows = msg.Width, msg.Height
478 case tea.KeyboardEnhancementsMsg:
479 m.keyenh = msg
480 if msg.SupportsKeyDisambiguation() {
481 m.keyMap.Models.SetHelp("ctrl+m", "models")
482 m.keyMap.Editor.Newline.SetHelp("shift+enter", "newline")
483 }
484 case tea.MouseClickMsg:
485 switch m.state {
486 case uiChat:
487 x, y := msg.X, msg.Y
488 // Adjust for chat area position
489 x -= m.layout.main.Min.X
490 y -= m.layout.main.Min.Y
491 m.chat.HandleMouseDown(x, y)
492 }
493
494 case tea.MouseMotionMsg:
495 switch m.state {
496 case uiChat:
497 if msg.Y <= 0 {
498 if cmd := m.chat.ScrollByAndAnimate(-1); cmd != nil {
499 cmds = append(cmds, cmd)
500 }
501 if !m.chat.SelectedItemInView() {
502 m.chat.SelectPrev()
503 if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
504 cmds = append(cmds, cmd)
505 }
506 }
507 } else if msg.Y >= m.chat.Height()-1 {
508 if cmd := m.chat.ScrollByAndAnimate(1); cmd != nil {
509 cmds = append(cmds, cmd)
510 }
511 if !m.chat.SelectedItemInView() {
512 m.chat.SelectNext()
513 if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
514 cmds = append(cmds, cmd)
515 }
516 }
517 }
518
519 x, y := msg.X, msg.Y
520 // Adjust for chat area position
521 x -= m.layout.main.Min.X
522 y -= m.layout.main.Min.Y
523 m.chat.HandleMouseDrag(x, y)
524 }
525
526 case tea.MouseReleaseMsg:
527 switch m.state {
528 case uiChat:
529 x, y := msg.X, msg.Y
530 // Adjust for chat area position
531 x -= m.layout.main.Min.X
532 y -= m.layout.main.Min.Y
533 m.chat.HandleMouseUp(x, y)
534 }
535 case tea.MouseWheelMsg:
536 // Pass mouse events to dialogs first if any are open.
537 if m.dialog.HasDialogs() {
538 m.dialog.Update(msg)
539 return m, tea.Batch(cmds...)
540 }
541
542 // Otherwise handle mouse wheel for chat.
543 switch m.state {
544 case uiChat:
545 switch msg.Button {
546 case tea.MouseWheelUp:
547 if cmd := m.chat.ScrollByAndAnimate(-5); cmd != nil {
548 cmds = append(cmds, cmd)
549 }
550 if !m.chat.SelectedItemInView() {
551 m.chat.SelectPrev()
552 if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
553 cmds = append(cmds, cmd)
554 }
555 }
556 case tea.MouseWheelDown:
557 if cmd := m.chat.ScrollByAndAnimate(5); cmd != nil {
558 cmds = append(cmds, cmd)
559 }
560 if !m.chat.SelectedItemInView() {
561 m.chat.SelectNext()
562 if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
563 cmds = append(cmds, cmd)
564 }
565 }
566 }
567 }
568 case anim.StepMsg:
569 if m.state == uiChat {
570 if cmd := m.chat.Animate(msg); cmd != nil {
571 cmds = append(cmds, cmd)
572 }
573 }
574 case spinner.TickMsg:
575 if m.dialog.HasDialogs() {
576 // route to dialog
577 if cmd := m.handleDialogMsg(msg); cmd != nil {
578 cmds = append(cmds, cmd)
579 }
580 }
581 if m.state == uiChat && m.hasSession() && hasInProgressTodo(m.session.Todos) && m.todoIsSpinning {
582 var cmd tea.Cmd
583 m.todoSpinner, cmd = m.todoSpinner.Update(msg)
584 if cmd != nil {
585 m.renderPills()
586 cmds = append(cmds, cmd)
587 }
588 }
589
590 case tea.KeyPressMsg:
591 if cmd := m.handleKeyPressMsg(msg); cmd != nil {
592 cmds = append(cmds, cmd)
593 }
594 case tea.PasteMsg:
595 if cmd := m.handlePasteMsg(msg); cmd != nil {
596 cmds = append(cmds, cmd)
597 }
598 case openEditorMsg:
599 m.textarea.SetValue(msg.Text)
600 m.textarea.MoveToEnd()
601 case uiutil.InfoMsg:
602 m.status.SetInfoMsg(msg)
603 ttl := msg.TTL
604 if ttl <= 0 {
605 ttl = DefaultStatusTTL
606 }
607 cmds = append(cmds, clearInfoMsgCmd(ttl))
608 case uiutil.ClearStatusMsg:
609 m.status.ClearInfoMsg()
610 case completions.FilesLoadedMsg:
611 // Handle async file loading for completions.
612 if m.completionsOpen {
613 m.completions.SetFiles(msg.Files)
614 }
615 case uv.WindowPixelSizeEvent:
616 // [timage.RequestCapabilities] requests the terminal to send a window
617 // size event to help determine pixel dimensions.
618 m.imgCaps.PixelWidth = msg.Width
619 m.imgCaps.PixelHeight = msg.Height
620 case uv.KittyGraphicsEvent:
621 // [timage.RequestCapabilities] sends a Kitty graphics query and this
622 // captures the response. Any response means the terminal understands
623 // the protocol.
624 m.imgCaps.SupportsKittyGraphics = true
625 if !bytes.HasPrefix(msg.Payload, []byte("OK")) {
626 slog.Warn("unexpected Kitty graphics response",
627 "response", string(msg.Payload),
628 "options", msg.Options)
629 }
630 default:
631 if m.dialog.HasDialogs() {
632 if cmd := m.handleDialogMsg(msg); cmd != nil {
633 cmds = append(cmds, cmd)
634 }
635 }
636 }
637
638 // This logic gets triggered on any message type, but should it?
639 switch m.focus {
640 case uiFocusMain:
641 case uiFocusEditor:
642 // Textarea placeholder logic
643 if m.isAgentBusy() {
644 m.textarea.Placeholder = m.workingPlaceholder
645 } else {
646 m.textarea.Placeholder = m.readyPlaceholder
647 }
648 if m.com.App.Permissions.SkipRequests() {
649 m.textarea.Placeholder = "Yolo mode!"
650 }
651 }
652
653 // at this point this can only handle [message.Attachment] message, and we
654 // should return all cmds anyway.
655 _ = m.attachments.Update(msg)
656 return m, tea.Batch(cmds...)
657}
658
659// setSessionMessages sets the messages for the current session in the chat
660func (m *UI) setSessionMessages(msgs []message.Message) tea.Cmd {
661 var cmds []tea.Cmd
662 // Build tool result map to link tool calls with their results
663 msgPtrs := make([]*message.Message, len(msgs))
664 for i := range msgs {
665 msgPtrs[i] = &msgs[i]
666 }
667 toolResultMap := chat.BuildToolResultMap(msgPtrs)
668 if len(msgPtrs) > 0 {
669 m.lastUserMessageTime = msgPtrs[0].CreatedAt
670 }
671
672 // Add messages to chat with linked tool results
673 items := make([]chat.MessageItem, 0, len(msgs)*2)
674 for _, msg := range msgPtrs {
675 switch msg.Role {
676 case message.User:
677 m.lastUserMessageTime = msg.CreatedAt
678 items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...)
679 case message.Assistant:
680 items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...)
681 if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
682 infoItem := chat.NewAssistantInfoItem(m.com.Styles, msg, time.Unix(m.lastUserMessageTime, 0))
683 items = append(items, infoItem)
684 }
685 default:
686 items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...)
687 }
688 }
689
690 // Load nested tool calls for agent/agentic_fetch tools.
691 m.loadNestedToolCalls(items)
692
693 // If the user switches between sessions while the agent is working we want
694 // to make sure the animations are shown.
695 for _, item := range items {
696 if animatable, ok := item.(chat.Animatable); ok {
697 if cmd := animatable.StartAnimation(); cmd != nil {
698 cmds = append(cmds, cmd)
699 }
700 }
701 }
702
703 m.chat.SetMessages(items...)
704 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
705 cmds = append(cmds, cmd)
706 }
707 m.chat.SelectLast()
708 return tea.Batch(cmds...)
709}
710
711// loadNestedToolCalls recursively loads nested tool calls for agent/agentic_fetch tools.
712func (m *UI) loadNestedToolCalls(items []chat.MessageItem) {
713 for _, item := range items {
714 nestedContainer, ok := item.(chat.NestedToolContainer)
715 if !ok {
716 continue
717 }
718 toolItem, ok := item.(chat.ToolMessageItem)
719 if !ok {
720 continue
721 }
722
723 tc := toolItem.ToolCall()
724 messageID := toolItem.MessageID()
725
726 // Get the agent tool session ID.
727 agentSessionID := m.com.App.Sessions.CreateAgentToolSessionID(messageID, tc.ID)
728
729 // Fetch nested messages.
730 nestedMsgs, err := m.com.App.Messages.List(context.Background(), agentSessionID)
731 if err != nil || len(nestedMsgs) == 0 {
732 continue
733 }
734
735 // Build tool result map for nested messages.
736 nestedMsgPtrs := make([]*message.Message, len(nestedMsgs))
737 for i := range nestedMsgs {
738 nestedMsgPtrs[i] = &nestedMsgs[i]
739 }
740 nestedToolResultMap := chat.BuildToolResultMap(nestedMsgPtrs)
741
742 // Extract nested tool items.
743 var nestedTools []chat.ToolMessageItem
744 for _, nestedMsg := range nestedMsgPtrs {
745 nestedItems := chat.ExtractMessageItems(m.com.Styles, nestedMsg, nestedToolResultMap)
746 for _, nestedItem := range nestedItems {
747 if nestedToolItem, ok := nestedItem.(chat.ToolMessageItem); ok {
748 // Mark nested tools as simple (compact) rendering.
749 if simplifiable, ok := nestedToolItem.(chat.Compactable); ok {
750 simplifiable.SetCompact(true)
751 }
752 nestedTools = append(nestedTools, nestedToolItem)
753 }
754 }
755 }
756
757 // Recursively load nested tool calls for any agent tools within.
758 nestedMessageItems := make([]chat.MessageItem, len(nestedTools))
759 for i, nt := range nestedTools {
760 nestedMessageItems[i] = nt
761 }
762 m.loadNestedToolCalls(nestedMessageItems)
763
764 // Set nested tools on the parent.
765 nestedContainer.SetNestedTools(nestedTools)
766 }
767}
768
769// appendSessionMessage appends a new message to the current session in the chat
770// if the message is a tool result it will update the corresponding tool call message
771func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
772 var cmds []tea.Cmd
773 existing := m.chat.MessageItem(msg.ID)
774 if existing != nil {
775 // message already exists, skip
776 return nil
777 }
778 switch msg.Role {
779 case message.User:
780 m.lastUserMessageTime = msg.CreatedAt
781 items := chat.ExtractMessageItems(m.com.Styles, &msg, nil)
782 for _, item := range items {
783 if animatable, ok := item.(chat.Animatable); ok {
784 if cmd := animatable.StartAnimation(); cmd != nil {
785 cmds = append(cmds, cmd)
786 }
787 }
788 }
789 m.chat.AppendMessages(items...)
790 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
791 cmds = append(cmds, cmd)
792 }
793 case message.Assistant:
794 items := chat.ExtractMessageItems(m.com.Styles, &msg, nil)
795 for _, item := range items {
796 if animatable, ok := item.(chat.Animatable); ok {
797 if cmd := animatable.StartAnimation(); cmd != nil {
798 cmds = append(cmds, cmd)
799 }
800 }
801 }
802 m.chat.AppendMessages(items...)
803 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
804 cmds = append(cmds, cmd)
805 }
806 if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
807 infoItem := chat.NewAssistantInfoItem(m.com.Styles, &msg, time.Unix(m.lastUserMessageTime, 0))
808 m.chat.AppendMessages(infoItem)
809 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
810 cmds = append(cmds, cmd)
811 }
812 }
813 case message.Tool:
814 for _, tr := range msg.ToolResults() {
815 toolItem := m.chat.MessageItem(tr.ToolCallID)
816 if toolItem == nil {
817 // we should have an item!
818 continue
819 }
820 if toolMsgItem, ok := toolItem.(chat.ToolMessageItem); ok {
821 toolMsgItem.SetResult(&tr)
822 }
823 }
824 }
825 return tea.Batch(cmds...)
826}
827
828// updateSessionMessage updates an existing message in the current session in the chat
829// when an assistant message is updated it may include updated tool calls as well
830// that is why we need to handle creating/updating each tool call message too
831func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd {
832 var cmds []tea.Cmd
833 existingItem := m.chat.MessageItem(msg.ID)
834
835 if existingItem != nil {
836 if assistantItem, ok := existingItem.(*chat.AssistantMessageItem); ok {
837 assistantItem.SetMessage(&msg)
838 }
839 }
840
841 shouldRenderAssistant := chat.ShouldRenderAssistantMessage(&msg)
842 // if the message of the assistant does not have any response just tool calls we need to remove it
843 if !shouldRenderAssistant && len(msg.ToolCalls()) > 0 && existingItem != nil {
844 m.chat.RemoveMessage(msg.ID)
845 if infoItem := m.chat.MessageItem(chat.AssistantInfoID(msg.ID)); infoItem != nil {
846 m.chat.RemoveMessage(chat.AssistantInfoID(msg.ID))
847 }
848 }
849
850 if shouldRenderAssistant && msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
851 if infoItem := m.chat.MessageItem(chat.AssistantInfoID(msg.ID)); infoItem == nil {
852 newInfoItem := chat.NewAssistantInfoItem(m.com.Styles, &msg, time.Unix(m.lastUserMessageTime, 0))
853 m.chat.AppendMessages(newInfoItem)
854 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
855 cmds = append(cmds, cmd)
856 }
857 }
858 }
859
860 var items []chat.MessageItem
861 for _, tc := range msg.ToolCalls() {
862 existingToolItem := m.chat.MessageItem(tc.ID)
863 if toolItem, ok := existingToolItem.(chat.ToolMessageItem); ok {
864 existingToolCall := toolItem.ToolCall()
865 // only update if finished state changed or input changed
866 // to avoid clearing the cache
867 if (tc.Finished && !existingToolCall.Finished) || tc.Input != existingToolCall.Input {
868 toolItem.SetToolCall(tc)
869 }
870 }
871 if existingToolItem == nil {
872 items = append(items, chat.NewToolMessageItem(m.com.Styles, msg.ID, tc, nil, false))
873 }
874 }
875
876 for _, item := range items {
877 if animatable, ok := item.(chat.Animatable); ok {
878 if cmd := animatable.StartAnimation(); cmd != nil {
879 cmds = append(cmds, cmd)
880 }
881 }
882 }
883 m.chat.AppendMessages(items...)
884 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
885 cmds = append(cmds, cmd)
886 }
887
888 return tea.Batch(cmds...)
889}
890
891// handleChildSessionMessage handles messages from child sessions (agent tools).
892func (m *UI) handleChildSessionMessage(event pubsub.Event[message.Message]) tea.Cmd {
893 var cmds []tea.Cmd
894
895 // Only process messages with tool calls or results.
896 if len(event.Payload.ToolCalls()) == 0 && len(event.Payload.ToolResults()) == 0 {
897 return nil
898 }
899
900 // Check if this is an agent tool session and parse it.
901 childSessionID := event.Payload.SessionID
902 _, toolCallID, ok := m.com.App.Sessions.ParseAgentToolSessionID(childSessionID)
903 if !ok {
904 return nil
905 }
906
907 // Find the parent agent tool item.
908 var agentItem chat.NestedToolContainer
909 for i := 0; i < m.chat.Len(); i++ {
910 item := m.chat.MessageItem(toolCallID)
911 if item == nil {
912 continue
913 }
914 if agent, ok := item.(chat.NestedToolContainer); ok {
915 if toolMessageItem, ok := item.(chat.ToolMessageItem); ok {
916 if toolMessageItem.ToolCall().ID == toolCallID {
917 // Verify this agent belongs to the correct parent message.
918 // We can't directly check parentMessageID on the item, so we trust the session parsing.
919 agentItem = agent
920 break
921 }
922 }
923 }
924 }
925
926 if agentItem == nil {
927 return nil
928 }
929
930 // Get existing nested tools.
931 nestedTools := agentItem.NestedTools()
932
933 // Update or create nested tool calls.
934 for _, tc := range event.Payload.ToolCalls() {
935 found := false
936 for _, existingTool := range nestedTools {
937 if existingTool.ToolCall().ID == tc.ID {
938 existingTool.SetToolCall(tc)
939 found = true
940 break
941 }
942 }
943 if !found {
944 // Create a new nested tool item.
945 nestedItem := chat.NewToolMessageItem(m.com.Styles, event.Payload.ID, tc, nil, false)
946 if simplifiable, ok := nestedItem.(chat.Compactable); ok {
947 simplifiable.SetCompact(true)
948 }
949 if animatable, ok := nestedItem.(chat.Animatable); ok {
950 if cmd := animatable.StartAnimation(); cmd != nil {
951 cmds = append(cmds, cmd)
952 }
953 }
954 nestedTools = append(nestedTools, nestedItem)
955 }
956 }
957
958 // Update nested tool results.
959 for _, tr := range event.Payload.ToolResults() {
960 for _, nestedTool := range nestedTools {
961 if nestedTool.ToolCall().ID == tr.ToolCallID {
962 nestedTool.SetResult(&tr)
963 break
964 }
965 }
966 }
967
968 // Update the agent item with the new nested tools.
969 agentItem.SetNestedTools(nestedTools)
970
971 // Update the chat so it updates the index map for animations to work as expected
972 m.chat.UpdateNestedToolIDs(toolCallID)
973
974 return tea.Batch(cmds...)
975}
976
977func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
978 var cmds []tea.Cmd
979 action := m.dialog.Update(msg)
980 if action == nil {
981 return tea.Batch(cmds...)
982 }
983
984 switch msg := action.(type) {
985 // Generic dialog messages
986 case dialog.ActionClose:
987 m.dialog.CloseFrontDialog()
988 if m.focus == uiFocusEditor {
989 cmds = append(cmds, m.textarea.Focus())
990 }
991 case dialog.ActionCmd:
992 if msg.Cmd != nil {
993 cmds = append(cmds, msg.Cmd)
994 }
995
996 // Session dialog messages
997 case dialog.ActionSelectSession:
998 m.dialog.CloseDialog(dialog.SessionsID)
999 cmds = append(cmds, m.loadSession(msg.Session.ID))
1000
1001 // Open dialog message
1002 case dialog.ActionOpenDialog:
1003 m.dialog.CloseDialog(dialog.CommandsID)
1004 if cmd := m.openDialog(msg.DialogID); cmd != nil {
1005 cmds = append(cmds, cmd)
1006 }
1007
1008 // Command dialog messages
1009 case dialog.ActionToggleYoloMode:
1010 yolo := !m.com.App.Permissions.SkipRequests()
1011 m.com.App.Permissions.SetSkipRequests(yolo)
1012 m.setEditorPrompt(yolo)
1013 m.dialog.CloseDialog(dialog.CommandsID)
1014 case dialog.ActionNewSession:
1015 if m.isAgentBusy() {
1016 cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session..."))
1017 break
1018 }
1019 m.newSession()
1020 m.dialog.CloseDialog(dialog.CommandsID)
1021 case dialog.ActionSummarize:
1022 if m.isAgentBusy() {
1023 cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before summarizing session..."))
1024 break
1025 }
1026 cmds = append(cmds, func() tea.Msg {
1027 err := m.com.App.AgentCoordinator.Summarize(context.Background(), msg.SessionID)
1028 if err != nil {
1029 return uiutil.ReportError(err)()
1030 }
1031 return nil
1032 })
1033 m.dialog.CloseDialog(dialog.CommandsID)
1034 case dialog.ActionToggleHelp:
1035 m.status.ToggleHelp()
1036 m.dialog.CloseDialog(dialog.CommandsID)
1037 case dialog.ActionExternalEditor:
1038 if m.isAgentBusy() {
1039 cmds = append(cmds, uiutil.ReportWarn("Agent is working, please wait..."))
1040 break
1041 }
1042 cmds = append(cmds, m.openEditor(m.textarea.Value()))
1043 m.dialog.CloseDialog(dialog.CommandsID)
1044 case dialog.ActionToggleCompactMode:
1045 cmds = append(cmds, m.toggleCompactMode())
1046 m.dialog.CloseDialog(dialog.CommandsID)
1047 case dialog.ActionToggleThinking:
1048 if m.isAgentBusy() {
1049 cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait..."))
1050 break
1051 }
1052
1053 cmds = append(cmds, func() tea.Msg {
1054 cfg := m.com.Config()
1055 if cfg == nil {
1056 return uiutil.ReportError(errors.New("configuration not found"))()
1057 }
1058
1059 agentCfg, ok := cfg.Agents[config.AgentCoder]
1060 if !ok {
1061 return uiutil.ReportError(errors.New("agent configuration not found"))()
1062 }
1063
1064 currentModel := cfg.Models[agentCfg.Model]
1065 currentModel.Think = !currentModel.Think
1066 if err := cfg.UpdatePreferredModel(agentCfg.Model, currentModel); err != nil {
1067 return uiutil.ReportError(err)()
1068 }
1069 m.com.App.UpdateAgentModel(context.TODO())
1070 status := "disabled"
1071 if currentModel.Think {
1072 status = "enabled"
1073 }
1074 return uiutil.NewInfoMsg("Thinking mode " + status)
1075 })
1076 m.dialog.CloseDialog(dialog.CommandsID)
1077 case dialog.ActionQuit:
1078 cmds = append(cmds, tea.Quit)
1079 case dialog.ActionInitializeProject:
1080 if m.isAgentBusy() {
1081 cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before summarizing session..."))
1082 break
1083 }
1084 cmds = append(cmds, m.initializeProject())
1085
1086 case dialog.ActionSelectModel:
1087 if m.isAgentBusy() {
1088 cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait..."))
1089 break
1090 }
1091
1092 cfg := m.com.Config()
1093 if cfg == nil {
1094 cmds = append(cmds, uiutil.ReportError(errors.New("configuration not found")))
1095 break
1096 }
1097
1098 var (
1099 providerID = msg.Model.Provider
1100 isCopilot = providerID == string(catwalk.InferenceProviderCopilot)
1101 isConfigured = func() bool { _, ok := cfg.Providers.Get(providerID); return ok }
1102 )
1103
1104 // Attempt to import GitHub Copilot tokens from VSCode if available.
1105 if isCopilot && !isConfigured() {
1106 config.Get().ImportCopilot()
1107 }
1108
1109 if !isConfigured() {
1110 m.dialog.CloseDialog(dialog.ModelsID)
1111 if cmd := m.openAuthenticationDialog(msg.Provider, msg.Model, msg.ModelType); cmd != nil {
1112 cmds = append(cmds, cmd)
1113 }
1114 break
1115 }
1116
1117 if err := cfg.UpdatePreferredModel(msg.ModelType, msg.Model); err != nil {
1118 cmds = append(cmds, uiutil.ReportError(err))
1119 }
1120
1121 cmds = append(cmds, func() tea.Msg {
1122 m.com.App.UpdateAgentModel(context.TODO())
1123
1124 modelMsg := fmt.Sprintf("%s model changed to %s", msg.ModelType, msg.Model.Model)
1125
1126 return uiutil.NewInfoMsg(modelMsg)
1127 })
1128
1129 m.dialog.CloseDialog(dialog.APIKeyInputID)
1130 m.dialog.CloseDialog(dialog.OAuthID)
1131 m.dialog.CloseDialog(dialog.ModelsID)
1132 case dialog.ActionSelectReasoningEffort:
1133 if m.isAgentBusy() {
1134 cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait..."))
1135 break
1136 }
1137
1138 cfg := m.com.Config()
1139 if cfg == nil {
1140 cmds = append(cmds, uiutil.ReportError(errors.New("configuration not found")))
1141 break
1142 }
1143
1144 agentCfg, ok := cfg.Agents[config.AgentCoder]
1145 if !ok {
1146 cmds = append(cmds, uiutil.ReportError(errors.New("agent configuration not found")))
1147 break
1148 }
1149
1150 currentModel := cfg.Models[agentCfg.Model]
1151 currentModel.ReasoningEffort = msg.Effort
1152 if err := cfg.UpdatePreferredModel(agentCfg.Model, currentModel); err != nil {
1153 cmds = append(cmds, uiutil.ReportError(err))
1154 break
1155 }
1156
1157 cmds = append(cmds, func() tea.Msg {
1158 m.com.App.UpdateAgentModel(context.TODO())
1159 return uiutil.NewInfoMsg("Reasoning effort set to " + msg.Effort)
1160 })
1161 m.dialog.CloseDialog(dialog.ReasoningID)
1162 case dialog.ActionPermissionResponse:
1163 m.dialog.CloseDialog(dialog.PermissionsID)
1164 switch msg.Action {
1165 case dialog.PermissionAllow:
1166 m.com.App.Permissions.Grant(msg.Permission)
1167 case dialog.PermissionAllowForSession:
1168 m.com.App.Permissions.GrantPersistent(msg.Permission)
1169 case dialog.PermissionDeny:
1170 m.com.App.Permissions.Deny(msg.Permission)
1171 }
1172
1173 case dialog.ActionFilePickerSelected:
1174 cmds = append(cmds, tea.Sequence(
1175 msg.Cmd(),
1176 func() tea.Msg {
1177 m.dialog.CloseDialog(dialog.FilePickerID)
1178 return nil
1179 },
1180 ))
1181
1182 case dialog.ActionRunCustomCommand:
1183 if len(msg.Arguments) > 0 && msg.Args == nil {
1184 m.dialog.CloseFrontDialog()
1185 argsDialog := dialog.NewArguments(
1186 m.com,
1187 "Custom Command Arguments",
1188 "",
1189 msg.Arguments,
1190 msg, // Pass the action as the result
1191 )
1192 m.dialog.OpenDialog(argsDialog)
1193 break
1194 }
1195 content := msg.Content
1196 if msg.Args != nil {
1197 content = substituteArgs(content, msg.Args)
1198 }
1199 cmds = append(cmds, m.sendMessage(content))
1200 m.dialog.CloseFrontDialog()
1201 case dialog.ActionRunMCPPrompt:
1202 if len(msg.Arguments) > 0 && msg.Args == nil {
1203 m.dialog.CloseFrontDialog()
1204 title := msg.Title
1205 if title == "" {
1206 title = "MCP Prompt Arguments"
1207 }
1208 argsDialog := dialog.NewArguments(
1209 m.com,
1210 title,
1211 msg.Description,
1212 msg.Arguments,
1213 msg, // Pass the action as the result
1214 )
1215 m.dialog.OpenDialog(argsDialog)
1216 break
1217 }
1218 cmds = append(cmds, m.runMCPPrompt(msg.ClientID, msg.PromptID, msg.Args))
1219 default:
1220 cmds = append(cmds, uiutil.CmdHandler(msg))
1221 }
1222
1223 return tea.Batch(cmds...)
1224}
1225
1226// substituteArgs replaces $ARG_NAME placeholders in content with actual values.
1227func substituteArgs(content string, args map[string]string) string {
1228 for name, value := range args {
1229 placeholder := "$" + name
1230 content = strings.ReplaceAll(content, placeholder, value)
1231 }
1232 return content
1233}
1234
1235func (m *UI) openAuthenticationDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd {
1236 var (
1237 dlg dialog.Dialog
1238 cmd tea.Cmd
1239 )
1240
1241 switch provider.ID {
1242 case "hyper":
1243 dlg, cmd = dialog.NewOAuthHyper(m.com, provider, model, modelType)
1244 case catwalk.InferenceProviderCopilot:
1245 dlg, cmd = dialog.NewOAuthCopilot(m.com, provider, model, modelType)
1246 default:
1247 dlg, cmd = dialog.NewAPIKeyInput(m.com, provider, model, modelType)
1248 }
1249
1250 if m.dialog.ContainsDialog(dlg.ID()) {
1251 m.dialog.BringToFront(dlg.ID())
1252 return nil
1253 }
1254
1255 m.dialog.OpenDialog(dlg)
1256 return cmd
1257}
1258
1259func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
1260 var cmds []tea.Cmd
1261
1262 handleGlobalKeys := func(msg tea.KeyPressMsg) bool {
1263 switch {
1264 case key.Matches(msg, m.keyMap.Help):
1265 m.status.ToggleHelp()
1266 m.updateLayoutAndSize()
1267 return true
1268 case key.Matches(msg, m.keyMap.Commands):
1269 if cmd := m.openCommandsDialog(); cmd != nil {
1270 cmds = append(cmds, cmd)
1271 }
1272 return true
1273 case key.Matches(msg, m.keyMap.Models):
1274 if cmd := m.openModelsDialog(); cmd != nil {
1275 cmds = append(cmds, cmd)
1276 }
1277 return true
1278 case key.Matches(msg, m.keyMap.Sessions):
1279 if cmd := m.openSessionsDialog(); cmd != nil {
1280 cmds = append(cmds, cmd)
1281 }
1282 return true
1283 case key.Matches(msg, m.keyMap.Chat.Details) && m.isCompact:
1284 m.detailsOpen = !m.detailsOpen
1285 m.updateLayoutAndSize()
1286 return true
1287 case key.Matches(msg, m.keyMap.Chat.TogglePills):
1288 if m.state == uiChat && m.hasSession() {
1289 if cmd := m.togglePillsExpanded(); cmd != nil {
1290 cmds = append(cmds, cmd)
1291 }
1292 return true
1293 }
1294 case key.Matches(msg, m.keyMap.Chat.PillLeft):
1295 if m.state == uiChat && m.hasSession() && m.pillsExpanded {
1296 if cmd := m.switchPillSection(-1); cmd != nil {
1297 cmds = append(cmds, cmd)
1298 }
1299 return true
1300 }
1301 case key.Matches(msg, m.keyMap.Chat.PillRight):
1302 if m.state == uiChat && m.hasSession() && m.pillsExpanded {
1303 if cmd := m.switchPillSection(1); cmd != nil {
1304 cmds = append(cmds, cmd)
1305 }
1306 return true
1307 }
1308 }
1309 return false
1310 }
1311
1312 if key.Matches(msg, m.keyMap.Quit) && !m.dialog.ContainsDialog(dialog.QuitID) {
1313 // Always handle quit keys first
1314 if cmd := m.openQuitDialog(); cmd != nil {
1315 cmds = append(cmds, cmd)
1316 }
1317
1318 return tea.Batch(cmds...)
1319 }
1320
1321 // Route all messages to dialog if one is open.
1322 if m.dialog.HasDialogs() {
1323 return m.handleDialogMsg(msg)
1324 }
1325
1326 // Handle cancel key when agent is busy.
1327 if key.Matches(msg, m.keyMap.Chat.Cancel) {
1328 if m.isAgentBusy() {
1329 if cmd := m.cancelAgent(); cmd != nil {
1330 cmds = append(cmds, cmd)
1331 }
1332 return tea.Batch(cmds...)
1333 }
1334 }
1335
1336 switch m.state {
1337 case uiConfigure:
1338 return tea.Batch(cmds...)
1339 case uiInitialize:
1340 cmds = append(cmds, m.updateInitializeView(msg)...)
1341 return tea.Batch(cmds...)
1342 case uiChat, uiLanding:
1343 switch m.focus {
1344 case uiFocusEditor:
1345 // Handle completions if open.
1346 if m.completionsOpen {
1347 if msg, ok := m.completions.Update(msg); ok {
1348 switch msg := msg.(type) {
1349 case completions.SelectionMsg:
1350 // Handle file completion selection.
1351 if item, ok := msg.Value.(completions.FileCompletionValue); ok {
1352 cmds = append(cmds, m.insertFileCompletion(item.Path))
1353 }
1354 if !msg.Insert {
1355 m.closeCompletions()
1356 }
1357 case completions.ClosedMsg:
1358 m.completionsOpen = false
1359 }
1360 return tea.Batch(cmds...)
1361 }
1362 }
1363
1364 if ok := m.attachments.Update(msg); ok {
1365 return tea.Batch(cmds...)
1366 }
1367
1368 switch {
1369 case key.Matches(msg, m.keyMap.Editor.AddImage):
1370 if cmd := m.openFilesDialog(); cmd != nil {
1371 cmds = append(cmds, cmd)
1372 }
1373
1374 case key.Matches(msg, m.keyMap.Editor.SendMessage):
1375 value := m.textarea.Value()
1376 if before, ok := strings.CutSuffix(value, "\\"); ok {
1377 // If the last character is a backslash, remove it and add a newline.
1378 m.textarea.SetValue(before)
1379 break
1380 }
1381
1382 // Otherwise, send the message
1383 m.textarea.Reset()
1384
1385 value = strings.TrimSpace(value)
1386 if value == "exit" || value == "quit" {
1387 return m.openQuitDialog()
1388 }
1389
1390 attachments := m.attachments.List()
1391 m.attachments.Reset()
1392 if len(value) == 0 && !message.ContainsTextAttachment(attachments) {
1393 return nil
1394 }
1395
1396 m.randomizePlaceholders()
1397
1398 return m.sendMessage(value, attachments...)
1399 case key.Matches(msg, m.keyMap.Chat.NewSession):
1400 if !m.hasSession() {
1401 break
1402 }
1403 if m.isAgentBusy() {
1404 cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session..."))
1405 break
1406 }
1407 m.newSession()
1408 case key.Matches(msg, m.keyMap.Tab):
1409 m.focus = uiFocusMain
1410 m.textarea.Blur()
1411 m.chat.Focus()
1412 m.chat.SetSelected(m.chat.Len() - 1)
1413 case key.Matches(msg, m.keyMap.Editor.OpenEditor):
1414 if m.isAgentBusy() {
1415 cmds = append(cmds, uiutil.ReportWarn("Agent is working, please wait..."))
1416 break
1417 }
1418 cmds = append(cmds, m.openEditor(m.textarea.Value()))
1419 case key.Matches(msg, m.keyMap.Editor.Newline):
1420 m.textarea.InsertRune('\n')
1421 m.closeCompletions()
1422 default:
1423 if handleGlobalKeys(msg) {
1424 // Handle global keys first before passing to textarea.
1425 break
1426 }
1427
1428 // Check for @ trigger before passing to textarea.
1429 curValue := m.textarea.Value()
1430 curIdx := len(curValue)
1431
1432 // Trigger completions on @.
1433 if msg.String() == "@" && !m.completionsOpen {
1434 // Only show if beginning of prompt or after whitespace.
1435 if curIdx == 0 || (curIdx > 0 && isWhitespace(curValue[curIdx-1])) {
1436 m.completionsOpen = true
1437 m.completionsQuery = ""
1438 m.completionsStartIndex = curIdx
1439 m.completionsPositionStart = m.completionsPosition()
1440 depth, limit := m.com.Config().Options.TUI.Completions.Limits()
1441 cmds = append(cmds, m.completions.OpenWithFiles(depth, limit))
1442 }
1443 }
1444
1445 // remove the details if they are open when user starts typing
1446 if m.detailsOpen {
1447 m.detailsOpen = false
1448 m.updateLayoutAndSize()
1449 }
1450
1451 ta, cmd := m.textarea.Update(msg)
1452 m.textarea = ta
1453 cmds = append(cmds, cmd)
1454
1455 // After updating textarea, check if we need to filter completions.
1456 // Skip filtering on the initial @ keystroke since items are loading async.
1457 if m.completionsOpen && msg.String() != "@" {
1458 newValue := m.textarea.Value()
1459 newIdx := len(newValue)
1460
1461 // Close completions if cursor moved before start.
1462 if newIdx <= m.completionsStartIndex {
1463 m.closeCompletions()
1464 } else if msg.String() == "space" {
1465 // Close on space.
1466 m.closeCompletions()
1467 } else {
1468 // Extract current word and filter.
1469 word := m.textareaWord()
1470 if strings.HasPrefix(word, "@") {
1471 m.completionsQuery = word[1:]
1472 m.completions.Filter(m.completionsQuery)
1473 } else if m.completionsOpen {
1474 m.closeCompletions()
1475 }
1476 }
1477 }
1478 }
1479 case uiFocusMain:
1480 switch {
1481 case key.Matches(msg, m.keyMap.Tab):
1482 m.focus = uiFocusEditor
1483 cmds = append(cmds, m.textarea.Focus())
1484 m.chat.Blur()
1485 case key.Matches(msg, m.keyMap.Chat.Expand):
1486 m.chat.ToggleExpandedSelectedItem()
1487 case key.Matches(msg, m.keyMap.Chat.Up):
1488 if cmd := m.chat.ScrollByAndAnimate(-1); cmd != nil {
1489 cmds = append(cmds, cmd)
1490 }
1491 if !m.chat.SelectedItemInView() {
1492 m.chat.SelectPrev()
1493 if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1494 cmds = append(cmds, cmd)
1495 }
1496 }
1497 case key.Matches(msg, m.keyMap.Chat.Down):
1498 if cmd := m.chat.ScrollByAndAnimate(1); cmd != nil {
1499 cmds = append(cmds, cmd)
1500 }
1501 if !m.chat.SelectedItemInView() {
1502 m.chat.SelectNext()
1503 if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1504 cmds = append(cmds, cmd)
1505 }
1506 }
1507 case key.Matches(msg, m.keyMap.Chat.UpOneItem):
1508 m.chat.SelectPrev()
1509 if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1510 cmds = append(cmds, cmd)
1511 }
1512 case key.Matches(msg, m.keyMap.Chat.DownOneItem):
1513 m.chat.SelectNext()
1514 if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1515 cmds = append(cmds, cmd)
1516 }
1517 case key.Matches(msg, m.keyMap.Chat.HalfPageUp):
1518 if cmd := m.chat.ScrollByAndAnimate(-m.chat.Height() / 2); cmd != nil {
1519 cmds = append(cmds, cmd)
1520 }
1521 m.chat.SelectFirstInView()
1522 case key.Matches(msg, m.keyMap.Chat.HalfPageDown):
1523 if cmd := m.chat.ScrollByAndAnimate(m.chat.Height() / 2); cmd != nil {
1524 cmds = append(cmds, cmd)
1525 }
1526 m.chat.SelectLastInView()
1527 case key.Matches(msg, m.keyMap.Chat.PageUp):
1528 if cmd := m.chat.ScrollByAndAnimate(-m.chat.Height()); cmd != nil {
1529 cmds = append(cmds, cmd)
1530 }
1531 m.chat.SelectFirstInView()
1532 case key.Matches(msg, m.keyMap.Chat.PageDown):
1533 if cmd := m.chat.ScrollByAndAnimate(m.chat.Height()); cmd != nil {
1534 cmds = append(cmds, cmd)
1535 }
1536 m.chat.SelectLastInView()
1537 case key.Matches(msg, m.keyMap.Chat.Home):
1538 if cmd := m.chat.ScrollToTopAndAnimate(); cmd != nil {
1539 cmds = append(cmds, cmd)
1540 }
1541 m.chat.SelectFirst()
1542 case key.Matches(msg, m.keyMap.Chat.End):
1543 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
1544 cmds = append(cmds, cmd)
1545 }
1546 m.chat.SelectLast()
1547 default:
1548 handleGlobalKeys(msg)
1549 }
1550 default:
1551 handleGlobalKeys(msg)
1552 }
1553 default:
1554 handleGlobalKeys(msg)
1555 }
1556
1557 return tea.Batch(cmds...)
1558}
1559
1560// Draw implements [uv.Drawable] and draws the UI model.
1561func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
1562 layout := m.generateLayout(area.Dx(), area.Dy())
1563
1564 if m.layout != layout {
1565 m.layout = layout
1566 m.updateSize()
1567 }
1568
1569 // Clear the screen first
1570 screen.Clear(scr)
1571
1572 switch m.state {
1573 case uiConfigure:
1574 header := uv.NewStyledString(m.header)
1575 header.Draw(scr, layout.header)
1576
1577 mainView := lipgloss.NewStyle().Width(layout.main.Dx()).
1578 Height(layout.main.Dy()).
1579 Background(lipgloss.ANSIColor(rand.Intn(256))).
1580 Render(" Configure ")
1581 main := uv.NewStyledString(mainView)
1582 main.Draw(scr, layout.main)
1583
1584 case uiInitialize:
1585 header := uv.NewStyledString(m.header)
1586 header.Draw(scr, layout.header)
1587
1588 main := uv.NewStyledString(m.initializeView())
1589 main.Draw(scr, layout.main)
1590
1591 case uiLanding:
1592 header := uv.NewStyledString(m.header)
1593 header.Draw(scr, layout.header)
1594 main := uv.NewStyledString(m.landingView())
1595 main.Draw(scr, layout.main)
1596
1597 editor := uv.NewStyledString(m.renderEditorView(scr.Bounds().Dx()))
1598 editor.Draw(scr, layout.editor)
1599
1600 case uiChat:
1601 if m.isCompact {
1602 header := uv.NewStyledString(m.header)
1603 header.Draw(scr, layout.header)
1604 } else {
1605 m.drawSidebar(scr, layout.sidebar)
1606 }
1607
1608 m.chat.Draw(scr, layout.main)
1609 if layout.pills.Dy() > 0 && m.pillsView != "" {
1610 uv.NewStyledString(m.pillsView).Draw(scr, layout.pills)
1611 }
1612
1613 editorWidth := scr.Bounds().Dx()
1614 if !m.isCompact {
1615 editorWidth -= layout.sidebar.Dx()
1616 }
1617 editor := uv.NewStyledString(m.renderEditorView(editorWidth))
1618 editor.Draw(scr, layout.editor)
1619
1620 // Draw details overlay in compact mode when open
1621 if m.isCompact && m.detailsOpen {
1622 m.drawSessionDetails(scr, layout.sessionDetails)
1623 }
1624 }
1625
1626 // Add status and help layer
1627 m.status.Draw(scr, layout.status)
1628
1629 // Draw completions popup if open
1630 if m.completionsOpen && m.completions.HasItems() {
1631 w, h := m.completions.Size()
1632 x := m.completionsPositionStart.X
1633 y := m.completionsPositionStart.Y - h
1634
1635 screenW := area.Dx()
1636 if x+w > screenW {
1637 x = screenW - w
1638 }
1639 x = max(0, x)
1640 y = max(0, y)
1641
1642 completionsView := uv.NewStyledString(m.completions.Render())
1643 completionsView.Draw(scr, image.Rectangle{
1644 Min: image.Pt(x, y),
1645 Max: image.Pt(x+w, y+h),
1646 })
1647 }
1648
1649 // Debugging rendering (visually see when the tui rerenders)
1650 if os.Getenv("CRUSH_UI_DEBUG") == "true" {
1651 debugView := lipgloss.NewStyle().Background(lipgloss.ANSIColor(rand.Intn(256))).Width(4).Height(2)
1652 debug := uv.NewStyledString(debugView.String())
1653 debug.Draw(scr, image.Rectangle{
1654 Min: image.Pt(4, 1),
1655 Max: image.Pt(8, 3),
1656 })
1657 }
1658
1659 // This needs to come last to overlay on top of everything. We always pass
1660 // the full screen bounds because the dialogs will position themselves
1661 // accordingly.
1662 if m.dialog.HasDialogs() {
1663 return m.dialog.Draw(scr, scr.Bounds())
1664 }
1665
1666 switch m.focus {
1667 case uiFocusEditor:
1668 if m.layout.editor.Dy() <= 0 {
1669 // Don't show cursor if editor is not visible
1670 return nil
1671 }
1672 if m.detailsOpen && m.isCompact {
1673 // Don't show cursor if details overlay is open
1674 return nil
1675 }
1676
1677 if m.textarea.Focused() {
1678 cur := m.textarea.Cursor()
1679 cur.X++ // Adjust for app margins
1680 cur.Y += m.layout.editor.Min.Y
1681 // Offset for attachment row if present.
1682 if len(m.attachments.List()) > 0 {
1683 cur.Y++
1684 }
1685 return cur
1686 }
1687 }
1688 return nil
1689}
1690
1691// View renders the UI model's view.
1692func (m *UI) View() tea.View {
1693 var v tea.View
1694 v.AltScreen = true
1695 v.BackgroundColor = m.com.Styles.Background
1696 v.MouseMode = tea.MouseModeCellMotion
1697
1698 canvas := uv.NewScreenBuffer(m.width, m.height)
1699 v.Cursor = m.Draw(canvas, canvas.Bounds())
1700
1701 content := strings.ReplaceAll(canvas.Render(), "\r\n", "\n") // normalize newlines
1702 contentLines := strings.Split(content, "\n")
1703 for i, line := range contentLines {
1704 // Trim trailing spaces for concise rendering
1705 contentLines[i] = strings.TrimRight(line, " ")
1706 }
1707
1708 content = strings.Join(contentLines, "\n")
1709
1710 v.Content = content
1711 if m.sendProgressBar && m.isAgentBusy() {
1712 // HACK: use a random percentage to prevent ghostty from hiding it
1713 // after a timeout.
1714 v.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
1715 }
1716
1717 return v
1718}
1719
1720// ShortHelp implements [help.KeyMap].
1721func (m *UI) ShortHelp() []key.Binding {
1722 var binds []key.Binding
1723 k := &m.keyMap
1724 tab := k.Tab
1725 commands := k.Commands
1726 if m.focus == uiFocusEditor && m.textarea.LineCount() == 0 {
1727 commands.SetHelp("/ or ctrl+p", "commands")
1728 }
1729
1730 switch m.state {
1731 case uiInitialize:
1732 binds = append(binds, k.Quit)
1733 case uiChat:
1734 // Show cancel binding if agent is busy.
1735 if m.isAgentBusy() {
1736 cancelBinding := k.Chat.Cancel
1737 if m.isCanceling {
1738 cancelBinding.SetHelp("esc", "press again to cancel")
1739 } else if m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID) > 0 {
1740 cancelBinding.SetHelp("esc", "clear queue")
1741 }
1742 binds = append(binds, cancelBinding)
1743 }
1744
1745 if m.focus == uiFocusEditor {
1746 tab.SetHelp("tab", "focus chat")
1747 } else {
1748 tab.SetHelp("tab", "focus editor")
1749 }
1750
1751 binds = append(binds,
1752 tab,
1753 commands,
1754 k.Models,
1755 )
1756
1757 switch m.focus {
1758 case uiFocusEditor:
1759 binds = append(binds,
1760 k.Editor.Newline,
1761 )
1762 case uiFocusMain:
1763 binds = append(binds,
1764 k.Chat.UpDown,
1765 k.Chat.UpDownOneItem,
1766 k.Chat.PageUp,
1767 k.Chat.PageDown,
1768 k.Chat.Copy,
1769 )
1770 if m.pillsExpanded && hasIncompleteTodos(m.session.Todos) && m.promptQueue > 0 {
1771 binds = append(binds, k.Chat.PillLeft)
1772 }
1773 }
1774 default:
1775 // TODO: other states
1776 // if m.session == nil {
1777 // no session selected
1778 binds = append(binds,
1779 commands,
1780 k.Models,
1781 k.Editor.Newline,
1782 )
1783 }
1784
1785 binds = append(binds,
1786 k.Quit,
1787 k.Help,
1788 )
1789
1790 return binds
1791}
1792
1793// FullHelp implements [help.KeyMap].
1794func (m *UI) FullHelp() [][]key.Binding {
1795 var binds [][]key.Binding
1796 k := &m.keyMap
1797 help := k.Help
1798 help.SetHelp("ctrl+g", "less")
1799 hasAttachments := len(m.attachments.List()) > 0
1800 hasSession := m.hasSession()
1801 commands := k.Commands
1802 if m.focus == uiFocusEditor && m.textarea.LineCount() == 0 {
1803 commands.SetHelp("/ or ctrl+p", "commands")
1804 }
1805
1806 switch m.state {
1807 case uiInitialize:
1808 binds = append(binds,
1809 []key.Binding{
1810 k.Quit,
1811 })
1812 case uiChat:
1813 // Show cancel binding if agent is busy.
1814 if m.isAgentBusy() {
1815 cancelBinding := k.Chat.Cancel
1816 if m.isCanceling {
1817 cancelBinding.SetHelp("esc", "press again to cancel")
1818 } else if m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID) > 0 {
1819 cancelBinding.SetHelp("esc", "clear queue")
1820 }
1821 binds = append(binds, []key.Binding{cancelBinding})
1822 }
1823
1824 mainBinds := []key.Binding{}
1825 tab := k.Tab
1826 if m.focus == uiFocusEditor {
1827 tab.SetHelp("tab", "focus chat")
1828 } else {
1829 tab.SetHelp("tab", "focus editor")
1830 }
1831
1832 mainBinds = append(mainBinds,
1833 tab,
1834 commands,
1835 k.Models,
1836 k.Sessions,
1837 )
1838 if hasSession {
1839 mainBinds = append(mainBinds, k.Chat.NewSession)
1840 }
1841
1842 binds = append(binds, mainBinds)
1843
1844 switch m.focus {
1845 case uiFocusEditor:
1846 binds = append(binds,
1847 []key.Binding{
1848 k.Editor.Newline,
1849 k.Editor.AddImage,
1850 k.Editor.MentionFile,
1851 k.Editor.OpenEditor,
1852 },
1853 )
1854 if hasAttachments {
1855 binds = append(binds,
1856 []key.Binding{
1857 k.Editor.AttachmentDeleteMode,
1858 k.Editor.DeleteAllAttachments,
1859 k.Editor.Escape,
1860 },
1861 )
1862 }
1863 case uiFocusMain:
1864 binds = append(binds,
1865 []key.Binding{
1866 k.Chat.UpDown,
1867 k.Chat.UpDownOneItem,
1868 k.Chat.PageUp,
1869 k.Chat.PageDown,
1870 },
1871 []key.Binding{
1872 k.Chat.HalfPageUp,
1873 k.Chat.HalfPageDown,
1874 k.Chat.Home,
1875 k.Chat.End,
1876 },
1877 []key.Binding{
1878 k.Chat.Copy,
1879 k.Chat.ClearHighlight,
1880 },
1881 )
1882 if m.pillsExpanded && hasIncompleteTodos(m.session.Todos) && m.promptQueue > 0 {
1883 binds = append(binds, []key.Binding{k.Chat.PillLeft})
1884 }
1885 }
1886 default:
1887 if m.session == nil {
1888 // no session selected
1889 binds = append(binds,
1890 []key.Binding{
1891 commands,
1892 k.Models,
1893 k.Sessions,
1894 },
1895 []key.Binding{
1896 k.Editor.Newline,
1897 k.Editor.AddImage,
1898 k.Editor.MentionFile,
1899 k.Editor.OpenEditor,
1900 },
1901 )
1902 if hasAttachments {
1903 binds = append(binds,
1904 []key.Binding{
1905 k.Editor.AttachmentDeleteMode,
1906 k.Editor.DeleteAllAttachments,
1907 k.Editor.Escape,
1908 },
1909 )
1910 }
1911 binds = append(binds,
1912 []key.Binding{
1913 help,
1914 },
1915 )
1916 }
1917 }
1918
1919 binds = append(binds,
1920 []key.Binding{
1921 help,
1922 k.Quit,
1923 },
1924 )
1925
1926 return binds
1927}
1928
1929// toggleCompactMode toggles compact mode between uiChat and uiChatCompact states.
1930func (m *UI) toggleCompactMode() tea.Cmd {
1931 m.forceCompactMode = !m.forceCompactMode
1932
1933 err := m.com.Config().SetCompactMode(m.forceCompactMode)
1934 if err != nil {
1935 return uiutil.ReportError(err)
1936 }
1937
1938 m.handleCompactMode(m.width, m.height)
1939 m.updateLayoutAndSize()
1940
1941 return nil
1942}
1943
1944// handleCompactMode updates the UI state based on window size and compact mode setting.
1945func (m *UI) handleCompactMode(newWidth, newHeight int) {
1946 if m.state == uiChat {
1947 if m.forceCompactMode {
1948 m.isCompact = true
1949 return
1950 }
1951 if newWidth < compactModeWidthBreakpoint || newHeight < compactModeHeightBreakpoint {
1952 m.isCompact = true
1953 } else {
1954 m.isCompact = false
1955 }
1956 }
1957}
1958
1959// updateLayoutAndSize updates the layout and sizes of UI components.
1960func (m *UI) updateLayoutAndSize() {
1961 m.layout = m.generateLayout(m.width, m.height)
1962 m.updateSize()
1963}
1964
1965// updateSize updates the sizes of UI components based on the current layout.
1966func (m *UI) updateSize() {
1967 // Set status width
1968 m.status.SetWidth(m.layout.status.Dx())
1969
1970 m.chat.SetSize(m.layout.main.Dx(), m.layout.main.Dy())
1971 m.textarea.SetWidth(m.layout.editor.Dx())
1972 m.textarea.SetHeight(m.layout.editor.Dy())
1973 m.renderPills()
1974
1975 // Handle different app states
1976 switch m.state {
1977 case uiConfigure, uiInitialize, uiLanding:
1978 m.renderHeader(false, m.layout.header.Dx())
1979
1980 case uiChat:
1981 if m.isCompact {
1982 m.renderHeader(true, m.layout.header.Dx())
1983 } else {
1984 m.renderSidebarLogo(m.layout.sidebar.Dx())
1985 }
1986 }
1987}
1988
1989// generateLayout calculates the layout rectangles for all UI components based
1990// on the current UI state and terminal dimensions.
1991func (m *UI) generateLayout(w, h int) layout {
1992 // The screen area we're working with
1993 area := image.Rect(0, 0, w, h)
1994
1995 // The help height
1996 helpHeight := 1
1997 // The editor height
1998 editorHeight := 5
1999 // The sidebar width
2000 sidebarWidth := 30
2001 // The header height
2002 const landingHeaderHeight = 4
2003
2004 var helpKeyMap help.KeyMap = m
2005 if m.status.ShowingAll() {
2006 for _, row := range helpKeyMap.FullHelp() {
2007 helpHeight = max(helpHeight, len(row))
2008 }
2009 }
2010
2011 // Add app margins
2012 appRect, helpRect := uv.SplitVertical(area, uv.Fixed(area.Dy()-helpHeight))
2013 appRect.Min.Y += 1
2014 appRect.Max.Y -= 1
2015 helpRect.Min.Y -= 1
2016 appRect.Min.X += 1
2017 appRect.Max.X -= 1
2018
2019 if slices.Contains([]uiState{uiConfigure, uiInitialize, uiLanding}, m.state) {
2020 // extra padding on left and right for these states
2021 appRect.Min.X += 1
2022 appRect.Max.X -= 1
2023 }
2024
2025 layout := layout{
2026 area: area,
2027 status: helpRect,
2028 }
2029
2030 // Handle different app states
2031 switch m.state {
2032 case uiConfigure, uiInitialize:
2033 // Layout
2034 //
2035 // header
2036 // ------
2037 // main
2038 // ------
2039 // help
2040
2041 headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(landingHeaderHeight))
2042 layout.header = headerRect
2043 layout.main = mainRect
2044
2045 case uiLanding:
2046 // Layout
2047 //
2048 // header
2049 // ------
2050 // main
2051 // ------
2052 // editor
2053 // ------
2054 // help
2055 headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(landingHeaderHeight))
2056 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
2057 // Remove extra padding from editor (but keep it for header and main)
2058 editorRect.Min.X -= 1
2059 editorRect.Max.X += 1
2060 layout.header = headerRect
2061 layout.main = mainRect
2062 layout.editor = editorRect
2063
2064 case uiChat:
2065 if m.isCompact {
2066 // Layout
2067 //
2068 // compact-header
2069 // ------
2070 // main
2071 // ------
2072 // editor
2073 // ------
2074 // help
2075 const compactHeaderHeight = 1
2076 headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(compactHeaderHeight))
2077 detailsHeight := min(sessionDetailsMaxHeight, area.Dy()-1) // One row for the header
2078 sessionDetailsArea, _ := uv.SplitVertical(appRect, uv.Fixed(detailsHeight))
2079 layout.sessionDetails = sessionDetailsArea
2080 layout.sessionDetails.Min.Y += compactHeaderHeight // adjust for header
2081 // Add one line gap between header and main content
2082 mainRect.Min.Y += 1
2083 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
2084 mainRect.Max.X -= 1 // Add padding right
2085 layout.header = headerRect
2086 pillsHeight := m.pillsAreaHeight()
2087 if pillsHeight > 0 {
2088 pillsHeight = min(pillsHeight, mainRect.Dy())
2089 chatRect, pillsRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-pillsHeight))
2090 layout.main = chatRect
2091 layout.pills = pillsRect
2092 } else {
2093 layout.main = mainRect
2094 }
2095 // Add bottom margin to main
2096 layout.main.Max.Y -= 1
2097 layout.editor = editorRect
2098 } else {
2099 // Layout
2100 //
2101 // ------|---
2102 // main |
2103 // ------| side
2104 // editor|
2105 // ----------
2106 // help
2107
2108 mainRect, sideRect := uv.SplitHorizontal(appRect, uv.Fixed(appRect.Dx()-sidebarWidth))
2109 // Add padding left
2110 sideRect.Min.X += 1
2111 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
2112 mainRect.Max.X -= 1 // Add padding right
2113 layout.sidebar = sideRect
2114 pillsHeight := m.pillsAreaHeight()
2115 if pillsHeight > 0 {
2116 pillsHeight = min(pillsHeight, mainRect.Dy())
2117 chatRect, pillsRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-pillsHeight))
2118 layout.main = chatRect
2119 layout.pills = pillsRect
2120 } else {
2121 layout.main = mainRect
2122 }
2123 // Add bottom margin to main
2124 layout.main.Max.Y -= 1
2125 layout.editor = editorRect
2126 }
2127 }
2128
2129 if !layout.editor.Empty() {
2130 // Add editor margins 1 top and bottom
2131 layout.editor.Min.Y += 1
2132 layout.editor.Max.Y -= 1
2133 }
2134
2135 return layout
2136}
2137
2138// layout defines the positioning of UI elements.
2139type layout struct {
2140 // area is the overall available area.
2141 area uv.Rectangle
2142
2143 // header is the header shown in special cases
2144 // e.x when the sidebar is collapsed
2145 // or when in the landing page
2146 // or in init/config
2147 header uv.Rectangle
2148
2149 // main is the area for the main pane. (e.x chat, configure, landing)
2150 main uv.Rectangle
2151
2152 // pills is the area for the pills panel.
2153 pills uv.Rectangle
2154
2155 // editor is the area for the editor pane.
2156 editor uv.Rectangle
2157
2158 // sidebar is the area for the sidebar.
2159 sidebar uv.Rectangle
2160
2161 // status is the area for the status view.
2162 status uv.Rectangle
2163
2164 // session details is the area for the session details overlay in compact mode.
2165 sessionDetails uv.Rectangle
2166}
2167
2168func (m *UI) openEditor(value string) tea.Cmd {
2169 tmpfile, err := os.CreateTemp("", "msg_*.md")
2170 if err != nil {
2171 return uiutil.ReportError(err)
2172 }
2173 defer tmpfile.Close() //nolint:errcheck
2174 if _, err := tmpfile.WriteString(value); err != nil {
2175 return uiutil.ReportError(err)
2176 }
2177 cmd, err := editor.Command(
2178 "crush",
2179 tmpfile.Name(),
2180 editor.AtPosition(
2181 m.textarea.Line()+1,
2182 m.textarea.Column()+1,
2183 ),
2184 )
2185 if err != nil {
2186 return uiutil.ReportError(err)
2187 }
2188 return tea.ExecProcess(cmd, func(err error) tea.Msg {
2189 if err != nil {
2190 return uiutil.ReportError(err)
2191 }
2192 content, err := os.ReadFile(tmpfile.Name())
2193 if err != nil {
2194 return uiutil.ReportError(err)
2195 }
2196 if len(content) == 0 {
2197 return uiutil.ReportWarn("Message is empty")
2198 }
2199 os.Remove(tmpfile.Name())
2200 return openEditorMsg{
2201 Text: strings.TrimSpace(string(content)),
2202 }
2203 })
2204}
2205
2206// setEditorPrompt configures the textarea prompt function based on whether
2207// yolo mode is enabled.
2208func (m *UI) setEditorPrompt(yolo bool) {
2209 if yolo {
2210 m.textarea.SetPromptFunc(4, m.yoloPromptFunc)
2211 return
2212 }
2213 m.textarea.SetPromptFunc(4, m.normalPromptFunc)
2214}
2215
2216// normalPromptFunc returns the normal editor prompt style (" > " on first
2217// line, "::: " on subsequent lines).
2218func (m *UI) normalPromptFunc(info textarea.PromptInfo) string {
2219 t := m.com.Styles
2220 if info.LineNumber == 0 {
2221 if info.Focused {
2222 return " > "
2223 }
2224 return "::: "
2225 }
2226 if info.Focused {
2227 return t.EditorPromptNormalFocused.Render()
2228 }
2229 return t.EditorPromptNormalBlurred.Render()
2230}
2231
2232// yoloPromptFunc returns the yolo mode editor prompt style with warning icon
2233// and colored dots.
2234func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string {
2235 t := m.com.Styles
2236 if info.LineNumber == 0 {
2237 if info.Focused {
2238 return t.EditorPromptYoloIconFocused.Render()
2239 } else {
2240 return t.EditorPromptYoloIconBlurred.Render()
2241 }
2242 }
2243 if info.Focused {
2244 return t.EditorPromptYoloDotsFocused.Render()
2245 }
2246 return t.EditorPromptYoloDotsBlurred.Render()
2247}
2248
2249// closeCompletions closes the completions popup and resets state.
2250func (m *UI) closeCompletions() {
2251 m.completionsOpen = false
2252 m.completionsQuery = ""
2253 m.completionsStartIndex = 0
2254 m.completions.Close()
2255}
2256
2257// insertFileCompletion inserts the selected file path into the textarea,
2258// replacing the @query, and adds the file as an attachment.
2259func (m *UI) insertFileCompletion(path string) tea.Cmd {
2260 value := m.textarea.Value()
2261 word := m.textareaWord()
2262
2263 // Find the @ and query to replace.
2264 if m.completionsStartIndex > len(value) {
2265 return nil
2266 }
2267
2268 // Build the new value: everything before @, the path, everything after query.
2269 endIdx := min(m.completionsStartIndex+len(word), len(value))
2270
2271 newValue := value[:m.completionsStartIndex] + path + value[endIdx:]
2272 m.textarea.SetValue(newValue)
2273 m.textarea.MoveToEnd()
2274 m.textarea.InsertRune(' ')
2275
2276 return func() tea.Msg {
2277 absPath, _ := filepath.Abs(path)
2278 // Skip attachment if file was already read and hasn't been modified.
2279 lastRead := filetracker.LastReadTime(absPath)
2280 if !lastRead.IsZero() {
2281 if info, err := os.Stat(path); err == nil && !info.ModTime().After(lastRead) {
2282 return nil
2283 }
2284 }
2285
2286 // Add file as attachment.
2287 content, err := os.ReadFile(path)
2288 if err != nil {
2289 // If it fails, let the LLM handle it later.
2290 return nil
2291 }
2292 filetracker.RecordRead(absPath)
2293
2294 return message.Attachment{
2295 FilePath: path,
2296 FileName: filepath.Base(path),
2297 MimeType: mimeOf(content),
2298 Content: content,
2299 }
2300 }
2301}
2302
2303// completionsPosition returns the X and Y position for the completions popup.
2304func (m *UI) completionsPosition() image.Point {
2305 cur := m.textarea.Cursor()
2306 if cur == nil {
2307 return image.Point{
2308 X: m.layout.editor.Min.X,
2309 Y: m.layout.editor.Min.Y,
2310 }
2311 }
2312 return image.Point{
2313 X: cur.X + m.layout.editor.Min.X,
2314 Y: m.layout.editor.Min.Y + cur.Y,
2315 }
2316}
2317
2318// textareaWord returns the current word at the cursor position.
2319func (m *UI) textareaWord() string {
2320 return m.textarea.Word()
2321}
2322
2323// isWhitespace returns true if the byte is a whitespace character.
2324func isWhitespace(b byte) bool {
2325 return b == ' ' || b == '\t' || b == '\n' || b == '\r'
2326}
2327
2328// isAgentBusy returns true if the agent coordinator exists and is currently
2329// busy processing a request.
2330func (m *UI) isAgentBusy() bool {
2331 return m.com.App != nil &&
2332 m.com.App.AgentCoordinator != nil &&
2333 m.com.App.AgentCoordinator.IsBusy()
2334}
2335
2336// hasSession returns true if there is an active session with a valid ID.
2337func (m *UI) hasSession() bool {
2338 return m.session != nil && m.session.ID != ""
2339}
2340
2341// mimeOf detects the MIME type of the given content.
2342func mimeOf(content []byte) string {
2343 mimeBufferSize := min(512, len(content))
2344 return http.DetectContentType(content[:mimeBufferSize])
2345}
2346
2347var readyPlaceholders = [...]string{
2348 "Ready!",
2349 "Ready...",
2350 "Ready?",
2351 "Ready for instructions",
2352}
2353
2354var workingPlaceholders = [...]string{
2355 "Working!",
2356 "Working...",
2357 "Brrrrr...",
2358 "Prrrrrrrr...",
2359 "Processing...",
2360 "Thinking...",
2361}
2362
2363// randomizePlaceholders selects random placeholder text for the textarea's
2364// ready and working states.
2365func (m *UI) randomizePlaceholders() {
2366 m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
2367 m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
2368}
2369
2370// renderEditorView renders the editor view with attachments if any.
2371func (m *UI) renderEditorView(width int) string {
2372 if len(m.attachments.List()) == 0 {
2373 return m.textarea.View()
2374 }
2375 return lipgloss.JoinVertical(
2376 lipgloss.Top,
2377 m.attachments.Render(width),
2378 m.textarea.View(),
2379 )
2380}
2381
2382// renderHeader renders and caches the header logo at the specified width.
2383func (m *UI) renderHeader(compact bool, width int) {
2384 if compact && m.session != nil && m.com.App != nil {
2385 m.header = renderCompactHeader(m.com, m.session, m.com.App.LSPClients, m.detailsOpen, width)
2386 } else {
2387 m.header = renderLogo(m.com.Styles, compact, width)
2388 }
2389}
2390
2391// renderSidebarLogo renders and caches the sidebar logo at the specified
2392// width.
2393func (m *UI) renderSidebarLogo(width int) {
2394 m.sidebarLogo = renderLogo(m.com.Styles, true, width)
2395}
2396
2397// sendMessage sends a message with the given content and attachments.
2398func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.Cmd {
2399 if m.com.App.AgentCoordinator == nil {
2400 return uiutil.ReportError(fmt.Errorf("coder agent is not initialized"))
2401 }
2402
2403 var cmds []tea.Cmd
2404 if !m.hasSession() {
2405 newSession, err := m.com.App.Sessions.Create(context.Background(), "New Session")
2406 if err != nil {
2407 return uiutil.ReportError(err)
2408 }
2409 m.state = uiChat
2410 if m.forceCompactMode {
2411 m.isCompact = true
2412 }
2413 if newSession.ID != "" {
2414 m.session = &newSession
2415 cmds = append(cmds, m.loadSession(newSession.ID))
2416 }
2417 }
2418
2419 // Capture session ID to avoid race with main goroutine updating m.session.
2420 sessionID := m.session.ID
2421 cmds = append(cmds, func() tea.Msg {
2422 _, err := m.com.App.AgentCoordinator.Run(context.Background(), sessionID, content, attachments...)
2423 if err != nil {
2424 isCancelErr := errors.Is(err, context.Canceled)
2425 isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)
2426 if isCancelErr || isPermissionErr {
2427 return nil
2428 }
2429 return uiutil.InfoMsg{
2430 Type: uiutil.InfoTypeError,
2431 Msg: err.Error(),
2432 }
2433 }
2434 return nil
2435 })
2436 return tea.Batch(cmds...)
2437}
2438
2439const cancelTimerDuration = 2 * time.Second
2440
2441// cancelTimerCmd creates a command that expires the cancel timer.
2442func cancelTimerCmd() tea.Cmd {
2443 return tea.Tick(cancelTimerDuration, func(time.Time) tea.Msg {
2444 return cancelTimerExpiredMsg{}
2445 })
2446}
2447
2448// cancelAgent handles the cancel key press. The first press sets isCanceling to true
2449// and starts a timer. The second press (before the timer expires) actually
2450// cancels the agent.
2451func (m *UI) cancelAgent() tea.Cmd {
2452 if !m.hasSession() {
2453 return nil
2454 }
2455
2456 coordinator := m.com.App.AgentCoordinator
2457 if coordinator == nil {
2458 return nil
2459 }
2460
2461 if m.isCanceling {
2462 // Second escape press - actually cancel the agent.
2463 m.isCanceling = false
2464 coordinator.Cancel(m.session.ID)
2465 // Stop the spinning todo indicator.
2466 m.todoIsSpinning = false
2467 m.renderPills()
2468 return nil
2469 }
2470
2471 // Check if there are queued prompts - if so, clear the queue.
2472 if coordinator.QueuedPrompts(m.session.ID) > 0 {
2473 coordinator.ClearQueue(m.session.ID)
2474 return nil
2475 }
2476
2477 // First escape press - set canceling state and start timer.
2478 m.isCanceling = true
2479 return cancelTimerCmd()
2480}
2481
2482// openDialog opens a dialog by its ID.
2483func (m *UI) openDialog(id string) tea.Cmd {
2484 var cmds []tea.Cmd
2485 switch id {
2486 case dialog.SessionsID:
2487 if cmd := m.openSessionsDialog(); cmd != nil {
2488 cmds = append(cmds, cmd)
2489 }
2490 case dialog.ModelsID:
2491 if cmd := m.openModelsDialog(); cmd != nil {
2492 cmds = append(cmds, cmd)
2493 }
2494 case dialog.CommandsID:
2495 if cmd := m.openCommandsDialog(); cmd != nil {
2496 cmds = append(cmds, cmd)
2497 }
2498 case dialog.ReasoningID:
2499 if cmd := m.openReasoningDialog(); cmd != nil {
2500 cmds = append(cmds, cmd)
2501 }
2502 case dialog.QuitID:
2503 if cmd := m.openQuitDialog(); cmd != nil {
2504 cmds = append(cmds, cmd)
2505 }
2506 default:
2507 // Unknown dialog
2508 break
2509 }
2510 return tea.Batch(cmds...)
2511}
2512
2513// openQuitDialog opens the quit confirmation dialog.
2514func (m *UI) openQuitDialog() tea.Cmd {
2515 if m.dialog.ContainsDialog(dialog.QuitID) {
2516 // Bring to front
2517 m.dialog.BringToFront(dialog.QuitID)
2518 return nil
2519 }
2520
2521 quitDialog := dialog.NewQuit(m.com)
2522 m.dialog.OpenDialog(quitDialog)
2523 return nil
2524}
2525
2526// openModelsDialog opens the models dialog.
2527func (m *UI) openModelsDialog() tea.Cmd {
2528 if m.dialog.ContainsDialog(dialog.ModelsID) {
2529 // Bring to front
2530 m.dialog.BringToFront(dialog.ModelsID)
2531 return nil
2532 }
2533
2534 modelsDialog, err := dialog.NewModels(m.com)
2535 if err != nil {
2536 return uiutil.ReportError(err)
2537 }
2538
2539 m.dialog.OpenDialog(modelsDialog)
2540
2541 return nil
2542}
2543
2544// openCommandsDialog opens the commands dialog.
2545func (m *UI) openCommandsDialog() tea.Cmd {
2546 if m.dialog.ContainsDialog(dialog.CommandsID) {
2547 // Bring to front
2548 m.dialog.BringToFront(dialog.CommandsID)
2549 return nil
2550 }
2551
2552 sessionID := ""
2553 if m.session != nil {
2554 sessionID = m.session.ID
2555 }
2556
2557 commands, err := dialog.NewCommands(m.com, sessionID, m.customCommands, m.mcpPrompts)
2558 if err != nil {
2559 return uiutil.ReportError(err)
2560 }
2561
2562 m.dialog.OpenDialog(commands)
2563
2564 return nil
2565}
2566
2567// openReasoningDialog opens the reasoning effort dialog.
2568func (m *UI) openReasoningDialog() tea.Cmd {
2569 if m.dialog.ContainsDialog(dialog.ReasoningID) {
2570 m.dialog.BringToFront(dialog.ReasoningID)
2571 return nil
2572 }
2573
2574 reasoningDialog, err := dialog.NewReasoning(m.com)
2575 if err != nil {
2576 return uiutil.ReportError(err)
2577 }
2578
2579 m.dialog.OpenDialog(reasoningDialog)
2580 return nil
2581}
2582
2583// openSessionsDialog opens the sessions dialog. If the dialog is already open,
2584// it brings it to the front. Otherwise, it will list all the sessions and open
2585// the dialog.
2586func (m *UI) openSessionsDialog() tea.Cmd {
2587 if m.dialog.ContainsDialog(dialog.SessionsID) {
2588 // Bring to front
2589 m.dialog.BringToFront(dialog.SessionsID)
2590 return nil
2591 }
2592
2593 selectedSessionID := ""
2594 if m.session != nil {
2595 selectedSessionID = m.session.ID
2596 }
2597
2598 dialog, err := dialog.NewSessions(m.com, selectedSessionID)
2599 if err != nil {
2600 return uiutil.ReportError(err)
2601 }
2602
2603 m.dialog.OpenDialog(dialog)
2604 return nil
2605}
2606
2607// openFilesDialog opens the file picker dialog.
2608func (m *UI) openFilesDialog() tea.Cmd {
2609 if m.dialog.ContainsDialog(dialog.FilePickerID) {
2610 // Bring to front
2611 m.dialog.BringToFront(dialog.FilePickerID)
2612 return nil
2613 }
2614
2615 filePicker, cmd := dialog.NewFilePicker(m.com)
2616 filePicker.SetImageCapabilities(&m.imgCaps)
2617 m.dialog.OpenDialog(filePicker)
2618
2619 return cmd
2620}
2621
2622// openPermissionsDialog opens the permissions dialog for a permission request.
2623func (m *UI) openPermissionsDialog(perm permission.PermissionRequest) tea.Cmd {
2624 // Close any existing permissions dialog first.
2625 m.dialog.CloseDialog(dialog.PermissionsID)
2626
2627 // Get diff mode from config.
2628 var opts []dialog.PermissionsOption
2629 if diffMode := m.com.Config().Options.TUI.DiffMode; diffMode != "" {
2630 opts = append(opts, dialog.WithDiffMode(diffMode == "split"))
2631 }
2632
2633 permDialog := dialog.NewPermissions(m.com, perm, opts...)
2634 m.dialog.OpenDialog(permDialog)
2635 return nil
2636}
2637
2638// handlePermissionNotification updates tool items when permission state changes.
2639func (m *UI) handlePermissionNotification(notification permission.PermissionNotification) {
2640 toolItem := m.chat.MessageItem(notification.ToolCallID)
2641 if toolItem == nil {
2642 return
2643 }
2644
2645 if permItem, ok := toolItem.(chat.ToolMessageItem); ok {
2646 if notification.Granted {
2647 permItem.SetStatus(chat.ToolStatusRunning)
2648 } else {
2649 permItem.SetStatus(chat.ToolStatusAwaitingPermission)
2650 }
2651 }
2652}
2653
2654// newSession clears the current session state and prepares for a new session.
2655// The actual session creation happens when the user sends their first message.
2656func (m *UI) newSession() {
2657 if !m.hasSession() {
2658 return
2659 }
2660
2661 m.session = nil
2662 m.sessionFiles = nil
2663 m.state = uiLanding
2664 m.focus = uiFocusEditor
2665 m.textarea.Focus()
2666 m.chat.Blur()
2667 m.chat.ClearMessages()
2668 m.pillsExpanded = false
2669 m.promptQueue = 0
2670 m.pillsView = ""
2671}
2672
2673// handlePasteMsg handles a paste message.
2674func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
2675 if m.dialog.HasDialogs() {
2676 return m.handleDialogMsg(msg)
2677 }
2678
2679 if m.focus != uiFocusEditor {
2680 return nil
2681 }
2682
2683 // If pasted text has more than 2 newlines, treat it as a file attachment.
2684 if strings.Count(msg.Content, "\n") > 2 {
2685 return func() tea.Msg {
2686 content := []byte(msg.Content)
2687 if int64(len(content)) > common.MaxAttachmentSize {
2688 return uiutil.ReportWarn("Paste is too big (>5mb)")
2689 }
2690 name := fmt.Sprintf("paste_%d.txt", m.pasteIdx())
2691 mimeBufferSize := min(512, len(content))
2692 mimeType := http.DetectContentType(content[:mimeBufferSize])
2693 return message.Attachment{
2694 FileName: name,
2695 FilePath: name,
2696 MimeType: mimeType,
2697 Content: content,
2698 }
2699 }
2700 }
2701
2702 var cmd tea.Cmd
2703 path := strings.ReplaceAll(msg.Content, "\\ ", " ")
2704 // Try to get an image.
2705 path, err := filepath.Abs(strings.TrimSpace(path))
2706 if err != nil {
2707 m.textarea, cmd = m.textarea.Update(msg)
2708 return cmd
2709 }
2710
2711 // Check if file has an allowed image extension.
2712 isAllowedType := false
2713 lowerPath := strings.ToLower(path)
2714 for _, ext := range common.AllowedImageTypes {
2715 if strings.HasSuffix(lowerPath, ext) {
2716 isAllowedType = true
2717 break
2718 }
2719 }
2720 if !isAllowedType {
2721 m.textarea, cmd = m.textarea.Update(msg)
2722 return cmd
2723 }
2724
2725 return func() tea.Msg {
2726 fileInfo, err := os.Stat(path)
2727 if err != nil {
2728 return uiutil.ReportError(err)
2729 }
2730 if fileInfo.Size() > common.MaxAttachmentSize {
2731 return uiutil.ReportWarn("File is too big (>5mb)")
2732 }
2733
2734 content, err := os.ReadFile(path)
2735 if err != nil {
2736 return uiutil.ReportError(err)
2737 }
2738
2739 mimeBufferSize := min(512, len(content))
2740 mimeType := http.DetectContentType(content[:mimeBufferSize])
2741 fileName := filepath.Base(path)
2742 return message.Attachment{
2743 FilePath: path,
2744 FileName: fileName,
2745 MimeType: mimeType,
2746 Content: content,
2747 }
2748 }
2749}
2750
2751var pasteRE = regexp.MustCompile(`paste_(\d+).txt`)
2752
2753func (m *UI) pasteIdx() int {
2754 result := 0
2755 for _, at := range m.attachments.List() {
2756 found := pasteRE.FindStringSubmatch(at.FileName)
2757 if len(found) == 0 {
2758 continue
2759 }
2760 idx, err := strconv.Atoi(found[1])
2761 if err == nil {
2762 result = max(result, idx)
2763 }
2764 }
2765 return result + 1
2766}
2767
2768// drawSessionDetails draws the session details in compact mode.
2769func (m *UI) drawSessionDetails(scr uv.Screen, area uv.Rectangle) {
2770 if m.session == nil {
2771 return
2772 }
2773
2774 s := m.com.Styles
2775
2776 width := area.Dx() - s.CompactDetails.View.GetHorizontalFrameSize()
2777 height := area.Dy() - s.CompactDetails.View.GetVerticalFrameSize()
2778
2779 title := s.CompactDetails.Title.Width(width).MaxHeight(2).Render(m.session.Title)
2780 blocks := []string{
2781 title,
2782 "",
2783 m.modelInfo(width),
2784 "",
2785 }
2786
2787 detailsHeader := lipgloss.JoinVertical(
2788 lipgloss.Left,
2789 blocks...,
2790 )
2791
2792 version := s.CompactDetails.Version.Foreground(s.Border).Width(width).AlignHorizontal(lipgloss.Right).Render(version.Version)
2793
2794 remainingHeight := height - lipgloss.Height(detailsHeader) - lipgloss.Height(version)
2795
2796 const maxSectionWidth = 50
2797 sectionWidth := min(maxSectionWidth, width/3-2) // account for 2 spaces
2798 maxItemsPerSection := remainingHeight - 3 // Account for section title and spacing
2799
2800 lspSection := m.lspInfo(sectionWidth, maxItemsPerSection, false)
2801 mcpSection := m.mcpInfo(sectionWidth, maxItemsPerSection, false)
2802 filesSection := m.filesInfo(m.com.Config().WorkingDir(), sectionWidth, maxItemsPerSection, false)
2803 sections := lipgloss.JoinHorizontal(lipgloss.Top, filesSection, " ", lspSection, " ", mcpSection)
2804 uv.NewStyledString(
2805 s.CompactDetails.View.
2806 Width(area.Dx()).
2807 Render(
2808 lipgloss.JoinVertical(
2809 lipgloss.Left,
2810 detailsHeader,
2811 sections,
2812 version,
2813 ),
2814 ),
2815 ).Draw(scr, area)
2816}
2817
2818func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string) tea.Cmd {
2819 load := func() tea.Msg {
2820 prompt, err := commands.GetMCPPrompt(clientID, promptID, arguments)
2821 if err != nil {
2822 // TODO: make this better
2823 return uiutil.ReportError(err)()
2824 }
2825
2826 if prompt == "" {
2827 return nil
2828 }
2829 return sendMessageMsg{
2830 Content: prompt,
2831 }
2832 }
2833
2834 var cmds []tea.Cmd
2835 if cmd := m.dialog.StartLoading(); cmd != nil {
2836 cmds = append(cmds, cmd)
2837 }
2838 cmds = append(cmds, load, func() tea.Msg {
2839 return closeDialogMsg{}
2840 })
2841
2842 return tea.Sequence(cmds...)
2843}
2844
2845// renderLogo renders the Crush logo with the given styles and dimensions.
2846func renderLogo(t *styles.Styles, compact bool, width int) string {
2847 return logo.Render(version.Version, compact, logo.Opts{
2848 FieldColor: t.LogoFieldColor,
2849 TitleColorA: t.LogoTitleColorA,
2850 TitleColorB: t.LogoTitleColorB,
2851 CharmColor: t.LogoCharmColor,
2852 VersionColor: t.LogoVersionColor,
2853 Width: width,
2854 })
2855}