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