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