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