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