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