@@ -0,0 +1,2895 @@
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"
55+// Compact mode breakpoints.
57+ compactModeWidthBreakpoint = 120
58+ compactModeHeightBreakpoint = 30
61+// If pasted text has more than 2 newlines, treat it as a file attachment.
62+const pasteLinesThreshold = 10
64+// Session details panel max height.
65+const sessionDetailsMaxHeight = 20
67+// uiFocusState represents the current focus state of the UI.
68+type uiFocusState uint8
70+// Possible uiFocusState values.
72+ uiFocusNone uiFocusState = iota
79+// Possible uiState values.
81+ uiConfigure uiState = iota
87+type openEditorMsg struct {
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
98+ // mcpPromptsLoadedMsg is sent when mcp prompts are loaded.
99+ mcpPromptsLoadedMsg struct {
100+ Prompts []commands.MCPPrompt
102+ // sendMessageMsg is sent to send a message.
103+ // currently only used for mcp prompts.
104+ sendMessageMsg struct {
106+ Attachments []message.Attachment
109+ // closeDialogMsg is sent to close the current dialog.
110+ closeDialogMsg struct{}
112+ // copyChatHighlightMsg is sent to copy the current chat highlight to clipboard.
113+ copyChatHighlightMsg struct{}
116+// UI represents the main user interface model.
119+ session *session.Session
120+ sessionFiles []SessionFile
122+ lastUserMessageTime int64
124+ // The width and height of the terminal in cells.
133+ keyenh tea.KeyboardEnhancementsMsg
135+ dialog *dialog.Overlay
138+ // isCanceling tracks whether the user has pressed escape once to cancel.
141+ // header is the last cached header logo
144+ // sendProgressBar instructs the TUI to send progress bar updates to the
146+ sendProgressBar bool
148+ // QueryVersion instructs the TUI to query for the terminal version when it
152+ // Editor components
153+ textarea textarea.Model
156+ attachments *attachments.Attachments
158+ readyPlaceholder string
159+ workingPlaceholder string
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 '@'
173+ yesInitializeSelected bool
177+ lspStates map[string]app.LSPClientInfo
180+ mcpStates map[string]mcp.ClientInfo
182+ // sidebarLogo keeps a cached version of the sidebar sidebarLogo.
185+ // imgCaps stores the terminal image capabilities.
186+ imgCaps timage.Capabilities
188+ // custom commands & mcp commands
189+ customCommands []commands.CustomCommand
190+ mcpPrompts []commands.MCPPrompt
192+ // forceCompactMode tracks whether compact mode is forced by user toggle
193+ forceCompactMode bool
195+ // isCompact tracks whether we're currently in compact layout mode (either
196+ // by user toggle or auto-switch based on window size)
199+ // detailsOpen tracks whether the details panel is open (in compact mode)
204+ focusedPillSection pillSection
209+ todoSpinner spinner.Model
212+ // mouse highlighting related state
213+ lastClickTime time.Time
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
223+ ta.SetVirtualCursor(false)
228+ keyMap := DefaultKeyMap()
230+ // Completions component
231+ comp := completions.New(
232+ com.Styles.Completions.Normal,
233+ com.Styles.Completions.Focused,
234+ com.Styles.Completions.Match,
237+ todoSpinner := spinner.New(
238+ spinner.WithSpinner(spinner.MiniDot),
239+ spinner.WithStyle(com.Styles.Pills.TodoSpinner),
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,
251+ DeleteMode: keyMap.Editor.AttachmentDeleteMode,
252+ DeleteAll: keyMap.Editor.DeleteAllAttachments,
253+ Escape: keyMap.Editor.Escape,
259+ dialog: dialog.NewOverlay(),
266+ attachments: attachments,
267+ todoSpinner: todoSpinner,
270+ status := NewStatus(com, ui)
272+ // set onboarding state defaults
273+ ui.onboarding.yesInitializeSelected = true
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
283+ ui.state = uiLanding
284+ ui.focus = uiFocusEditor
287+ ui.setEditorPrompt(false)
288+ ui.randomizePlaceholders()
289+ ui.textarea.Placeholder = ui.readyPlaceholder
292+ // Initialize compact mode from config
293+ ui.forceCompactMode = com.Config().Options.TUI.CompactMode
298+// Init initializes the UI model.
299+func (m *UI) Init() tea.Cmd {
302+ cmds = append(cmds, tea.RequestTerminalVersion)
304+ // load the user commands async
305+ cmds = append(cmds, m.loadCustomCommands())
306+ return tea.Batch(cmds...)
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())
314+ slog.Error("failed to load custom commands", "error", err)
316+ return userCommandsLoadedMsg{Commands: customCommands}
320+// loadMCPrompts loads the MCP prompts asynchronously.
321+func (m *UI) loadMCPrompts() tea.Cmd {
322+ return func() tea.Msg {
323+ prompts, err := commands.LoadMCPPrompts()
325+ slog.Error("failed to load mcp prompts", "error", err)
328+ // flag them as loaded even if there is none or an error
329+ prompts = []commands.MCPPrompt{}
331+ return mcpPromptsLoadedMsg{Prompts: prompts}
335+// Update handles updates to the UI model.
336+func (m *UI) Update(msg tea.Msg) (tea.Model, 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()
345+ switch msg := msg.(type) {
347+ // Is this Windows Terminal?
348+ if !m.sendProgressBar {
349+ m.sendProgressBar = slices.Contains(msg, "WT_SESSION")
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
356+ cmds = append(cmds, timage.RequestCapabilities(m.imgCaps.Env))
357+ case loadSessionMsg:
359+ if m.forceCompactMode {
362+ m.session = msg.session
363+ m.sessionFiles = msg.files
364+ msgs, err := m.com.App.Messages.List(context.Background(), m.session.ID)
366+ cmds = append(cmds, uiutil.ReportError(err))
369+ if cmd := m.setSessionMessages(msgs); cmd != nil {
370+ cmds = append(cmds, cmd)
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)
378+ m.updateLayoutAndSize()
381+ case sendMessageMsg:
382+ cmds = append(cmds, m.sendMessage(msg.Content, msg.Attachments...))
384+ case userCommandsLoadedMsg:
385+ m.customCommands = msg.Commands
386+ dia := m.dialog.Dialog(dialog.CommandsID)
391+ commands, ok := dia.(*dialog.Commands)
393+ commands.SetCustomCommands(m.customCommands)
395+ case mcpPromptsLoadedMsg:
396+ m.mcpPrompts = msg.Prompts
397+ dia := m.dialog.Dialog(dialog.CommandsID)
402+ commands, ok := dia.(*dialog.Commands)
404+ commands.SetMCPPrompts(m.mcpPrompts)
407+ case closeDialogMsg:
408+ m.dialog.CloseFrontDialog()
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()
420+ case pubsub.Event[message.Message]:
421+ // Check if this is a child session message for an agent tool.
422+ if m.session == nil {
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)
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)
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)
445+ // stop the spinner if the agent is not busy anymore
446+ if m.todoIsSpinning && !m.isAgentBusy() {
447+ m.todoIsSpinning = false
449+ // there is a number of things that could change the pills here so we want to re-render
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
459+ for _, state := range m.mcpStates {
460+ if state.State == mcp.StateStarting {
465+ if initialized && m.mcpPrompts == nil {
466+ cmds = append(cmds, m.loadMCPrompts())
468+ case pubsub.Event[permission.PermissionRequest]:
469+ if cmd := m.openPermissionsDialog(msg.Payload); cmd != nil {
470+ cmds = append(cmds, cmd)
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")
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:
491+ if msg.SupportsKeyDisambiguation() {
492+ m.keyMap.Models.SetHelp("ctrl+m", "models")
493+ m.keyMap.Editor.Newline.SetHelp("shift+enter", "newline")
495+ case copyChatHighlightMsg:
496+ cmds = append(cmds, m.copyChatHighlight())
497+ case tea.MouseClickMsg:
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()
509+ case tea.MouseMotionMsg:
513+ if cmd := m.chat.ScrollByAndAnimate(-1); cmd != nil {
514+ cmds = append(cmds, cmd)
516+ if !m.chat.SelectedItemInView() {
518+ if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
519+ cmds = append(cmds, cmd)
522+ } else if msg.Y >= m.chat.Height()-1 {
523+ if cmd := m.chat.ScrollByAndAnimate(1); cmd != nil {
524+ cmds = append(cmds, cmd)
526+ if !m.chat.SelectedItemInView() {
528+ if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
529+ cmds = append(cmds, cmd)
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)
541+ case tea.MouseReleaseMsg:
542+ const doubleClickThreshold = 500 * time.Millisecond
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{}
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...)
566+ // Otherwise handle mouse wheel for chat.
570+ case tea.MouseWheelUp:
571+ if cmd := m.chat.ScrollByAndAnimate(-5); cmd != nil {
572+ cmds = append(cmds, cmd)
574+ if !m.chat.SelectedItemInView() {
576+ if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
577+ cmds = append(cmds, cmd)
580+ case tea.MouseWheelDown:
581+ if cmd := m.chat.ScrollByAndAnimate(5); cmd != nil {
582+ cmds = append(cmds, cmd)
584+ if !m.chat.SelectedItemInView() {
586+ if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
587+ cmds = append(cmds, cmd)
593+ if m.state == uiChat {
594+ if cmd := m.chat.Animate(msg); cmd != nil {
595+ cmds = append(cmds, cmd)
598+ case spinner.TickMsg:
599+ if m.dialog.HasDialogs() {
601+ if cmd := m.handleDialogMsg(msg); cmd != nil {
602+ cmds = append(cmds, cmd)
605+ if m.state == uiChat && m.hasSession() && hasInProgressTodo(m.session.Todos) && m.todoIsSpinning {
607+ m.todoSpinner, cmd = m.todoSpinner.Update(msg)
610+ cmds = append(cmds, cmd)
614+ case tea.KeyPressMsg:
615+ if cmd := m.handleKeyPressMsg(msg); cmd != nil {
616+ cmds = append(cmds, cmd)
619+ if cmd := m.handlePasteMsg(msg); cmd != nil {
620+ cmds = append(cmds, cmd)
623+ m.textarea.SetValue(msg.Text)
624+ m.textarea.MoveToEnd()
625+ case uiutil.InfoMsg:
626+ m.status.SetInfoMsg(msg)
629+ ttl = DefaultStatusTTL
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)
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
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)
655+ if m.dialog.HasDialogs() {
656+ if cmd := m.handleDialogMsg(msg); cmd != nil {
657+ cmds = append(cmds, cmd)
662+ // This logic gets triggered on any message type, but should it?
666+ // Textarea placeholder logic
667+ if m.isAgentBusy() {
668+ m.textarea.Placeholder = m.workingPlaceholder
670+ m.textarea.Placeholder = m.readyPlaceholder
672+ if m.com.App.Permissions.SkipRequests() {
673+ m.textarea.Placeholder = "Yolo mode!"
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...)
683+// setSessionMessages sets the messages for the current session in the chat
684+func (m *UI) setSessionMessages(msgs []message.Message) 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]
691+ toolResultMap := chat.BuildToolResultMap(msgPtrs)
692+ if len(msgPtrs) > 0 {
693+ m.lastUserMessageTime = msgPtrs[0].CreatedAt
696+ // Add messages to chat with linked tool results
697+ items := make([]chat.MessageItem, 0, len(msgs)*2)
698+ for _, msg := range msgPtrs {
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)
710+ items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...)
714+ // Load nested tool calls for agent/agentic_fetch tools.
715+ m.loadNestedToolCalls(items)
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)
727+ m.chat.SetMessages(items...)
728+ if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
729+ cmds = append(cmds, cmd)
732+ return tea.Batch(cmds...)
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)
742+ toolItem, ok := item.(chat.ToolMessageItem)
747+ tc := toolItem.ToolCall()
748+ messageID := toolItem.MessageID()
750+ // Get the agent tool session ID.
751+ agentSessionID := m.com.App.Sessions.CreateAgentToolSessionID(messageID, tc.ID)
753+ // Fetch nested messages.
754+ nestedMsgs, err := m.com.App.Messages.List(context.Background(), agentSessionID)
755+ if err != nil || len(nestedMsgs) == 0 {
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]
764+ nestedToolResultMap := chat.BuildToolResultMap(nestedMsgPtrs)
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)
776+ nestedTools = append(nestedTools, nestedToolItem)
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
786+ m.loadNestedToolCalls(nestedMessageItems)
788+ // Set nested tools on the parent.
789+ nestedContainer.SetNestedTools(nestedTools)
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 {
797+ existing := m.chat.MessageItem(msg.ID)
798+ if existing != nil {
799+ // message already exists, skip
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)
813+ m.chat.AppendMessages(items...)
814+ if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
815+ cmds = append(cmds, cmd)
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)
826+ m.chat.AppendMessages(items...)
827+ if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
828+ cmds = append(cmds, cmd)
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)
838+ for _, tr := range msg.ToolResults() {
839+ toolItem := m.chat.MessageItem(tr.ToolCallID)
840+ if toolItem == nil {
841+ // we should have an item!
844+ if toolMsgItem, ok := toolItem.(chat.ToolMessageItem); ok {
845+ toolMsgItem.SetResult(&tr)
849+ return tea.Batch(cmds...)
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 {
857+ existingItem := m.chat.MessageItem(msg.ID)
859+ if existingItem != nil {
860+ if assistantItem, ok := existingItem.(*chat.AssistantMessageItem); ok {
861+ assistantItem.SetMessage(&msg)
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))
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)
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)
895+ if existingToolItem == nil {
896+ items = append(items, chat.NewToolMessageItem(m.com.Styles, msg.ID, tc, nil, false))
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)
907+ m.chat.AppendMessages(items...)
908+ if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
909+ cmds = append(cmds, cmd)
912+ return tea.Batch(cmds...)
915+// handleChildSessionMessage handles messages from child sessions (agent tools).
916+func (m *UI) handleChildSessionMessage(event pubsub.Event[message.Message]) tea.Cmd {
919+ // Only process messages with tool calls or results.
920+ if len(event.Payload.ToolCalls()) == 0 && len(event.Payload.ToolResults()) == 0 {
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)
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)
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.
950+ if agentItem == nil {
954+ // Get existing nested tools.
955+ nestedTools := agentItem.NestedTools()
957+ // Update or create nested tool calls.
958+ for _, tc := range event.Payload.ToolCalls() {
960+ for _, existingTool := range nestedTools {
961+ if existingTool.ToolCall().ID == tc.ID {
962+ existingTool.SetToolCall(tc)
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)
973+ if animatable, ok := nestedItem.(chat.Animatable); ok {
974+ if cmd := animatable.StartAnimation(); cmd != nil {
975+ cmds = append(cmds, cmd)
978+ nestedTools = append(nestedTools, nestedItem)
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)
992+ // Update the agent item with the new nested tools.
993+ agentItem.SetNestedTools(nestedTools)
995+ // Update the chat so it updates the index map for animations to work as expected
996+ m.chat.UpdateNestedToolIDs(toolCallID)
998+ return tea.Batch(cmds...)
1001+func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
1003+ action := m.dialog.Update(msg)
1005+ return tea.Batch(cmds...)
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())
1015+ case dialog.ActionCmd:
1016+ if msg.Cmd != nil {
1017+ cmds = append(cmds, msg.Cmd)
1020+ // Session dialog messages
1021+ case dialog.ActionSelectSession:
1022+ m.dialog.CloseDialog(dialog.SessionsID)
1023+ cmds = append(cmds, m.loadSession(msg.Session.ID))
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)
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..."))
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..."))
1050+ cmds = append(cmds, func() tea.Msg {
1051+ err := m.com.App.AgentCoordinator.Summarize(context.Background(), msg.SessionID)
1053+ return uiutil.ReportError(err)()
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..."))
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..."))
1077+ cmds = append(cmds, func() tea.Msg {
1078+ cfg := m.com.Config()
1080+ return uiutil.ReportError(errors.New("configuration not found"))()
1083+ agentCfg, ok := cfg.Agents[config.AgentCoder]
1085+ return uiutil.ReportError(errors.New("agent configuration not found"))()
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)()
1093+ m.com.App.UpdateAgentModel(context.TODO())
1094+ status := "disabled"
1095+ if currentModel.Think {
1098+ return uiutil.NewInfoMsg("Thinking mode " + status)
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..."))
1108+ cmds = append(cmds, m.initializeProject())
1110+ case dialog.ActionSelectModel:
1111+ if m.isAgentBusy() {
1112+ cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait..."))
1116+ cfg := m.com.Config()
1118+ cmds = append(cmds, uiutil.ReportError(errors.New("configuration not found")))
1123+ providerID = msg.Model.Provider
1124+ isCopilot = providerID == string(catwalk.InferenceProviderCopilot)
1125+ isConfigured = func() bool { _, ok := cfg.Providers.Get(providerID); return ok }
1128+ // Attempt to import GitHub Copilot tokens from VSCode if available.
1129+ if isCopilot && !isConfigured() {
1130+ config.Get().ImportCopilot()
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)
1141+ if err := cfg.UpdatePreferredModel(msg.ModelType, msg.Model); err != nil {
1142+ cmds = append(cmds, uiutil.ReportError(err))
1145+ cmds = append(cmds, func() tea.Msg {
1146+ m.com.App.UpdateAgentModel(context.TODO())
1148+ modelMsg := fmt.Sprintf("%s model changed to %s", msg.ModelType, msg.Model.Model)
1150+ return uiutil.NewInfoMsg(modelMsg)
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..."))
1162+ cfg := m.com.Config()
1164+ cmds = append(cmds, uiutil.ReportError(errors.New("configuration not found")))
1168+ agentCfg, ok := cfg.Agents[config.AgentCoder]
1170+ cmds = append(cmds, uiutil.ReportError(errors.New("agent configuration not found")))
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))
1181+ cmds = append(cmds, func() tea.Msg {
1182+ m.com.App.UpdateAgentModel(context.TODO())
1183+ return uiutil.NewInfoMsg("Reasoning effort set to " + msg.Effort)
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)
1197+ case dialog.ActionFilePickerSelected:
1198+ cmds = append(cmds, tea.Sequence(
1201+ m.dialog.CloseDialog(dialog.FilePickerID)
1206+ case dialog.ActionRunCustomCommand:
1207+ if len(msg.Arguments) > 0 && msg.Args == nil {
1208+ m.dialog.CloseFrontDialog()
1209+ argsDialog := dialog.NewArguments(
1211+ "Custom Command Arguments",
1214+ msg, // Pass the action as the result
1216+ m.dialog.OpenDialog(argsDialog)
1219+ content := msg.Content
1220+ if msg.Args != nil {
1221+ content = substituteArgs(content, msg.Args)
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()
1230+ title = "MCP Prompt Arguments"
1232+ argsDialog := dialog.NewArguments(
1237+ msg, // Pass the action as the result
1239+ m.dialog.OpenDialog(argsDialog)
1242+ cmds = append(cmds, m.runMCPPrompt(msg.ClientID, msg.PromptID, msg.Args))
1244+ cmds = append(cmds, uiutil.CmdHandler(msg))
1247+ return tea.Batch(cmds...)
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)
1259+func (m *UI) openAuthenticationDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd {
1265+ switch provider.ID {
1267+ dlg, cmd = dialog.NewOAuthHyper(m.com, provider, model, modelType)
1268+ case catwalk.InferenceProviderCopilot:
1269+ dlg, cmd = dialog.NewOAuthCopilot(m.com, provider, model, modelType)
1271+ dlg, cmd = dialog.NewAPIKeyInput(m.com, provider, model, modelType)
1274+ if m.dialog.ContainsDialog(dlg.ID()) {
1275+ m.dialog.BringToFront(dlg.ID())
1279+ m.dialog.OpenDialog(dlg)
1283+func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
1286+ handleGlobalKeys := func(msg tea.KeyPressMsg) bool {
1288+ case key.Matches(msg, m.keyMap.Help):
1289+ m.status.ToggleHelp()
1290+ m.updateLayoutAndSize()
1292+ case key.Matches(msg, m.keyMap.Commands):
1293+ if cmd := m.openCommandsDialog(); cmd != nil {
1294+ cmds = append(cmds, cmd)
1297+ case key.Matches(msg, m.keyMap.Models):
1298+ if cmd := m.openModelsDialog(); cmd != nil {
1299+ cmds = append(cmds, cmd)
1302+ case key.Matches(msg, m.keyMap.Sessions):
1303+ if cmd := m.openSessionsDialog(); cmd != nil {
1304+ cmds = append(cmds, cmd)
1307+ case key.Matches(msg, m.keyMap.Chat.Details) && m.isCompact:
1308+ m.detailsOpen = !m.detailsOpen
1309+ m.updateLayoutAndSize()
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)
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)
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)
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)
1342+ return tea.Batch(cmds...)
1345+ // Route all messages to dialog if one is open.
1346+ if m.dialog.HasDialogs() {
1347+ return m.handleDialogMsg(msg)
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)
1356+ return tea.Batch(cmds...)
1362+ return tea.Batch(cmds...)
1364+ cmds = append(cmds, m.updateInitializeView(msg)...)
1365+ return tea.Batch(cmds...)
1366+ case uiChat, uiLanding:
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))
1379+ m.closeCompletions()
1381+ case completions.ClosedMsg:
1382+ m.completionsOpen = false
1384+ return tea.Batch(cmds...)
1388+ if ok := m.attachments.Update(msg); ok {
1389+ return tea.Batch(cmds...)
1393+ case key.Matches(msg, m.keyMap.Editor.AddImage):
1394+ if cmd := m.openFilesDialog(); cmd != nil {
1395+ cmds = append(cmds, cmd)
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)
1406+ // Otherwise, send the message
1409+ value = strings.TrimSpace(value)
1410+ if value == "exit" || value == "quit" {
1411+ return m.openQuitDialog()
1414+ attachments := m.attachments.List()
1415+ m.attachments.Reset()
1416+ if len(value) == 0 && !message.ContainsTextAttachment(attachments) {
1420+ m.randomizePlaceholders()
1422+ return m.sendMessage(value, attachments...)
1423+ case key.Matches(msg, m.keyMap.Chat.NewSession):
1424+ if !m.hasSession() {
1427+ if m.isAgentBusy() {
1428+ cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session..."))
1432+ case key.Matches(msg, m.keyMap.Tab):
1433+ m.focus = uiFocusMain
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..."))
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()
1447+ if handleGlobalKeys(msg) {
1448+ // Handle global keys first before passing to textarea.
1452+ // Check for @ trigger before passing to textarea.
1453+ curValue := m.textarea.Value()
1454+ curIdx := len(curValue)
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))
1469+ // remove the details if they are open when user starts typing
1471+ m.detailsOpen = false
1472+ m.updateLayoutAndSize()
1475+ ta, cmd := m.textarea.Update(msg)
1477+ cmds = append(cmds, cmd)
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)
1485+ // Close completions if cursor moved before start.
1486+ if newIdx <= m.completionsStartIndex {
1487+ m.closeCompletions()
1488+ } else if msg.String() == "space" {
1490+ m.closeCompletions()
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()
1505+ case key.Matches(msg, m.keyMap.Tab):
1506+ m.focus = uiFocusEditor
1507+ cmds = append(cmds, m.textarea.Focus())
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)
1515+ if !m.chat.SelectedItemInView() {
1516+ m.chat.SelectPrev()
1517+ if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1518+ cmds = append(cmds, cmd)
1521+ case key.Matches(msg, m.keyMap.Chat.Down):
1522+ if cmd := m.chat.ScrollByAndAnimate(1); cmd != nil {
1523+ cmds = append(cmds, cmd)
1525+ if !m.chat.SelectedItemInView() {
1526+ m.chat.SelectNext()
1527+ if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1528+ cmds = append(cmds, cmd)
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)
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)
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)
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)
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)
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)
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)
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)
1570+ m.chat.SelectLast()
1572+ handleGlobalKeys(msg)
1575+ handleGlobalKeys(msg)
1578+ handleGlobalKeys(msg)
1581+ return tea.Batch(cmds...)
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())
1588+ if m.layout != layout {
1593+ // Clear the screen first
1598+ header := uv.NewStyledString(m.header)
1599+ header.Draw(scr, layout.header)
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)
1609+ header := uv.NewStyledString(m.header)
1610+ header.Draw(scr, layout.header)
1612+ main := uv.NewStyledString(m.initializeView())
1613+ main.Draw(scr, layout.main)
1616+ header := uv.NewStyledString(m.header)
1617+ header.Draw(scr, layout.header)
1618+ main := uv.NewStyledString(m.landingView())
1619+ main.Draw(scr, layout.main)
1621+ editor := uv.NewStyledString(m.renderEditorView(scr.Bounds().Dx()))
1622+ editor.Draw(scr, layout.editor)
1626+ header := uv.NewStyledString(m.header)
1627+ header.Draw(scr, layout.header)
1629+ m.drawSidebar(scr, layout.sidebar)
1632+ m.chat.Draw(scr, layout.main)
1633+ if layout.pills.Dy() > 0 && m.pillsView != "" {
1634+ uv.NewStyledString(m.pillsView).Draw(scr, layout.pills)
1637+ editorWidth := scr.Bounds().Dx()
1639+ editorWidth -= layout.sidebar.Dx()
1641+ editor := uv.NewStyledString(m.renderEditorView(editorWidth))
1642+ editor.Draw(scr, layout.editor)
1644+ // Draw details overlay in compact mode when open
1645+ if m.isCompact && m.detailsOpen {
1646+ m.drawSessionDetails(scr, layout.sessionDetails)
1650+ // Add status and help layer
1651+ m.status.Draw(scr, layout.status)
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
1659+ screenW := area.Dx()
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),
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),
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
1686+ if m.dialog.HasDialogs() {
1687+ return m.dialog.Draw(scr, scr.Bounds())
1691+ case uiFocusEditor:
1692+ if m.layout.editor.Dy() <= 0 {
1693+ // Don't show cursor if editor is not visible
1696+ if m.detailsOpen && m.isCompact {
1697+ // Don't show cursor if details overlay is open
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 {
1715+// View renders the UI model's view.
1716+func (m *UI) View() tea.View {
1719+ v.BackgroundColor = m.com.Styles.Background
1720+ v.MouseMode = tea.MouseModeCellMotion
1721+ v.WindowTitle = "crush " + home.Short(m.com.Config().WorkingDir())
1723+ canvas := uv.NewScreenBuffer(m.width, m.height)
1724+ v.Cursor = m.Draw(canvas, canvas.Bounds())
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, " ")
1733+ content = strings.Join(contentLines, "\n")
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))
1745+// ShortHelp implements [help.KeyMap].
1746+func (m *UI) ShortHelp() []key.Binding {
1747+ var binds []key.Binding
1750+ commands := k.Commands
1751+ if m.focus == uiFocusEditor && m.textarea.LineCount() == 0 {
1752+ commands.SetHelp("/ or ctrl+p", "commands")
1757+ binds = append(binds, k.Quit)
1759+ // Show cancel binding if agent is busy.
1760+ if m.isAgentBusy() {
1761+ cancelBinding := k.Chat.Cancel
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")
1767+ binds = append(binds, cancelBinding)
1770+ if m.focus == uiFocusEditor {
1771+ tab.SetHelp("tab", "focus chat")
1773+ tab.SetHelp("tab", "focus editor")
1776+ binds = append(binds,
1783+ case uiFocusEditor:
1784+ binds = append(binds,
1788+ binds = append(binds,
1790+ k.Chat.UpDownOneItem,
1795+ if m.pillsExpanded && hasIncompleteTodos(m.session.Todos) && m.promptQueue > 0 {
1796+ binds = append(binds, k.Chat.PillLeft)
1800+ // TODO: other states
1801+ // if m.session == nil {
1802+ // no session selected
1803+ binds = append(binds,
1810+ binds = append(binds,
1818+// FullHelp implements [help.KeyMap].
1819+func (m *UI) FullHelp() [][]key.Binding {
1820+ var binds [][]key.Binding
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")
1833+ binds = append(binds,
1838+ // Show cancel binding if agent is busy.
1839+ if m.isAgentBusy() {
1840+ cancelBinding := k.Chat.Cancel
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")
1846+ binds = append(binds, []key.Binding{cancelBinding})
1849+ mainBinds := []key.Binding{}
1851+ if m.focus == uiFocusEditor {
1852+ tab.SetHelp("tab", "focus chat")
1854+ tab.SetHelp("tab", "focus editor")
1857+ mainBinds = append(mainBinds,
1864+ mainBinds = append(mainBinds, k.Chat.NewSession)
1867+ binds = append(binds, mainBinds)
1870+ case uiFocusEditor:
1871+ binds = append(binds,
1875+ k.Editor.MentionFile,
1876+ k.Editor.OpenEditor,
1879+ if hasAttachments {
1880+ binds = append(binds,
1882+ k.Editor.AttachmentDeleteMode,
1883+ k.Editor.DeleteAllAttachments,
1889+ binds = append(binds,
1892+ k.Chat.UpDownOneItem,
1898+ k.Chat.HalfPageDown,
1904+ k.Chat.ClearHighlight,
1907+ if m.pillsExpanded && hasIncompleteTodos(m.session.Todos) && m.promptQueue > 0 {
1908+ binds = append(binds, []key.Binding{k.Chat.PillLeft})
1912+ if m.session == nil {
1913+ // no session selected
1914+ binds = append(binds,
1923+ k.Editor.MentionFile,
1924+ k.Editor.OpenEditor,
1927+ if hasAttachments {
1928+ binds = append(binds,
1930+ k.Editor.AttachmentDeleteMode,
1931+ k.Editor.DeleteAllAttachments,
1936+ binds = append(binds,
1944+ binds = append(binds,
1954+// toggleCompactMode toggles compact mode between uiChat and uiChatCompact states.
1955+func (m *UI) toggleCompactMode() tea.Cmd {
1956+ m.forceCompactMode = !m.forceCompactMode
1958+ err := m.com.Config().SetCompactMode(m.forceCompactMode)
1960+ return uiutil.ReportError(err)
1963+ m.handleCompactMode(m.width, m.height)
1964+ m.updateLayoutAndSize()
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 {
1976+ if newWidth < compactModeWidthBreakpoint || newHeight < compactModeHeightBreakpoint {
1979+ m.isCompact = false
1984+// updateLayoutAndSize updates the layout and sizes of UI components.
1985+func (m *UI) updateLayoutAndSize() {
1986+ m.layout = m.generateLayout(m.width, m.height)
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())
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())
2000+ // Handle different app states
2002+ case uiConfigure, uiInitialize, uiLanding:
2003+ m.renderHeader(false, m.layout.header.Dx())
2007+ m.renderHeader(true, m.layout.header.Dx())
2009+ m.renderSidebarLogo(m.layout.sidebar.Dx())
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)
2022+ // The editor height
2024+ // The sidebar width
2026+ // The header height
2027+ const landingHeaderHeight = 4
2029+ var helpKeyMap help.KeyMap = m
2030+ if m.status.ShowingAll() {
2031+ for _, row := range helpKeyMap.FullHelp() {
2032+ helpHeight = max(helpHeight, len(row))
2037+ appRect, helpRect := uv.SplitVertical(area, uv.Fixed(area.Dy()-helpHeight))
2040+ helpRect.Min.Y -= 1
2044+ if slices.Contains([]uiState{uiConfigure, uiInitialize, uiLanding}, m.state) {
2045+ // extra padding on left and right for these states
2055+ // Handle different app states
2057+ case uiConfigure, uiInitialize:
2066+ headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(landingHeaderHeight))
2067+ layout.header = headerRect
2068+ layout.main = mainRect
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
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
2118+ layout.main = mainRect
2120+ // Add bottom margin to main
2121+ layout.main.Max.Y -= 1
2122+ layout.editor = editorRect
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
2146+ layout.main = mainRect
2148+ // Add bottom margin to main
2149+ layout.main.Max.Y -= 1
2150+ layout.editor = editorRect
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
2163+// layout defines the positioning of UI elements.
2164+type layout struct {
2165+ // area is the overall available area.
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
2174+ // main is the area for the main pane. (e.x chat, configure, landing)
2177+ // pills is the area for the pills panel.
2180+ // editor is the area for the editor pane.
2181+ editor uv.Rectangle
2183+ // sidebar is the area for the sidebar.
2184+ sidebar uv.Rectangle
2186+ // status is the area for the status view.
2187+ status uv.Rectangle
2189+ // session details is the area for the session details overlay in compact mode.
2190+ sessionDetails uv.Rectangle
2193+func (m *UI) openEditor(value string) tea.Cmd {
2194+ tmpfile, err := os.CreateTemp("", "msg_*.md")
2196+ return uiutil.ReportError(err)
2198+ defer tmpfile.Close() //nolint:errcheck
2199+ if _, err := tmpfile.WriteString(value); err != nil {
2200+ return uiutil.ReportError(err)
2202+ cmd, err := editor.Command(
2206+ m.textarea.Line()+1,
2207+ m.textarea.Column()+1,
2211+ return uiutil.ReportError(err)
2213+ return tea.ExecProcess(cmd, func(err error) tea.Msg {
2215+ return uiutil.ReportError(err)
2217+ content, err := os.ReadFile(tmpfile.Name())
2219+ return uiutil.ReportError(err)
2221+ if len(content) == 0 {
2222+ return uiutil.ReportWarn("Message is empty")
2224+ os.Remove(tmpfile.Name())
2225+ return openEditorMsg{
2226+ Text: strings.TrimSpace(string(content)),
2231+// setEditorPrompt configures the textarea prompt function based on whether
2232+// yolo mode is enabled.
2233+func (m *UI) setEditorPrompt(yolo bool) {
2235+ m.textarea.SetPromptFunc(4, m.yoloPromptFunc)
2238+ m.textarea.SetPromptFunc(4, m.normalPromptFunc)
2241+// normalPromptFunc returns the normal editor prompt style (" > " on first
2242+// line, "::: " on subsequent lines).
2243+func (m *UI) normalPromptFunc(info textarea.PromptInfo) string {
2245+ if info.LineNumber == 0 {
2252+ return t.EditorPromptNormalFocused.Render()
2254+ return t.EditorPromptNormalBlurred.Render()
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 {
2261+ if info.LineNumber == 0 {
2263+ return t.EditorPromptYoloIconFocused.Render()
2265+ return t.EditorPromptYoloIconBlurred.Render()
2269+ return t.EditorPromptYoloDotsFocused.Render()
2271+ return t.EditorPromptYoloDotsBlurred.Render()
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()
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()
2288+ // Find the @ and query to replace.
2289+ if m.completionsStartIndex > len(value) {
2293+ // Build the new value: everything before @, the path, everything after query.
2294+ endIdx := min(m.completionsStartIndex+len(word), len(value))
2296+ newValue := value[:m.completionsStartIndex] + path + value[endIdx:]
2297+ m.textarea.SetValue(newValue)
2298+ m.textarea.MoveToEnd()
2299+ m.textarea.InsertRune(' ')
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) {
2311+ // Add file as attachment.
2312+ content, err := os.ReadFile(path)
2314+ // If it fails, let the LLM handle it later.
2317+ filetracker.RecordRead(absPath)
2319+ return message.Attachment{
2321+ FileName: filepath.Base(path),
2322+ MimeType: mimeOf(content),
2328+// completionsPosition returns the X and Y position for the completions popup.
2329+func (m *UI) completionsPosition() image.Point {
2330+ cur := m.textarea.Cursor()
2332+ return image.Point{
2333+ X: m.layout.editor.Min.X,
2334+ Y: m.layout.editor.Min.Y,
2337+ return image.Point{
2338+ X: cur.X + m.layout.editor.Min.X,
2339+ Y: m.layout.editor.Min.Y + cur.Y,
2343+// textareaWord returns the current word at the cursor position.
2344+func (m *UI) textareaWord() string {
2345+ return m.textarea.Word()
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'
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()
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 != ""
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])
2372+var readyPlaceholders = [...]string{
2376+ "Ready for instructions",
2379+var workingPlaceholders = [...]string{
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))]
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()
2400+ return lipgloss.JoinVertical(
2402+ m.attachments.Render(width),
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)
2412+ m.header = renderLogo(m.com.Styles, compact, width)
2416+// renderSidebarLogo renders and caches the sidebar logo at the specified
2418+func (m *UI) renderSidebarLogo(width int) {
2419+ m.sidebarLogo = renderLogo(m.com.Styles, true, width)
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"))
2429+ if !m.hasSession() {
2430+ newSession, err := m.com.App.Sessions.Create(context.Background(), "New Session")
2432+ return uiutil.ReportError(err)
2435+ if m.forceCompactMode {
2438+ if newSession.ID != "" {
2439+ m.session = &newSession
2440+ cmds = append(cmds, m.loadSession(newSession.ID))
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...)
2449+ isCancelErr := errors.Is(err, context.Canceled)
2450+ isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)
2451+ if isCancelErr || isPermissionErr {
2454+ return uiutil.InfoMsg{
2455+ Type: uiutil.InfoTypeError,
2461+ return tea.Batch(cmds...)
2464+const cancelTimerDuration = 2 * time.Second
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{}
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() {
2481+ coordinator := m.com.App.AgentCoordinator
2482+ if coordinator == nil {
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
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)
2502+ // First escape press - set canceling state and start timer.
2503+ m.isCanceling = true
2504+ return cancelTimerCmd()
2507+// openDialog opens a dialog by its ID.
2508+func (m *UI) openDialog(id string) tea.Cmd {
2511+ case dialog.SessionsID:
2512+ if cmd := m.openSessionsDialog(); cmd != nil {
2513+ cmds = append(cmds, cmd)
2515+ case dialog.ModelsID:
2516+ if cmd := m.openModelsDialog(); cmd != nil {
2517+ cmds = append(cmds, cmd)
2519+ case dialog.CommandsID:
2520+ if cmd := m.openCommandsDialog(); cmd != nil {
2521+ cmds = append(cmds, cmd)
2523+ case dialog.ReasoningID:
2524+ if cmd := m.openReasoningDialog(); cmd != nil {
2525+ cmds = append(cmds, cmd)
2527+ case dialog.QuitID:
2528+ if cmd := m.openQuitDialog(); cmd != nil {
2529+ cmds = append(cmds, cmd)
2535+ return tea.Batch(cmds...)
2538+// openQuitDialog opens the quit confirmation dialog.
2539+func (m *UI) openQuitDialog() tea.Cmd {
2540+ if m.dialog.ContainsDialog(dialog.QuitID) {
2542+ m.dialog.BringToFront(dialog.QuitID)
2546+ quitDialog := dialog.NewQuit(m.com)
2547+ m.dialog.OpenDialog(quitDialog)
2551+// openModelsDialog opens the models dialog.
2552+func (m *UI) openModelsDialog() tea.Cmd {
2553+ if m.dialog.ContainsDialog(dialog.ModelsID) {
2555+ m.dialog.BringToFront(dialog.ModelsID)
2559+ modelsDialog, err := dialog.NewModels(m.com)
2561+ return uiutil.ReportError(err)
2564+ m.dialog.OpenDialog(modelsDialog)
2569+// openCommandsDialog opens the commands dialog.
2570+func (m *UI) openCommandsDialog() tea.Cmd {
2571+ if m.dialog.ContainsDialog(dialog.CommandsID) {
2573+ m.dialog.BringToFront(dialog.CommandsID)
2578+ if m.session != nil {
2579+ sessionID = m.session.ID
2582+ commands, err := dialog.NewCommands(m.com, sessionID, m.customCommands, m.mcpPrompts)
2584+ return uiutil.ReportError(err)
2587+ m.dialog.OpenDialog(commands)
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)
2599+ reasoningDialog, err := dialog.NewReasoning(m.com)
2601+ return uiutil.ReportError(err)
2604+ m.dialog.OpenDialog(reasoningDialog)
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
2611+func (m *UI) openSessionsDialog() tea.Cmd {
2612+ if m.dialog.ContainsDialog(dialog.SessionsID) {
2614+ m.dialog.BringToFront(dialog.SessionsID)
2618+ selectedSessionID := ""
2619+ if m.session != nil {
2620+ selectedSessionID = m.session.ID
2623+ dialog, err := dialog.NewSessions(m.com, selectedSessionID)
2625+ return uiutil.ReportError(err)
2628+ m.dialog.OpenDialog(dialog)
2632+// openFilesDialog opens the file picker dialog.
2633+func (m *UI) openFilesDialog() tea.Cmd {
2634+ if m.dialog.ContainsDialog(dialog.FilePickerID) {
2636+ m.dialog.BringToFront(dialog.FilePickerID)
2640+ filePicker, cmd := dialog.NewFilePicker(m.com)
2641+ filePicker.SetImageCapabilities(&m.imgCaps)
2642+ m.dialog.OpenDialog(filePicker)
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)
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"))
2658+ permDialog := dialog.NewPermissions(m.com, perm, opts...)
2659+ m.dialog.OpenDialog(permDialog)
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 {
2670+ if permItem, ok := toolItem.(chat.ToolMessageItem); ok {
2671+ if notification.Granted {
2672+ permItem.SetStatus(chat.ToolStatusRunning)
2674+ permItem.SetStatus(chat.ToolStatusAwaitingPermission)
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() {
2687+ m.sessionFiles = nil
2688+ m.state = uiLanding
2689+ m.focus = uiFocusEditor
2692+ m.chat.ClearMessages()
2693+ m.pillsExpanded = false
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)
2704+ if m.focus != uiFocusEditor {
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)")
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{
2720+ MimeType: mimeType,
2727+ path := strings.ReplaceAll(msg.Content, "\\ ", " ")
2728+ // Try to get an image.
2729+ path, err := filepath.Abs(strings.TrimSpace(path))
2731+ m.textarea, cmd = m.textarea.Update(msg)
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
2744+ if !isAllowedType {
2745+ m.textarea, cmd = m.textarea.Update(msg)
2749+ return func() tea.Msg {
2750+ fileInfo, err := os.Stat(path)
2752+ return uiutil.ReportError(err)
2754+ if fileInfo.Size() > common.MaxAttachmentSize {
2755+ return uiutil.ReportWarn("File is too big (>5mb)")
2758+ content, err := os.ReadFile(path)
2760+ return uiutil.ReportError(err)
2763+ mimeBufferSize := min(512, len(content))
2764+ mimeType := http.DetectContentType(content[:mimeBufferSize])
2765+ fileName := filepath.Base(path)
2766+ return message.Attachment{
2768+ FileName: fileName,
2769+ MimeType: mimeType,
2775+var pasteRE = regexp.MustCompile(`paste_(\d+).txt`)
2777+func (m *UI) pasteIdx() int {
2779+ for _, at := range m.attachments.List() {
2780+ found := pasteRE.FindStringSubmatch(at.FileName)
2781+ if len(found) == 0 {
2784+ idx, err := strconv.Atoi(found[1])
2786+ result = max(result, idx)
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 {
2800+ width := area.Dx() - s.CompactDetails.View.GetHorizontalFrameSize()
2801+ height := area.Dy() - s.CompactDetails.View.GetVerticalFrameSize()
2803+ title := s.CompactDetails.Title.Width(width).MaxHeight(2).Render(m.session.Title)
2804+ blocks := []string{
2807+ m.modelInfo(width),
2811+ detailsHeader := lipgloss.JoinVertical(
2816+ version := s.CompactDetails.Version.Foreground(s.Border).Width(width).AlignHorizontal(lipgloss.Right).Render(version.Version)
2818+ remainingHeight := height - lipgloss.Height(detailsHeader) - lipgloss.Height(version)
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
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.
2832+ lipgloss.JoinVertical(
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)
2846+ // TODO: make this better
2847+ return uiutil.ReportError(err)()
2853+ return sendMessageMsg{
2859+ if cmd := m.dialog.StartLoading(); cmd != nil {
2860+ cmds = append(cmds, cmd)
2862+ cmds = append(cmds, load, func() tea.Msg {
2863+ return closeDialogMsg{}
2866+ return tea.Sequence(cmds...)
2869+func (m *UI) copyChatHighlight() tea.Cmd {
2870+ text := m.chat.HighlighContent()
2871+ return tea.Sequence(
2872+ tea.SetClipboard(text),
2874+ _ = clipboard.WriteAll(text)
2878+ m.chat.ClearMouse()
2881+ uiutil.ReportInfo("Selected text copied to clipboard"),
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,