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