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