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