@@ -0,0 +1,2895 @@
1+package model
2+
3+import (
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.
56+const (
57+ compactModeWidthBreakpoint = 120
58+ compactModeHeightBreakpoint = 30
59+)
60+
61+// If pasted text has more than 2 newlines, treat it as a file attachment.
62+const pasteLinesThreshold = 10
63+
64+// Session details panel max height.
65+const sessionDetailsMaxHeight = 20
66+
67+// uiFocusState represents the current focus state of the UI.
68+type uiFocusState uint8
69+
70+// Possible uiFocusState values.
71+const (
72+ uiFocusNone uiFocusState = iota
73+ uiFocusEditor
74+ uiFocusMain
75+)
76+
77+type uiState uint8
78+
79+// Possible uiState values.
80+const (
81+ uiConfigure uiState = iota
82+ uiInitialize
83+ uiLanding
84+ uiChat
85+)
86+
87+type openEditorMsg struct {
88+ Text string
89+}
90+
91+type (
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.
117+type 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.
217+func 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.
299+func (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.
310+func (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.
321+func (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.
336+func (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
684+func (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.
736+func (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
795+func (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
855+func (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).
916+func (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+
1001+func (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.
1251+func 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+
1259+func (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+
1283+func (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.
1585+func (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.
1716+func (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].
1746+func (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].
1819+func (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.
1955+func (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.
1970+func (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.
1985+func (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.
1991+func (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.
2016+func (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.
2164+type 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+
2193+func (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.
2233+func (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).
2243+func (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.
2259+func (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.
2275+func (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.
2284+func (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.
2329+func (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.
2344+func (m *UI) textareaWord() string {
2345+ return m.textarea.Word()
2346+}
2347+
2348+// isWhitespace returns true if the byte is a whitespace character.
2349+func 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.
2355+func (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.
2362+func (m *UI) hasSession() bool {
2363+ return m.session != nil && m.session.ID != ""
2364+}
2365+
2366+// mimeOf detects the MIME type of the given content.
2367+func mimeOf(content []byte) string {
2368+ mimeBufferSize := min(512, len(content))
2369+ return http.DetectContentType(content[:mimeBufferSize])
2370+}
2371+
2372+var readyPlaceholders = [...]string{
2373+ "Ready!",
2374+ "Ready...",
2375+ "Ready?",
2376+ "Ready for instructions",
2377+}
2378+
2379+var 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.
2390+func (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.
2396+func (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.
2408+func (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.
2418+func (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.
2423+func (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+
2464+const cancelTimerDuration = 2 * time.Second
2465+
2466+// cancelTimerCmd creates a command that expires the cancel timer.
2467+func 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.
2476+func (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.
2508+func (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.
2539+func (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.
2552+func (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.
2570+func (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.
2593+func (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.
2611+func (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.
2633+func (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.
2648+func (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.
2664+func (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.
2681+func (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.
2699+func (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+
2775+var pasteRE = regexp.MustCompile(`paste_(\d+).txt`)
2776+
2777+func (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.
2793+func (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+
2842+func (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+
2869+func (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.
2886+func 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+}