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