Files
IlgazandKieran Klukas 8e7451fc1e fix(ui): show custom skills reliably on startup
Resolve race condition by implementing a package-level cache for skill discovery states. Add ~/.agents/skills/ and ~/.claude/skills/ as global skill scan paths.
2026-05-14 14:00:23 -04:00

3821 lines
104 KiB
Go

package model
import (
"bytes"
"cmp"
"context"
"errors"
"fmt"
"image"
"log/slog"
"math/rand"
"net/http"
"os"
"path/filepath"
"regexp"
"slices"
"strconv"
"strings"
"time"
"charm.land/bubbles/v2/help"
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/spinner"
"charm.land/bubbles/v2/textarea"
tea "charm.land/bubbletea/v2"
"charm.land/catwalk/pkg/catwalk"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/crush/internal/agent/hyper"
"github.com/charmbracelet/crush/internal/agent/notify"
agenttools "github.com/charmbracelet/crush/internal/agent/tools"
"github.com/charmbracelet/crush/internal/agent/tools/mcp"
"github.com/charmbracelet/crush/internal/app"
"github.com/charmbracelet/crush/internal/commands"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/fsext"
"github.com/charmbracelet/crush/internal/history"
"github.com/charmbracelet/crush/internal/home"
"github.com/charmbracelet/crush/internal/message"
"github.com/charmbracelet/crush/internal/permission"
"github.com/charmbracelet/crush/internal/pubsub"
"github.com/charmbracelet/crush/internal/session"
"github.com/charmbracelet/crush/internal/skills"
"github.com/charmbracelet/crush/internal/ui/anim"
"github.com/charmbracelet/crush/internal/ui/attachments"
"github.com/charmbracelet/crush/internal/ui/chat"
"github.com/charmbracelet/crush/internal/ui/common"
"github.com/charmbracelet/crush/internal/ui/completions"
"github.com/charmbracelet/crush/internal/ui/dialog"
fimage "github.com/charmbracelet/crush/internal/ui/image"
"github.com/charmbracelet/crush/internal/ui/logo"
"github.com/charmbracelet/crush/internal/ui/notification"
"github.com/charmbracelet/crush/internal/ui/styles"
"github.com/charmbracelet/crush/internal/ui/util"
"github.com/charmbracelet/crush/internal/version"
"github.com/charmbracelet/crush/internal/workspace"
uv "github.com/charmbracelet/ultraviolet"
"github.com/charmbracelet/ultraviolet/layout"
"github.com/charmbracelet/ultraviolet/screen"
"github.com/charmbracelet/x/editor"
xstrings "github.com/charmbracelet/x/exp/strings"
)
// MouseScrollThreshold defines how many lines to scroll the chat when a mouse
// wheel event occurs.
const MouseScrollThreshold = 5
// Compact mode breakpoints.
const (
compactModeWidthBreakpoint = 120
compactModeHeightBreakpoint = 30
)
// If pasted text has more than 10 newlines, treat it as a file attachment.
const pasteLinesThreshold = 10
// If pasted text has more than 1000 columns, treat it as a file attachment.
const pasteColsThreshold = 1000
// Session details panel max height.
const sessionDetailsMaxHeight = 20
// TextareaMaxHeight is the maximum height of the prompt textarea.
const TextareaMaxHeight = 15
// editorHeightMargin is the vertical margin added to the textarea height to
// account for the attachments row (top) and bottom margin.
const editorHeightMargin = 2
// TextareaMinHeight is the minimum height of the prompt textarea.
const TextareaMinHeight = 3
// uiFocusState represents the current focus state of the UI.
type uiFocusState uint8
// Possible uiFocusState values.
const (
uiFocusNone uiFocusState = iota
uiFocusEditor
uiFocusMain
)
type uiState uint8
// Possible uiState values.
const (
uiOnboarding uiState = iota
uiInitialize
uiLanding
uiChat
)
type openEditorMsg struct {
Text string
}
type (
// cancelTimerExpiredMsg is sent when the cancel timer expires.
cancelTimerExpiredMsg struct{}
// userCommandsLoadedMsg is sent when user commands are loaded.
userCommandsLoadedMsg struct {
Commands []commands.CustomCommand
}
// mcpPromptsLoadedMsg is sent when mcp prompts are loaded.
mcpPromptsLoadedMsg struct {
Prompts []commands.MCPPrompt
}
// mcpStateChangedMsg is sent when there is a change in MCP client states.
mcpStateChangedMsg struct {
states map[string]mcp.ClientInfo
}
// sendMessageMsg is sent to send a message.
// currently only used for mcp prompts.
sendMessageMsg struct {
Content string
Attachments []message.Attachment
}
// closeDialogMsg is sent to close the current dialog.
closeDialogMsg struct{}
// hyperRefreshDoneMsg is sent after a silent Hyper OAuth refresh
// finishes. It carries the original model-selection action so the
// selection can be resumed.
hyperRefreshDoneMsg struct {
action dialog.ActionSelectModel
}
// copyChatHighlightMsg is sent to copy the current chat highlight to clipboard.
copyChatHighlightMsg struct{}
// sessionFilesUpdatesMsg is sent when the files for this session have been updated
sessionFilesUpdatesMsg struct {
sessionFiles []SessionFile
}
// creditsUpdatedMsg is sent when the remaining Hyper credits have been
// fetched from the API.
creditsUpdatedMsg struct {
credits int
}
)
// UI represents the main user interface model.
type UI struct {
com *common.Common
session *session.Session
sessionFiles []SessionFile
// keeps track of read files while we don't have a session id
sessionFileReads []string
// initialSessionID is set when loading a specific session on startup.
initialSessionID string
// continueLastSession is set to continue the most recent session on startup.
continueLastSession bool
lastUserMessageTime int64
// The width and height of the terminal in cells.
width int
height int
layout uiLayout
isTransparent bool
focus uiFocusState
state uiState
keyMap KeyMap
keyenh tea.KeyboardEnhancementsMsg
dialog *dialog.Overlay
status *Status
// isCanceling tracks whether the user has pressed escape once to cancel.
isCanceling bool
header *header
// sendProgressBar instructs the TUI to send progress bar updates to the
// terminal.
sendProgressBar bool
progressBarEnabled bool
// caps hold different terminal capabilities that we query for.
caps common.Capabilities
// Editor components
textarea textarea.Model
// Attachment list
attachments *attachments.Attachments
readyPlaceholder string
workingPlaceholder string
// Completions state
completions *completions.Completions
completionsOpen bool
completionsStartIndex int
completionsQuery string
completionsPositionStart image.Point // x,y where user typed '@'
// Chat components
chat *Chat
// onboarding state
onboarding struct {
yesInitializeSelected bool
}
// lsp
lspStates map[string]app.LSPClientInfo
// mcp
mcpStates map[string]mcp.ClientInfo
// skills
skillStates []*skills.SkillState
// sidebarLogo keeps a cached version of the sidebar sidebarLogo.
sidebarLogo string
// Notification state
notifyBackend notification.Backend
notifyWindowFocused bool
// custom commands & mcp commands
customCommands []commands.CustomCommand
mcpPrompts []commands.MCPPrompt
// forceCompactMode tracks whether compact mode is forced by user toggle
forceCompactMode bool
// isCompact tracks whether we're currently in compact layout mode (either
// by user toggle or auto-switch based on window size)
isCompact bool
// detailsOpen tracks whether the details panel is open (in compact mode)
detailsOpen bool
// pills state
pillsExpanded bool
focusedPillSection pillSection
promptQueue int
pillsView string
// Todo spinner
todoSpinner spinner.Model
todoIsSpinning bool
// mouse highlighting related state
lastClickTime time.Time
// hyperCredits is the remaining Hyper credits, updated after each prompt.
hyperCredits *int
// Prompt history for up/down navigation through previous messages.
promptHistory struct {
messages []string
index int
draft string
}
}
// New creates a new instance of the [UI] model.
func New(com *common.Common, initialSessionID string, continueLast bool) *UI {
// Editor components
ta := textarea.New()
ta.SetStyles(com.Styles.Editor.Textarea)
ta.ShowLineNumbers = false
ta.CharLimit = -1
ta.SetVirtualCursor(false)
ta.DynamicHeight = true
ta.MinHeight = TextareaMinHeight
ta.MaxHeight = TextareaMaxHeight
ta.Focus()
ch := NewChat(com)
keyMap := DefaultKeyMap()
// Completions component
comp := completions.New(
com.Styles.Completions.Normal,
com.Styles.Completions.Focused,
com.Styles.Completions.Match,
)
todoSpinner := spinner.New(
spinner.WithSpinner(spinner.MiniDot),
spinner.WithStyle(com.Styles.Pills.TodoSpinner),
)
// Attachments component
attachments := attachments.New(
attachments.NewRenderer(
com.Styles.Attachments.Normal,
com.Styles.Attachments.Deleting,
com.Styles.Attachments.Image,
com.Styles.Attachments.Text,
),
attachments.Keymap{
DeleteMode: keyMap.Editor.AttachmentDeleteMode,
DeleteAll: keyMap.Editor.DeleteAllAttachments,
Escape: keyMap.Editor.Escape,
},
)
header := newHeader(com)
ui := &UI{
com: com,
dialog: dialog.NewOverlay(),
keyMap: keyMap,
textarea: ta,
chat: ch,
header: header,
completions: comp,
attachments: attachments,
todoSpinner: todoSpinner,
lspStates: make(map[string]app.LSPClientInfo),
mcpStates: make(map[string]mcp.ClientInfo),
notifyBackend: notification.NoopBackend{},
notifyWindowFocused: true,
initialSessionID: initialSessionID,
continueLastSession: continueLast,
skillStates: skills.GetLatestStates(),
}
status := NewStatus(com, ui)
ui.setEditorPrompt(com.Workspace.PermissionSkipRequests())
ui.randomizePlaceholders()
ui.textarea.Placeholder = ui.readyPlaceholder
ui.status = status
// Initialize compact mode from config
ui.forceCompactMode = com.Config().Options.TUI.CompactMode
// set onboarding state defaults
ui.onboarding.yesInitializeSelected = true
desiredState := uiLanding
desiredFocus := uiFocusEditor
if !com.Config().IsConfigured() {
desiredState = uiOnboarding
} else if n, _ := com.Workspace.ProjectNeedsInitialization(); n {
desiredState = uiInitialize
}
// set initial state
ui.setState(desiredState, desiredFocus)
opts := com.Config().Options
// disable indeterminate progress bar
ui.progressBarEnabled = opts.Progress == nil || *opts.Progress
// enable transparent mode
ui.isTransparent = opts.TUI.Transparent != nil && *opts.TUI.Transparent
return ui
}
// Init initializes the UI model.
func (m *UI) Init() tea.Cmd {
var cmds []tea.Cmd
if m.state == uiOnboarding {
if cmd := m.openModelsDialog(); cmd != nil {
cmds = append(cmds, cmd)
}
}
// load the user commands async
cmds = append(cmds, m.loadCustomCommands())
// load prompt history async
cmds = append(cmds, m.loadPromptHistory())
// load initial session if specified
if cmd := m.loadInitialSession(); cmd != nil {
cmds = append(cmds, cmd)
}
if m.com.IsHyper() {
cmds = append(cmds, m.fetchHyperCredits())
}
return tea.Batch(cmds...)
}
// loadInitialSession loads the initial session if one was specified on startup.
func (m *UI) loadInitialSession() tea.Cmd {
switch {
case m.state != uiLanding:
// Only load if we're in landing state (i.e., fully configured)
return nil
case m.initialSessionID != "":
return m.loadSession(m.initialSessionID)
case m.continueLastSession:
return func() tea.Msg {
sessions, err := m.com.Workspace.ListSessions(context.Background())
if err != nil || len(sessions) == 0 {
return nil
}
return m.loadSession(sessions[0].ID)()
}
default:
return nil
}
}
// sendNotification returns a command that sends a notification if allowed by policy.
func (m *UI) sendNotification(n notification.Notification) tea.Cmd {
if !m.shouldSendNotification() {
return nil
}
backend := m.notifyBackend
return func() tea.Msg {
if err := backend.Send(n); err != nil {
slog.Error("Failed to send notification", "error", err)
}
return nil
}
}
// shouldSendNotification returns true if notifications should be sent based on
// current state. Focus reporting must be supported, window must not focused,
// and notifications must not be disabled in config.
func (m *UI) shouldSendNotification() bool {
cfg := m.com.Config()
if cfg != nil && cfg.Options != nil && cfg.Options.DisableNotifications {
return false
}
return m.caps.ReportFocusEvents && !m.notifyWindowFocused
}
// setState changes the UI state and focus.
func (m *UI) setState(state uiState, focus uiFocusState) {
if state == uiLanding {
// Always turn off compact mode when going to landing
m.isCompact = false
}
m.state = state
m.focus = focus
// Changing the state may change layout, so update it.
m.updateLayoutAndSize()
}
// loadCustomCommands loads the custom commands asynchronously.
func (m *UI) loadCustomCommands() tea.Cmd {
return func() tea.Msg {
customCommands, err := commands.LoadCustomCommands(m.com.Config())
if err != nil {
slog.Error("Failed to load custom commands", "error", err)
}
return userCommandsLoadedMsg{Commands: customCommands}
}
}
// loadMCPrompts loads the MCP prompts asynchronously.
func (m *UI) loadMCPrompts() tea.Msg {
prompts, err := commands.LoadMCPPrompts()
if err != nil {
slog.Error("Failed to load MCP prompts", "error", err)
}
if prompts == nil {
// flag them as loaded even if there is none or an error
prompts = []commands.MCPPrompt{}
}
return mcpPromptsLoadedMsg{Prompts: prompts}
}
// Update handles updates to the UI model.
func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
if m.hasSession() && m.isAgentBusy() {
queueSize := m.com.Workspace.AgentQueuedPrompts(m.session.ID)
if queueSize != m.promptQueue {
m.promptQueue = queueSize
m.updateLayoutAndSize()
}
}
// Update terminal capabilities
m.caps.Update(msg)
switch msg := msg.(type) {
case tea.EnvMsg:
// Is this Windows Terminal?
if !m.sendProgressBar {
m.sendProgressBar = slices.Contains(msg, "WT_SESSION")
}
cmds = append(cmds, common.QueryCmd(uv.Environ(msg)))
case tea.ModeReportMsg:
if m.caps.ReportFocusEvents {
m.notifyBackend = notification.NewNativeBackend(notification.Icon)
}
case tea.FocusMsg:
m.notifyWindowFocused = true
case tea.BlurMsg:
m.notifyWindowFocused = false
case pubsub.Event[notify.Notification]:
if cmd := m.handleAgentNotification(msg.Payload); cmd != nil {
cmds = append(cmds, cmd)
}
case loadSessionMsg:
if m.forceCompactMode {
m.isCompact = true
}
m.setState(uiChat, m.focus)
m.session = msg.session
m.sessionFiles = msg.files
cmds = append(cmds, m.startLSPs(msg.lspFilePaths()))
msgs, err := m.com.Workspace.ListMessages(context.Background(), m.session.ID)
if err != nil {
cmds = append(cmds, util.ReportError(err))
break
}
if cmd := m.setSessionMessages(msgs); cmd != nil {
cmds = append(cmds, cmd)
}
if hasInProgressTodo(m.session.Todos) {
// only start spinner if there is an in-progress todo
if m.isAgentBusy() {
m.todoIsSpinning = true
cmds = append(cmds, m.todoSpinner.Tick)
}
m.updateLayoutAndSize()
}
// Reload prompt history for the new session.
m.historyReset()
cmds = append(cmds, m.loadPromptHistory())
m.updateLayoutAndSize()
case sessionFilesUpdatesMsg:
m.sessionFiles = msg.sessionFiles
var paths []string
for _, f := range msg.sessionFiles {
paths = append(paths, f.LatestVersion.Path)
}
cmds = append(cmds, m.startLSPs(paths))
case sendMessageMsg:
cmds = append(cmds, m.sendMessage(msg.Content, msg.Attachments...))
case userCommandsLoadedMsg:
m.customCommands = msg.Commands
dia := m.dialog.Dialog(dialog.CommandsID)
if dia == nil {
break
}
commands, ok := dia.(*dialog.Commands)
if ok {
commands.SetCustomCommands(m.customCommands)
}
case mcpStateChangedMsg:
m.mcpStates = msg.states
case mcpPromptsLoadedMsg:
m.mcpPrompts = msg.Prompts
dia := m.dialog.Dialog(dialog.CommandsID)
if dia == nil {
break
}
commands, ok := dia.(*dialog.Commands)
if ok {
commands.SetMCPPrompts(m.mcpPrompts)
}
case promptHistoryLoadedMsg:
m.promptHistory.messages = msg.messages
m.promptHistory.index = -1
m.promptHistory.draft = ""
case closeDialogMsg:
m.dialog.CloseFrontDialog()
case pubsub.Event[session.Session]:
if msg.Type == pubsub.DeletedEvent {
if m.session != nil && m.session.ID == msg.Payload.ID {
if cmd := m.newSession(); cmd != nil {
cmds = append(cmds, cmd)
}
}
break
}
if m.session != nil && msg.Payload.ID == m.session.ID {
prevHasInProgress := hasInProgressTodo(m.session.Todos)
m.session = &msg.Payload
if !prevHasInProgress && hasInProgressTodo(m.session.Todos) {
m.todoIsSpinning = true
cmds = append(cmds, m.todoSpinner.Tick)
m.updateLayoutAndSize()
}
}
case pubsub.Event[message.Message]:
// Check if this is a child session message for an agent tool.
if m.session == nil {
break
}
if msg.Payload.SessionID != m.session.ID {
// This might be a child session message from an agent tool.
if cmd := m.handleChildSessionMessage(msg); cmd != nil {
cmds = append(cmds, cmd)
}
break
}
switch msg.Type {
case pubsub.CreatedEvent:
cmds = append(cmds, m.appendSessionMessage(msg.Payload))
case pubsub.UpdatedEvent:
cmds = append(cmds, m.updateSessionMessage(msg.Payload))
case pubsub.DeletedEvent:
m.chat.RemoveMessage(msg.Payload.ID)
}
// start the spinner if there is a new message
if hasInProgressTodo(m.session.Todos) && m.isAgentBusy() && !m.todoIsSpinning {
m.todoIsSpinning = true
cmds = append(cmds, m.todoSpinner.Tick)
}
// stop the spinner if the agent is not busy anymore
if m.todoIsSpinning && !m.isAgentBusy() {
m.todoIsSpinning = false
}
// there is a number of things that could change the pills here so we want to re-render
m.renderPills()
case pubsub.Event[history.File]:
cmds = append(cmds, m.handleFileEvent(msg.Payload))
case pubsub.Event[app.LSPEvent]:
m.lspStates = app.GetLSPStates()
case pubsub.Event[skills.Event]:
m.skillStates = msg.Payload.States
case pubsub.Event[mcp.Event]:
switch msg.Payload.Type {
case mcp.EventStateChanged:
return m, tea.Batch(
m.handleStateChanged(),
m.loadMCPrompts,
)
case mcp.EventPromptsListChanged:
return m, handleMCPPromptsEvent(m.com.Workspace, msg.Payload.Name)
case mcp.EventToolsListChanged:
return m, handleMCPToolsEvent(m.com.Workspace, msg.Payload.Name)
case mcp.EventResourcesListChanged:
return m, handleMCPResourcesEvent(m.com.Workspace, msg.Payload.Name)
}
case pubsub.Event[permission.PermissionRequest]:
if cmd := m.openPermissionsDialog(msg.Payload); cmd != nil {
cmds = append(cmds, cmd)
}
if cmd := m.sendNotification(notification.Notification{
Title: "Crush is waiting...",
Message: fmt.Sprintf("Permission required to execute \"%s\"", msg.Payload.ToolName),
}); cmd != nil {
cmds = append(cmds, cmd)
}
case pubsub.Event[permission.PermissionNotification]:
m.handlePermissionNotification(msg.Payload)
case cancelTimerExpiredMsg:
m.isCanceling = false
case tea.TerminalVersionMsg:
termVersion := strings.ToLower(msg.Name)
// Only enable progress bar for the following terminals.
if !m.sendProgressBar {
m.sendProgressBar = xstrings.ContainsAnyOf(termVersion, "ghostty", "iterm2", "rio")
}
return m, nil
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
m.updateLayoutAndSize()
if m.state == uiChat && m.chat.Follow() {
if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
cmds = append(cmds, cmd)
}
}
case tea.KeyboardEnhancementsMsg:
m.keyenh = msg
if msg.SupportsKeyDisambiguation() {
m.keyMap.Models.SetHelp("ctrl+m", "models")
m.keyMap.Editor.Newline.SetHelp("shift+enter", "newline")
}
case copyChatHighlightMsg:
cmds = append(cmds, m.copyChatHighlight())
case DelayedClickMsg:
// Handle delayed single-click action (e.g., expansion).
m.chat.HandleDelayedClick(msg)
case tea.MouseClickMsg:
// Pass mouse events to dialogs first if any are open.
if m.dialog.HasDialogs() {
m.dialog.Update(msg)
return m, tea.Batch(cmds...)
}
if cmd := m.handleClickFocus(msg); cmd != nil {
cmds = append(cmds, cmd)
}
switch m.state {
case uiChat:
x, y := msg.X, msg.Y
// Adjust for chat area position
x -= m.layout.main.Min.X
y -= m.layout.main.Min.Y
if !image.Pt(msg.X, msg.Y).In(m.layout.sidebar) {
if handled, cmd := m.chat.HandleMouseDown(x, y); handled {
m.lastClickTime = time.Now()
if cmd != nil {
cmds = append(cmds, cmd)
}
}
}
}
case tea.MouseMotionMsg:
// Pass mouse events to dialogs first if any are open.
if m.dialog.HasDialogs() {
m.dialog.Update(msg)
return m, tea.Batch(cmds...)
}
switch m.state {
case uiChat:
if msg.Y <= 0 {
if cmd := m.chat.ScrollByAndAnimate(-1); cmd != nil {
cmds = append(cmds, cmd)
}
if !m.chat.SelectedItemInView() {
m.chat.SelectPrev()
if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
cmds = append(cmds, cmd)
}
}
} else if msg.Y >= m.chat.Height()-1 {
if cmd := m.chat.ScrollByAndAnimate(1); cmd != nil {
cmds = append(cmds, cmd)
}
if !m.chat.SelectedItemInView() {
m.chat.SelectNext()
if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
cmds = append(cmds, cmd)
}
}
}
x, y := msg.X, msg.Y
// Adjust for chat area position
x -= m.layout.main.Min.X
y -= m.layout.main.Min.Y
m.chat.HandleMouseDrag(x, y)
}
case tea.MouseReleaseMsg:
// Pass mouse events to dialogs first if any are open.
if m.dialog.HasDialogs() {
m.dialog.Update(msg)
return m, tea.Batch(cmds...)
}
switch m.state {
case uiChat:
x, y := msg.X, msg.Y
// Adjust for chat area position
x -= m.layout.main.Min.X
y -= m.layout.main.Min.Y
if m.chat.HandleMouseUp(x, y) && m.chat.HasHighlight() {
cmds = append(cmds, tea.Tick(doubleClickThreshold, func(t time.Time) tea.Msg {
if time.Since(m.lastClickTime) >= doubleClickThreshold {
return copyChatHighlightMsg{}
}
return nil
}))
}
}
case tea.MouseWheelMsg:
// Pass mouse events to dialogs first if any are open.
if m.dialog.HasDialogs() {
m.dialog.Update(msg)
return m, tea.Batch(cmds...)
}
// Otherwise handle mouse wheel for chat.
switch m.state {
case uiChat:
switch msg.Button {
case tea.MouseWheelUp:
if cmd := m.chat.ScrollByAndAnimate(-MouseScrollThreshold); cmd != nil {
cmds = append(cmds, cmd)
}
if !m.chat.SelectedItemInView() {
m.chat.SelectPrev()
if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
cmds = append(cmds, cmd)
}
}
case tea.MouseWheelDown:
if cmd := m.chat.ScrollByAndAnimate(MouseScrollThreshold); cmd != nil {
cmds = append(cmds, cmd)
}
if !m.chat.SelectedItemInView() {
if m.chat.AtBottom() {
m.chat.SelectLast()
} else {
m.chat.SelectNext()
}
if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
cmds = append(cmds, cmd)
}
}
}
}
case anim.StepMsg:
if m.state == uiChat {
if cmd := m.chat.Animate(msg); cmd != nil {
cmds = append(cmds, cmd)
}
if m.chat.Follow() {
if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
cmds = append(cmds, cmd)
}
}
}
case spinner.TickMsg:
if m.dialog.HasDialogs() {
// route to dialog
if cmd := m.handleDialogMsg(msg); cmd != nil {
cmds = append(cmds, cmd)
}
}
if m.state == uiChat && m.hasSession() && hasInProgressTodo(m.session.Todos) && m.todoIsSpinning {
var cmd tea.Cmd
m.todoSpinner, cmd = m.todoSpinner.Update(msg)
if cmd != nil {
m.renderPills()
cmds = append(cmds, cmd)
}
}
case tea.KeyPressMsg:
if cmd := m.handleKeyPressMsg(msg); cmd != nil {
cmds = append(cmds, cmd)
}
case tea.PasteMsg:
if cmd := m.handlePasteMsg(msg); cmd != nil {
cmds = append(cmds, cmd)
}
case openEditorMsg:
prevHeight := m.textarea.Height()
m.textarea.SetValue(msg.Text)
m.textarea.MoveToEnd()
cmds = append(cmds, m.updateTextareaWithPrevHeight(msg, prevHeight))
case hyperRefreshDoneMsg:
if cmd := m.handleSelectModel(msg.action); cmd != nil {
cmds = append(cmds, cmd)
}
case creditsUpdatedMsg:
m.hyperCredits = &msg.credits
case util.InfoMsg:
if msg.Type == util.InfoTypeError {
slog.Error("Error reported", "error", msg.Msg)
}
m.status.SetInfoMsg(msg)
ttl := msg.TTL
if ttl <= 0 {
ttl = DefaultStatusTTL
}
cmds = append(cmds, clearInfoMsgCmd(ttl))
case app.UpdateAvailableMsg:
text := fmt.Sprintf("Crush update available: v%s → v%s.", msg.CurrentVersion, msg.LatestVersion)
if msg.IsDevelopment {
text = fmt.Sprintf("This is a development version of Crush. The latest version is v%s.", msg.LatestVersion)
}
ttl := 10 * time.Second
m.status.SetInfoMsg(util.InfoMsg{
Type: util.InfoTypeUpdate,
Msg: text,
TTL: ttl,
})
cmds = append(cmds, clearInfoMsgCmd(ttl))
case util.ClearStatusMsg:
m.status.ClearInfoMsg()
case completions.CompletionItemsLoadedMsg:
if m.completionsOpen {
m.completions.SetItems(msg.Files, msg.Resources)
}
case uv.KittyGraphicsEvent:
if !bytes.HasPrefix(msg.Payload, []byte("OK")) {
slog.Warn("Unexpected Kitty graphics response",
"response", string(msg.Payload),
"options", msg.Options)
}
default:
if m.dialog.HasDialogs() {
if cmd := m.handleDialogMsg(msg); cmd != nil {
cmds = append(cmds, cmd)
}
}
}
// This logic gets triggered on any message type, but should it?
switch m.focus {
case uiFocusMain:
case uiFocusEditor:
// Textarea placeholder logic
if m.isAgentBusy() {
m.textarea.Placeholder = m.workingPlaceholder
} else {
m.textarea.Placeholder = m.readyPlaceholder
}
if m.com.Workspace.PermissionSkipRequests() {
m.textarea.Placeholder = "Yolo mode!"
}
}
// at this point this can only handle [message.Attachment] message, and we
// should return all cmds anyway.
_ = m.attachments.Update(msg)
return m, tea.Batch(cmds...)
}
// setSessionMessages sets the messages for the current session in the chat
func (m *UI) setSessionMessages(msgs []message.Message) tea.Cmd {
var cmds []tea.Cmd
// Build tool result map to link tool calls with their results
msgPtrs := make([]*message.Message, len(msgs))
for i := range msgs {
msgPtrs[i] = &msgs[i]
}
toolResultMap := chat.BuildToolResultMap(msgPtrs)
if len(msgPtrs) > 0 {
m.lastUserMessageTime = msgPtrs[0].CreatedAt
}
// Add messages to chat with linked tool results
items := make([]chat.MessageItem, 0, len(msgs)*2)
for _, msg := range msgPtrs {
switch msg.Role {
case message.User:
m.lastUserMessageTime = msg.CreatedAt
items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...)
case message.Assistant:
items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...)
if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
infoItem := chat.NewAssistantInfoItem(m.com.Styles, msg, m.com.Config(), time.Unix(m.lastUserMessageTime, 0))
items = append(items, infoItem)
}
default:
items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...)
}
}
// Load nested tool calls for agent/agentic_fetch tools.
m.loadNestedToolCalls(items)
// If the user switches between sessions while the agent is working we want
// to make sure the animations are shown.
for _, item := range items {
if animatable, ok := item.(chat.Animatable); ok {
if cmd := animatable.StartAnimation(); cmd != nil {
cmds = append(cmds, cmd)
}
}
}
m.chat.SetMessages(items...)
if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
cmds = append(cmds, cmd)
}
m.chat.SelectLast()
return tea.Sequence(cmds...)
}
// loadNestedToolCalls recursively loads nested tool calls for agent/agentic_fetch tools.
func (m *UI) loadNestedToolCalls(items []chat.MessageItem) {
for _, item := range items {
nestedContainer, ok := item.(chat.NestedToolContainer)
if !ok {
continue
}
toolItem, ok := item.(chat.ToolMessageItem)
if !ok {
continue
}
tc := toolItem.ToolCall()
messageID := toolItem.MessageID()
// Get the agent tool session ID.
agentSessionID := m.com.Workspace.CreateAgentToolSessionID(messageID, tc.ID)
// Fetch nested messages.
nestedMsgs, err := m.com.Workspace.ListMessages(context.Background(), agentSessionID)
if err != nil || len(nestedMsgs) == 0 {
continue
}
// Build tool result map for nested messages.
nestedMsgPtrs := make([]*message.Message, len(nestedMsgs))
for i := range nestedMsgs {
nestedMsgPtrs[i] = &nestedMsgs[i]
}
nestedToolResultMap := chat.BuildToolResultMap(nestedMsgPtrs)
// Extract nested tool items.
var nestedTools []chat.ToolMessageItem
for _, nestedMsg := range nestedMsgPtrs {
nestedItems := chat.ExtractMessageItems(m.com.Styles, nestedMsg, nestedToolResultMap)
for _, nestedItem := range nestedItems {
if nestedToolItem, ok := nestedItem.(chat.ToolMessageItem); ok {
// Mark nested tools as simple (compact) rendering.
if simplifiable, ok := nestedToolItem.(chat.Compactable); ok {
simplifiable.SetCompact(true)
}
nestedTools = append(nestedTools, nestedToolItem)
}
}
}
// Recursively load nested tool calls for any agent tools within.
nestedMessageItems := make([]chat.MessageItem, len(nestedTools))
for i, nt := range nestedTools {
nestedMessageItems[i] = nt
}
m.loadNestedToolCalls(nestedMessageItems)
// Set nested tools on the parent.
nestedContainer.SetNestedTools(nestedTools)
}
}
// appendSessionMessage appends a new message to the current session in the chat
// if the message is a tool result it will update the corresponding tool call message
func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
var cmds []tea.Cmd
existing := m.chat.MessageItem(msg.ID)
if existing != nil {
// message already exists, skip
return nil
}
switch msg.Role {
case message.User:
m.lastUserMessageTime = msg.CreatedAt
items := chat.ExtractMessageItems(m.com.Styles, &msg, nil)
for _, item := range items {
if animatable, ok := item.(chat.Animatable); ok {
if cmd := animatable.StartAnimation(); cmd != nil {
cmds = append(cmds, cmd)
}
}
}
m.chat.AppendMessages(items...)
if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
cmds = append(cmds, cmd)
}
case message.Assistant:
items := chat.ExtractMessageItems(m.com.Styles, &msg, nil)
for _, item := range items {
if animatable, ok := item.(chat.Animatable); ok {
if cmd := animatable.StartAnimation(); cmd != nil {
cmds = append(cmds, cmd)
}
}
}
m.chat.AppendMessages(items...)
if m.chat.Follow() {
if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
cmds = append(cmds, cmd)
}
}
if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
infoItem := chat.NewAssistantInfoItem(m.com.Styles, &msg, m.com.Config(), time.Unix(m.lastUserMessageTime, 0))
m.chat.AppendMessages(infoItem)
if m.chat.Follow() {
if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
cmds = append(cmds, cmd)
}
}
}
case message.Tool:
for _, tr := range msg.ToolResults() {
toolItem := m.chat.MessageItem(tr.ToolCallID)
if toolItem == nil {
// we should have an item!
continue
}
if toolMsgItem, ok := toolItem.(chat.ToolMessageItem); ok {
toolMsgItem.SetResult(&tr)
if m.chat.Follow() {
if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
cmds = append(cmds, cmd)
}
}
}
}
}
return tea.Sequence(cmds...)
}
func (m *UI) handleClickFocus(msg tea.MouseClickMsg) (cmd tea.Cmd) {
switch {
case m.state != uiChat:
return nil
case image.Pt(msg.X, msg.Y).In(m.layout.sidebar):
return nil
case m.focus != uiFocusEditor && image.Pt(msg.X, msg.Y).In(m.layout.editor):
m.focus = uiFocusEditor
cmd = m.textarea.Focus()
m.chat.Blur()
case m.focus != uiFocusMain && image.Pt(msg.X, msg.Y).In(m.layout.main):
m.focus = uiFocusMain
m.textarea.Blur()
m.chat.Focus()
}
return cmd
}
// updateSessionMessage updates an existing message in the current session in
// the chat when an assistant message is updated it may include updated tool
// calls as well that is why we need to handle creating/updating each tool call
// message too.
func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd {
var cmds []tea.Cmd
existingItem := m.chat.MessageItem(msg.ID)
if existingItem != nil {
if assistantItem, ok := existingItem.(*chat.AssistantMessageItem); ok {
assistantItem.SetMessage(&msg)
}
}
shouldRenderAssistant := chat.ShouldRenderAssistantMessage(&msg)
isEndTurn := msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn
// If the message of the assistant does not have any response just tool
// calls we need to remove it, but keep the info item for end-of-turn
// renders so the footer (model/provider/duration) remains visible when,
// for example, a hook halts the turn.
if !shouldRenderAssistant && len(msg.ToolCalls()) > 0 && existingItem != nil {
m.chat.RemoveMessage(msg.ID)
if !isEndTurn {
if infoItem := m.chat.MessageItem(chat.AssistantInfoID(msg.ID)); infoItem != nil {
m.chat.RemoveMessage(chat.AssistantInfoID(msg.ID))
}
}
}
if isEndTurn {
if infoItem := m.chat.MessageItem(chat.AssistantInfoID(msg.ID)); infoItem == nil {
newInfoItem := chat.NewAssistantInfoItem(m.com.Styles, &msg, m.com.Config(), time.Unix(m.lastUserMessageTime, 0))
m.chat.AppendMessages(newInfoItem)
}
}
var items []chat.MessageItem
for _, tc := range msg.ToolCalls() {
existingToolItem := m.chat.MessageItem(tc.ID)
if toolItem, ok := existingToolItem.(chat.ToolMessageItem); ok {
existingToolCall := toolItem.ToolCall()
// only update if finished state changed or input changed
// to avoid clearing the cache
if (tc.Finished && !existingToolCall.Finished) || tc.Input != existingToolCall.Input {
toolItem.SetToolCall(tc)
}
}
if existingToolItem == nil {
items = append(items, chat.NewToolMessageItem(m.com.Styles, msg.ID, tc, nil, false))
}
}
for _, item := range items {
if animatable, ok := item.(chat.Animatable); ok {
if cmd := animatable.StartAnimation(); cmd != nil {
cmds = append(cmds, cmd)
}
}
}
m.chat.AppendMessages(items...)
if m.chat.Follow() {
if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
cmds = append(cmds, cmd)
}
m.chat.SelectLast()
}
return tea.Sequence(cmds...)
}
// handleChildSessionMessage handles messages from child sessions (agent tools).
func (m *UI) handleChildSessionMessage(event pubsub.Event[message.Message]) tea.Cmd {
var cmds []tea.Cmd
// Only process messages with tool calls or results.
if len(event.Payload.ToolCalls()) == 0 && len(event.Payload.ToolResults()) == 0 {
return nil
}
// Check if this is an agent tool session and parse it.
childSessionID := event.Payload.SessionID
_, toolCallID, ok := m.com.Workspace.ParseAgentToolSessionID(childSessionID)
if !ok {
return nil
}
// Find the parent agent tool item.
var agentItem chat.NestedToolContainer
for i := 0; i < m.chat.Len(); i++ {
item := m.chat.MessageItem(toolCallID)
if item == nil {
continue
}
if agent, ok := item.(chat.NestedToolContainer); ok {
if toolMessageItem, ok := item.(chat.ToolMessageItem); ok {
if toolMessageItem.ToolCall().ID == toolCallID {
// Verify this agent belongs to the correct parent message.
// We can't directly check parentMessageID on the item, so we trust the session parsing.
agentItem = agent
break
}
}
}
}
if agentItem == nil {
return nil
}
// Get existing nested tools.
nestedTools := agentItem.NestedTools()
// Update or create nested tool calls.
for _, tc := range event.Payload.ToolCalls() {
found := false
for _, existingTool := range nestedTools {
if existingTool.ToolCall().ID == tc.ID {
existingTool.SetToolCall(tc)
found = true
break
}
}
if !found {
// Create a new nested tool item.
nestedItem := chat.NewToolMessageItem(m.com.Styles, event.Payload.ID, tc, nil, false)
if simplifiable, ok := nestedItem.(chat.Compactable); ok {
simplifiable.SetCompact(true)
}
if animatable, ok := nestedItem.(chat.Animatable); ok {
if cmd := animatable.StartAnimation(); cmd != nil {
cmds = append(cmds, cmd)
}
}
nestedTools = append(nestedTools, nestedItem)
}
}
// Update nested tool results.
for _, tr := range event.Payload.ToolResults() {
for _, nestedTool := range nestedTools {
if nestedTool.ToolCall().ID == tr.ToolCallID {
nestedTool.SetResult(&tr)
break
}
}
}
// Update the agent item with the new nested tools.
agentItem.SetNestedTools(nestedTools)
// Update the chat so it updates the index map for animations to work as expected
m.chat.UpdateNestedToolIDs(toolCallID)
if m.chat.Follow() {
if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
cmds = append(cmds, cmd)
}
m.chat.SelectLast()
}
return tea.Sequence(cmds...)
}
func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
var cmds []tea.Cmd
action := m.dialog.Update(msg)
if action == nil {
return tea.Batch(cmds...)
}
isOnboarding := m.state == uiOnboarding
switch msg := action.(type) {
// Generic dialog messages
case dialog.ActionClose:
if isOnboarding && m.dialog.ContainsDialog(dialog.ModelsID) {
break
}
if m.dialog.ContainsDialog(dialog.FilePickerID) {
defer fimage.ResetCache()
}
m.dialog.CloseFrontDialog()
if isOnboarding {
if cmd := m.openModelsDialog(); cmd != nil {
cmds = append(cmds, cmd)
}
}
if m.focus == uiFocusEditor {
cmds = append(cmds, m.textarea.Focus())
}
case dialog.ActionCmd:
if msg.Cmd != nil {
cmds = append(cmds, msg.Cmd)
}
// Session dialog messages.
case dialog.ActionSelectSession:
m.dialog.CloseDialog(dialog.SessionsID)
cmds = append(cmds, m.loadSession(msg.Session.ID))
// Open dialog message.
case dialog.ActionOpenDialog:
m.dialog.CloseDialog(dialog.CommandsID)
if cmd := m.openDialog(msg.DialogID); cmd != nil {
cmds = append(cmds, cmd)
}
// Command dialog messages.
case dialog.ActionToggleYoloMode:
yolo := !m.com.Workspace.PermissionSkipRequests()
m.com.Workspace.PermissionSetSkipRequests(yolo)
m.setEditorPrompt(yolo)
m.dialog.CloseDialog(dialog.CommandsID)
case dialog.ActionToggleNotifications:
cfg := m.com.Config()
if cfg != nil && cfg.Options != nil {
disabled := !cfg.Options.DisableNotifications
cfg.Options.DisableNotifications = disabled
if err := m.com.Workspace.SetConfigField(config.ScopeGlobal, "options.disable_notifications", disabled); err != nil {
cmds = append(cmds, util.ReportError(err))
} else {
status := "enabled"
if disabled {
status = "disabled"
}
cmds = append(cmds, util.CmdHandler(util.NewInfoMsg("Notifications "+status)))
}
}
m.dialog.CloseDialog(dialog.CommandsID)
case dialog.ActionNewSession:
if m.isAgentBusy() {
cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before starting a new session..."))
break
}
if cmd := m.newSession(); cmd != nil {
cmds = append(cmds, cmd)
}
m.dialog.CloseDialog(dialog.CommandsID)
case dialog.ActionSummarize:
if m.isAgentBusy() {
cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before summarizing session..."))
break
}
cmds = append(cmds, func() tea.Msg {
err := m.com.Workspace.AgentSummarize(context.Background(), msg.SessionID)
if err != nil {
return util.ReportError(err)()
}
return nil
})
m.dialog.CloseDialog(dialog.CommandsID)
case dialog.ActionToggleHelp:
m.status.ToggleHelp()
m.dialog.CloseDialog(dialog.CommandsID)
case dialog.ActionExternalEditor:
if m.isAgentBusy() {
cmds = append(cmds, util.ReportWarn("Agent is working, please wait..."))
break
}
cmds = append(cmds, m.openEditor(m.textarea.Value()))
m.dialog.CloseDialog(dialog.CommandsID)
case dialog.ActionToggleCompactMode:
cmds = append(cmds, m.toggleCompactMode())
m.dialog.CloseDialog(dialog.CommandsID)
case dialog.ActionTogglePills:
if cmd := m.togglePillsExpanded(); cmd != nil {
cmds = append(cmds, cmd)
}
m.dialog.CloseDialog(dialog.CommandsID)
case dialog.ActionToggleThinking:
cmds = append(cmds, func() tea.Msg {
cfg := m.com.Config()
if cfg == nil {
return util.ReportError(errors.New("configuration not found"))()
}
agentCfg, ok := cfg.Agents[config.AgentCoder]
if !ok {
return util.ReportError(errors.New("agent configuration not found"))()
}
currentModel := cfg.Models[agentCfg.Model]
currentModel.Think = !currentModel.Think
if err := m.com.Workspace.UpdatePreferredModel(config.ScopeGlobal, agentCfg.Model, currentModel); err != nil {
return util.ReportError(err)()
}
m.com.Workspace.UpdateAgentModel(context.TODO())
status := "disabled"
if currentModel.Think {
status = "enabled"
}
return util.NewInfoMsg("Thinking mode " + status)
})
m.dialog.CloseDialog(dialog.CommandsID)
case dialog.ActionToggleTransparentBackground:
cmds = append(cmds, func() tea.Msg {
cfg := m.com.Config()
if cfg == nil {
return util.ReportError(errors.New("configuration not found"))()
}
isTransparent := cfg.Options != nil && cfg.Options.TUI.Transparent != nil && *cfg.Options.TUI.Transparent
newValue := !isTransparent
if err := m.com.Workspace.SetConfigField(config.ScopeGlobal, "options.tui.transparent", newValue); err != nil {
return util.ReportError(err)()
}
m.isTransparent = newValue
status := "disabled"
if newValue {
status = "enabled"
}
return util.NewInfoMsg("Transparent background " + status)
})
m.dialog.CloseDialog(dialog.CommandsID)
case dialog.ActionQuit:
cmds = append(cmds, tea.Quit)
case dialog.ActionEnableDockerMCP:
m.dialog.CloseDialog(dialog.CommandsID)
cmds = append(cmds, m.enableDockerMCP)
case dialog.ActionDisableDockerMCP:
m.dialog.CloseDialog(dialog.CommandsID)
cmds = append(cmds, m.disableDockerMCP)
case dialog.ActionInitializeProject:
if m.isAgentBusy() {
cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before summarizing session..."))
break
}
cmds = append(cmds, m.initializeProject())
m.dialog.CloseDialog(dialog.CommandsID)
case dialog.ActionSelectModel:
if cmd := m.handleSelectModel(msg); cmd != nil {
cmds = append(cmds, cmd)
}
case dialog.ActionSelectReasoningEffort:
if m.isAgentBusy() {
cmds = append(cmds, util.ReportWarn("Agent is busy, please wait..."))
break
}
cfg := m.com.Config()
if cfg == nil {
cmds = append(cmds, util.ReportError(errors.New("configuration not found")))
break
}
agentCfg, ok := cfg.Agents[config.AgentCoder]
if !ok {
cmds = append(cmds, util.ReportError(errors.New("agent configuration not found")))
break
}
currentModel := cfg.Models[agentCfg.Model]
currentModel.ReasoningEffort = msg.Effort
if err := m.com.Workspace.UpdatePreferredModel(config.ScopeGlobal, agentCfg.Model, currentModel); err != nil {
cmds = append(cmds, util.ReportError(err))
break
}
cmds = append(cmds, func() tea.Msg {
m.com.Workspace.UpdateAgentModel(context.TODO())
return util.NewInfoMsg("Reasoning effort set to " + msg.Effort)
})
m.dialog.CloseDialog(dialog.ReasoningID)
case dialog.ActionPermissionResponse:
m.dialog.CloseDialog(dialog.PermissionsID)
switch msg.Action {
case dialog.PermissionAllow:
m.com.Workspace.PermissionGrant(msg.Permission)
case dialog.PermissionAllowForSession:
m.com.Workspace.PermissionGrantPersistent(msg.Permission)
case dialog.PermissionDeny:
m.com.Workspace.PermissionDeny(msg.Permission)
}
case dialog.ActionFilePickerSelected:
cmds = append(cmds, tea.Sequence(
msg.Cmd(),
func() tea.Msg {
m.dialog.CloseDialog(dialog.FilePickerID)
return nil
},
func() tea.Msg {
fimage.ResetCache()
return nil
},
))
case dialog.ActionRunCustomCommand:
if len(msg.Arguments) > 0 && msg.Args == nil {
m.dialog.CloseFrontDialog()
argsDialog := dialog.NewArguments(
m.com,
"Custom Command Arguments",
"",
msg.Arguments,
msg, // Pass the action as the result
)
m.dialog.OpenDialog(argsDialog)
break
}
content := msg.Content
if msg.Args != nil {
content = substituteArgs(content, msg.Args)
}
cmds = append(cmds, m.sendMessage(content))
m.dialog.CloseFrontDialog()
case dialog.ActionRunMCPPrompt:
if len(msg.Arguments) > 0 && msg.Args == nil {
m.dialog.CloseFrontDialog()
title := cmp.Or(msg.Title, "MCP Prompt Arguments")
argsDialog := dialog.NewArguments(
m.com,
title,
msg.Description,
msg.Arguments,
msg, // Pass the action as the result
)
m.dialog.OpenDialog(argsDialog)
break
}
cmds = append(cmds, m.runMCPPrompt(msg.ClientID, msg.PromptID, msg.Args))
default:
cmds = append(cmds, util.CmdHandler(msg))
}
return tea.Batch(cmds...)
}
// substituteArgs replaces $ARG_NAME placeholders in content with actual values.
func substituteArgs(content string, args map[string]string) string {
for name, value := range args {
placeholder := "$" + name
content = strings.ReplaceAll(content, placeholder, value)
}
return content
}
// refreshHyperAndRetrySelect returns a command that silently refreshes
// the Hyper OAuth token and then re-runs the model selection. If the
// refresh fails, the selection resumes with ReAuthenticate set so the
// OAuth dialog opens.
func (m *UI) refreshHyperAndRetrySelect(msg dialog.ActionSelectModel) tea.Cmd {
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := m.com.Workspace.RefreshOAuthToken(ctx, config.ScopeGlobal, "hyper"); err != nil {
slog.Warn("Hyper OAuth refresh failed, requesting re-auth", "error", err)
msg.ReAuthenticate = true
}
return hyperRefreshDoneMsg{action: msg}
}
}
// fetchHyperCredits returns a command that asynchronously fetches the
// remaining Hyper credits from the API.
func (m *UI) fetchHyperCredits() tea.Cmd {
return func() tea.Msg {
cfg := m.com.Config()
if cfg == nil {
return nil
}
providerCfg, ok := cfg.Providers.Get(hyper.Name)
if !ok {
return nil
}
apiKey, err := m.com.Workspace.Resolver().ResolveValue(providerCfg.APIKey)
if err != nil || apiKey == "" {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
credits, err := hyper.FetchCredits(ctx, apiKey)
if err != nil {
slog.Error("Failed to fetch Hyper credits", "error", err)
return nil
}
return creditsUpdatedMsg{credits: credits}
}
}
// handleSelectModel performs the model selection after any provider
// pre-checks (such as a silent Hyper OAuth refresh) have completed.
func (m *UI) handleSelectModel(msg dialog.ActionSelectModel) tea.Cmd {
var cmds []tea.Cmd
// we ignore dialogs with the oauth id as they need to be able to be dismissed
if m.isAgentBusy() && !m.dialog.ContainsDialog(dialog.OAuthID) {
return util.ReportWarn("Agent is busy, please wait...")
}
cfg := m.com.Config()
if cfg == nil {
return util.ReportError(errors.New("configuration not found"))
}
var (
providerID = msg.Model.Provider
isCopilot = providerID == string(catwalk.InferenceProviderCopilot)
isConfigured = func() bool { _, ok := cfg.Providers.Get(providerID); return ok }
isOnboarding = m.state == uiOnboarding
)
// For Hyper, if the stored OAuth token is expired, try a silent
// refresh before deciding whether the provider is configured. Keeps
// users from hitting a 401 on their first message after the
// short-lived access token ages out.
if !msg.ReAuthenticate && providerID == "hyper" {
if pc, ok := cfg.Providers.Get(providerID); ok && pc.OAuthToken != nil && pc.OAuthToken.IsExpired() {
return m.refreshHyperAndRetrySelect(msg)
}
}
// Attempt to import GitHub Copilot tokens from VSCode if available.
if isCopilot && !isConfigured() && !msg.ReAuthenticate {
m.com.Workspace.ImportCopilot()
}
if !isConfigured() || msg.ReAuthenticate {
m.dialog.CloseDialog(dialog.ModelsID)
if cmd := m.openAuthenticationDialog(msg.Provider, msg.Model, msg.ModelType); cmd != nil {
cmds = append(cmds, cmd)
}
return tea.Batch(cmds...)
}
if err := m.com.Workspace.UpdatePreferredModel(config.ScopeGlobal, msg.ModelType, msg.Model); err != nil {
cmds = append(cmds, util.ReportError(err))
} else {
if msg.ModelType == config.SelectedModelTypeLarge {
// Swap the theme live based on the newly selected large
// model's provider.
m.applyTheme(styles.ThemeForProvider(providerID))
}
if _, ok := cfg.Models[config.SelectedModelTypeSmall]; !ok {
// Ensure small model is set is unset.
smallModel := m.com.Workspace.GetDefaultSmallModel(providerID)
if err := m.com.Workspace.UpdatePreferredModel(config.ScopeGlobal, config.SelectedModelTypeSmall, smallModel); err != nil {
cmds = append(cmds, util.ReportError(err))
}
}
}
cmds = append(cmds, func() tea.Msg {
if err := m.com.Workspace.UpdateAgentModel(context.TODO()); err != nil {
return util.ReportError(err)
}
modelMsg := fmt.Sprintf("%s model changed to %s", msg.ModelType, msg.Model.Model)
return util.NewInfoMsg(modelMsg)
})
m.dialog.CloseDialog(dialog.APIKeyInputID)
m.dialog.CloseDialog(dialog.OAuthID)
m.dialog.CloseDialog(dialog.ModelsID)
if isOnboarding {
m.setState(uiLanding, uiFocusEditor)
m.com.Config().SetupAgents()
if err := m.com.Workspace.InitCoderAgent(context.TODO()); err != nil {
cmds = append(cmds, util.ReportError(err))
}
} else if m.com.IsHyper() {
cmds = append(cmds, m.fetchHyperCredits())
}
return tea.Batch(cmds...)
}
func (m *UI) openAuthenticationDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd {
var (
dlg dialog.Dialog
cmd tea.Cmd
isOnboarding = m.state == uiOnboarding
)
switch provider.ID {
case "hyper":
dlg, cmd = dialog.NewOAuthHyper(m.com, isOnboarding, provider, model, modelType)
case catwalk.InferenceProviderCopilot:
dlg, cmd = dialog.NewOAuthCopilot(m.com, isOnboarding, provider, model, modelType)
default:
dlg, cmd = dialog.NewAPIKeyInput(m.com, isOnboarding, provider, model, modelType)
}
if m.dialog.ContainsDialog(dlg.ID()) {
m.dialog.BringToFront(dlg.ID())
return nil
}
m.dialog.OpenDialog(dlg)
return cmd
}
func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
var cmds []tea.Cmd
handleGlobalKeys := func(msg tea.KeyPressMsg) bool {
switch {
case key.Matches(msg, m.keyMap.Help):
m.status.ToggleHelp()
m.updateLayoutAndSize()
return true
case key.Matches(msg, m.keyMap.Commands):
if cmd := m.openCommandsDialog(); cmd != nil {
cmds = append(cmds, cmd)
}
return true
case key.Matches(msg, m.keyMap.Models):
if cmd := m.openModelsDialog(); cmd != nil {
cmds = append(cmds, cmd)
}
return true
case key.Matches(msg, m.keyMap.Sessions):
if cmd := m.openSessionsDialog(); cmd != nil {
cmds = append(cmds, cmd)
}
return true
case key.Matches(msg, m.keyMap.Chat.Details) && m.isCompact:
m.detailsOpen = !m.detailsOpen
m.updateLayoutAndSize()
return true
case key.Matches(msg, m.keyMap.Chat.TogglePills):
if m.state == uiChat && m.hasSession() {
if cmd := m.togglePillsExpanded(); cmd != nil {
cmds = append(cmds, cmd)
}
return true
}
case key.Matches(msg, m.keyMap.Chat.PillLeft):
if m.state == uiChat && m.hasSession() && m.pillsExpanded && m.focus != uiFocusEditor {
if cmd := m.switchPillSection(-1); cmd != nil {
cmds = append(cmds, cmd)
}
return true
}
case key.Matches(msg, m.keyMap.Chat.PillRight):
if m.state == uiChat && m.hasSession() && m.pillsExpanded && m.focus != uiFocusEditor {
if cmd := m.switchPillSection(1); cmd != nil {
cmds = append(cmds, cmd)
}
return true
}
case key.Matches(msg, m.keyMap.Suspend):
if m.isAgentBusy() {
cmds = append(cmds, util.ReportWarn("Agent is busy, please wait..."))
return true
}
cmds = append(cmds, tea.Suspend)
return true
}
return false
}
if key.Matches(msg, m.keyMap.Quit) && !m.dialog.ContainsDialog(dialog.QuitID) {
// Always handle quit keys first
if cmd := m.openQuitDialog(); cmd != nil {
cmds = append(cmds, cmd)
}
return tea.Batch(cmds...)
}
// Route all messages to dialog if one is open.
if m.dialog.HasDialogs() {
return m.handleDialogMsg(msg)
}
// Handle cancel key when agent is busy.
if key.Matches(msg, m.keyMap.Chat.Cancel) {
if m.isAgentBusy() {
if cmd := m.cancelAgent(); cmd != nil {
cmds = append(cmds, cmd)
}
return tea.Batch(cmds...)
}
}
switch m.state {
case uiOnboarding:
return tea.Batch(cmds...)
case uiInitialize:
cmds = append(cmds, m.updateInitializeView(msg)...)
return tea.Batch(cmds...)
case uiChat, uiLanding:
switch m.focus {
case uiFocusEditor:
// Handle completions if open.
if m.completionsOpen {
if msg, ok := m.completions.Update(msg); ok {
switch msg := msg.(type) {
case completions.SelectionMsg[completions.FileCompletionValue]:
cmds = append(cmds, m.insertFileCompletion(msg.Value.Path))
if !msg.KeepOpen {
m.closeCompletions()
}
case completions.SelectionMsg[completions.ResourceCompletionValue]:
cmds = append(cmds, m.insertMCPResourceCompletion(msg.Value))
if !msg.KeepOpen {
m.closeCompletions()
}
case completions.ClosedMsg:
m.completionsOpen = false
}
return tea.Batch(cmds...)
}
}
if ok := m.attachments.Update(msg); ok {
return tea.Batch(cmds...)
}
switch {
case key.Matches(msg, m.keyMap.Editor.AddImage):
if !m.currentModelSupportsImages() {
break
}
if cmd := m.openFilesDialog(); cmd != nil {
cmds = append(cmds, cmd)
}
case key.Matches(msg, m.keyMap.Editor.PasteImage):
if !m.currentModelSupportsImages() {
break
}
cmds = append(cmds, m.pasteImageFromClipboard)
case key.Matches(msg, m.keyMap.Editor.SendMessage):
prevHeight := m.textarea.Height()
value := m.textarea.Value()
if before, ok := strings.CutSuffix(value, "\\"); ok {
// If the last character is a backslash, remove it and add a newline.
m.textarea.SetValue(before)
if cmd := m.handleTextareaHeightChange(prevHeight); cmd != nil {
cmds = append(cmds, cmd)
}
break
}
// Otherwise, send the message
m.textarea.Reset()
if cmd := m.handleTextareaHeightChange(prevHeight); cmd != nil {
cmds = append(cmds, cmd)
}
value = strings.TrimSpace(value)
if value == "exit" || value == "quit" {
return m.openQuitDialog()
}
attachments := m.attachments.List()
m.attachments.Reset()
if len(value) == 0 && !message.ContainsTextAttachment(attachments) {
return nil
}
m.randomizePlaceholders()
m.historyReset()
return tea.Batch(m.sendMessage(value, attachments...), m.loadPromptHistory())
case key.Matches(msg, m.keyMap.Chat.NewSession):
if !m.hasSession() {
break
}
if m.isAgentBusy() {
cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before starting a new session..."))
break
}
if cmd := m.newSession(); cmd != nil {
cmds = append(cmds, cmd)
}
case key.Matches(msg, m.keyMap.Tab):
if m.state != uiLanding {
m.setState(m.state, uiFocusMain)
m.textarea.Blur()
m.chat.Focus()
m.chat.SetSelected(m.chat.Len() - 1)
}
case key.Matches(msg, m.keyMap.Editor.OpenEditor):
if m.isAgentBusy() {
cmds = append(cmds, util.ReportWarn("Agent is working, please wait..."))
break
}
cmds = append(cmds, m.openEditor(m.textarea.Value()))
case key.Matches(msg, m.keyMap.Editor.Newline):
prevHeight := m.textarea.Height()
m.textarea.InsertRune('\n')
m.closeCompletions()
cmds = append(cmds, m.updateTextareaWithPrevHeight(msg, prevHeight))
case key.Matches(msg, m.keyMap.Editor.HistoryPrev):
cmd := m.handleHistoryUp(msg)
if cmd != nil {
cmds = append(cmds, cmd)
}
case key.Matches(msg, m.keyMap.Editor.HistoryNext):
cmd := m.handleHistoryDown(msg)
if cmd != nil {
cmds = append(cmds, cmd)
}
case key.Matches(msg, m.keyMap.Editor.Escape):
cmd := m.handleHistoryEscape(msg)
if cmd != nil {
cmds = append(cmds, cmd)
}
case key.Matches(msg, m.keyMap.Editor.Commands) && m.textarea.Value() == "":
if cmd := m.openCommandsDialog(); cmd != nil {
cmds = append(cmds, cmd)
}
default:
if handleGlobalKeys(msg) {
// Handle global keys first before passing to textarea.
break
}
// Check for @ trigger before passing to textarea.
curValue := m.textarea.Value()
curIdx := len(curValue)
// Trigger completions on @.
if msg.String() == "@" && !m.completionsOpen {
// Only show if beginning of prompt or after whitespace.
if curIdx == 0 || (curIdx > 0 && isWhitespace(curValue[curIdx-1])) {
m.completionsOpen = true
m.completionsQuery = ""
m.completionsStartIndex = curIdx
m.completionsPositionStart = m.completionsPosition()
depth, limit := m.com.Config().Options.TUI.Completions.Limits()
cmds = append(cmds, m.completions.Open(depth, limit))
}
}
// remove the details if they are open when user starts typing
if m.detailsOpen {
m.detailsOpen = false
m.updateLayoutAndSize()
}
prevHeight := m.textarea.Height()
cmds = append(cmds, m.updateTextareaWithPrevHeight(msg, prevHeight))
// Any text modification becomes the current draft.
m.updateHistoryDraft(curValue)
// After updating textarea, check if we need to filter completions.
// Skip filtering on the initial @ keystroke since items are loading async.
if m.completionsOpen && msg.String() != "@" {
newValue := m.textarea.Value()
newIdx := len(newValue)
// Close completions if cursor moved before start.
if newIdx <= m.completionsStartIndex {
m.closeCompletions()
} else if msg.String() == "space" {
// Close on space.
m.closeCompletions()
} else {
// Extract current word and filter.
word := m.textareaWord()
if strings.HasPrefix(word, "@") {
m.completionsQuery = word[1:]
m.completions.Filter(m.completionsQuery)
} else if m.completionsOpen {
m.closeCompletions()
}
}
}
}
case uiFocusMain:
switch {
case key.Matches(msg, m.keyMap.Tab):
m.focus = uiFocusEditor
cmds = append(cmds, m.textarea.Focus())
m.chat.Blur()
case key.Matches(msg, m.keyMap.Chat.NewSession):
if !m.hasSession() {
break
}
if m.isAgentBusy() {
cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before starting a new session..."))
break
}
m.focus = uiFocusEditor
if cmd := m.newSession(); cmd != nil {
cmds = append(cmds, cmd)
}
case key.Matches(msg, m.keyMap.Chat.Expand):
m.chat.ToggleExpandedSelectedItem()
case key.Matches(msg, m.keyMap.Chat.Up):
if cmd := m.chat.ScrollByAndAnimate(-1); cmd != nil {
cmds = append(cmds, cmd)
}
if !m.chat.SelectedItemInView() {
m.chat.SelectPrev()
if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
cmds = append(cmds, cmd)
}
}
case key.Matches(msg, m.keyMap.Chat.Down):
if cmd := m.chat.ScrollByAndAnimate(1); cmd != nil {
cmds = append(cmds, cmd)
}
if !m.chat.SelectedItemInView() {
m.chat.SelectNext()
if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
cmds = append(cmds, cmd)
}
}
case key.Matches(msg, m.keyMap.Chat.UpOneItem):
m.chat.SelectPrev()
if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
cmds = append(cmds, cmd)
}
case key.Matches(msg, m.keyMap.Chat.DownOneItem):
m.chat.SelectNext()
if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
cmds = append(cmds, cmd)
}
case key.Matches(msg, m.keyMap.Chat.HalfPageUp):
if cmd := m.chat.ScrollByAndAnimate(-m.chat.Height() / 2); cmd != nil {
cmds = append(cmds, cmd)
}
m.chat.SelectFirstInView()
case key.Matches(msg, m.keyMap.Chat.HalfPageDown):
if cmd := m.chat.ScrollByAndAnimate(m.chat.Height() / 2); cmd != nil {
cmds = append(cmds, cmd)
}
m.chat.SelectLastInView()
case key.Matches(msg, m.keyMap.Chat.PageUp):
if cmd := m.chat.ScrollByAndAnimate(-m.chat.Height()); cmd != nil {
cmds = append(cmds, cmd)
}
m.chat.SelectFirstInView()
case key.Matches(msg, m.keyMap.Chat.PageDown):
if cmd := m.chat.ScrollByAndAnimate(m.chat.Height()); cmd != nil {
cmds = append(cmds, cmd)
}
m.chat.SelectLastInView()
case key.Matches(msg, m.keyMap.Chat.Home):
if cmd := m.chat.ScrollToTopAndAnimate(); cmd != nil {
cmds = append(cmds, cmd)
}
m.chat.SelectFirst()
case key.Matches(msg, m.keyMap.Chat.End):
if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
cmds = append(cmds, cmd)
}
m.chat.SelectLast()
default:
if ok, cmd := m.chat.HandleKeyMsg(msg); ok {
cmds = append(cmds, cmd)
} else {
handleGlobalKeys(msg)
}
}
default:
handleGlobalKeys(msg)
}
default:
handleGlobalKeys(msg)
}
return tea.Sequence(cmds...)
}
// drawHeader draws the header section of the UI.
func (m *UI) drawHeader(scr uv.Screen, area uv.Rectangle) {
m.header.drawHeader(
scr,
area,
m.session,
m.isCompact,
m.detailsOpen,
area.Dx(),
m.hyperCredits,
)
}
// Draw implements [uv.Drawable] and draws the UI model.
func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
layout := m.generateLayout(area.Dx(), area.Dy())
if m.layout != layout {
m.layout = layout
m.updateSize()
}
// Clear the screen first
screen.Clear(scr)
switch m.state {
case uiOnboarding:
m.drawHeader(scr, layout.header)
// NOTE: Onboarding flow will be rendered as dialogs below, but
// positioned at the bottom left of the screen.
case uiInitialize:
m.drawHeader(scr, layout.header)
main := uv.NewStyledString(m.initializeView())
main.Draw(scr, layout.main)
case uiLanding:
m.drawHeader(scr, layout.header)
main := uv.NewStyledString(m.landingView())
main.Draw(scr, layout.main)
editor := uv.NewStyledString(m.renderEditorView(scr.Bounds().Dx()))
editor.Draw(scr, layout.editor)
case uiChat:
if m.isCompact {
m.drawHeader(scr, layout.header)
} else {
m.drawSidebar(scr, layout.sidebar)
}
m.chat.Draw(scr, layout.main)
if layout.pills.Dy() > 0 && m.pillsView != "" {
uv.NewStyledString(m.pillsView).Draw(scr, layout.pills)
}
editorWidth := scr.Bounds().Dx()
if !m.isCompact {
editorWidth -= layout.sidebar.Dx()
}
editor := uv.NewStyledString(m.renderEditorView(editorWidth))
editor.Draw(scr, layout.editor)
// Draw details overlay in compact mode when open
if m.isCompact && m.detailsOpen {
m.drawSessionDetails(scr, layout.sessionDetails)
}
}
isOnboarding := m.state == uiOnboarding
// Add status and help layer
m.status.SetHideHelp(isOnboarding)
m.status.Draw(scr, layout.status)
// Draw completions popup if open
if !isOnboarding && m.completionsOpen && m.completions.HasItems() {
w, h := m.completions.Size()
x := m.completionsPositionStart.X
y := m.completionsPositionStart.Y - h
screenW := area.Dx()
if x+w > screenW {
x = screenW - w
}
x = max(0, x)
y = max(0, y+1) // Offset for attachments row
completionsView := uv.NewStyledString(m.completions.Render())
completionsView.Draw(scr, image.Rectangle{
Min: image.Pt(x, y),
Max: image.Pt(x+w, y+h),
})
}
// Debugging rendering (visually see when the tui rerenders)
if os.Getenv("CRUSH_UI_DEBUG") == "true" {
debugView := lipgloss.NewStyle().Background(lipgloss.ANSIColor(rand.Intn(256))).Width(4).Height(2)
debug := uv.NewStyledString(debugView.String())
debug.Draw(scr, image.Rectangle{
Min: image.Pt(4, 1),
Max: image.Pt(8, 3),
})
}
// This needs to come last to overlay on top of everything. We always pass
// the full screen bounds because the dialogs will position themselves
// accordingly.
if m.dialog.HasDialogs() {
return m.dialog.Draw(scr, scr.Bounds())
}
switch m.focus {
case uiFocusEditor:
if m.layout.editor.Dy() <= 0 {
// Don't show cursor if editor is not visible
return nil
}
if m.detailsOpen && m.isCompact {
// Don't show cursor if details overlay is open
return nil
}
if m.textarea.Focused() {
cur := m.textarea.Cursor()
cur.X++ // Adjust for app margins
cur.Y += m.layout.editor.Min.Y + 1 // Offset for attachments row
return cur
}
}
return nil
}
// View renders the UI model's view.
func (m *UI) View() tea.View {
var v tea.View
v.AltScreen = true
if !m.isTransparent {
v.BackgroundColor = m.com.Styles.Background
}
v.MouseMode = tea.MouseModeCellMotion
v.ReportFocus = m.caps.ReportFocusEvents
v.WindowTitle = "crush " + home.Short(m.com.Workspace.WorkingDir())
canvas := uv.NewScreenBuffer(m.width, m.height)
v.Cursor = m.Draw(canvas, canvas.Bounds())
content := strings.ReplaceAll(canvas.Render(), "\r\n", "\n") // normalize newlines
contentLines := strings.Split(content, "\n")
for i, line := range contentLines {
// Trim trailing spaces for concise rendering
contentLines[i] = strings.TrimRight(line, " ")
}
content = strings.Join(contentLines, "\n")
v.Content = content
if m.progressBarEnabled && m.sendProgressBar && m.isAgentBusy() {
// HACK: use a random percentage to prevent ghostty from hiding it
// after a timeout.
v.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
}
return v
}
// ShortHelp implements [help.KeyMap].
func (m *UI) ShortHelp() []key.Binding {
var binds []key.Binding
k := &m.keyMap
tab := k.Tab
commands := k.Commands
if m.focus == uiFocusEditor && m.textarea.Value() == "" {
commands.SetHelp("/ or ctrl+p", "commands")
}
switch m.state {
case uiInitialize:
binds = append(binds, k.Quit)
case uiChat:
// Show cancel binding if agent is busy.
if m.isAgentBusy() {
cancelBinding := k.Chat.Cancel
if m.isCanceling {
cancelBinding.SetHelp("esc", "press again to cancel")
} else if m.com.Workspace.AgentQueuedPrompts(m.session.ID) > 0 {
cancelBinding.SetHelp("esc", "clear queue")
}
binds = append(binds, cancelBinding)
}
if m.focus == uiFocusEditor {
tab.SetHelp("tab", "focus chat")
} else {
tab.SetHelp("tab", "focus editor")
}
binds = append(binds,
tab,
commands,
k.Models,
)
switch m.focus {
case uiFocusEditor:
binds = append(binds,
k.Editor.Newline,
)
case uiFocusMain:
binds = append(binds,
k.Chat.UpDown,
k.Chat.UpDownOneItem,
k.Chat.PageUp,
k.Chat.PageDown,
k.Chat.Copy,
)
if m.pillsExpanded && hasIncompleteTodos(m.session.Todos) && m.promptQueue > 0 {
binds = append(binds, k.Chat.PillLeft)
}
}
default:
// TODO: other states
// if m.session == nil {
// no session selected
binds = append(binds,
commands,
k.Models,
k.Editor.Newline,
)
}
binds = append(binds,
k.Quit,
k.Help,
)
return binds
}
// FullHelp implements [help.KeyMap].
func (m *UI) FullHelp() [][]key.Binding {
var binds [][]key.Binding
k := &m.keyMap
help := k.Help
help.SetHelp("ctrl+g", "less")
hasAttachments := len(m.attachments.List()) > 0
hasSession := m.hasSession()
commands := k.Commands
if m.focus == uiFocusEditor && m.textarea.Value() == "" {
commands.SetHelp("/ or ctrl+p", "commands")
}
switch m.state {
case uiInitialize:
binds = append(binds,
[]key.Binding{
k.Quit,
})
case uiChat:
// Show cancel binding if agent is busy.
if m.isAgentBusy() {
cancelBinding := k.Chat.Cancel
if m.isCanceling {
cancelBinding.SetHelp("esc", "press again to cancel")
} else if m.com.Workspace.AgentQueuedPrompts(m.session.ID) > 0 {
cancelBinding.SetHelp("esc", "clear queue")
}
binds = append(binds, []key.Binding{cancelBinding})
}
mainBinds := []key.Binding{}
tab := k.Tab
if m.focus == uiFocusEditor {
tab.SetHelp("tab", "focus chat")
} else {
tab.SetHelp("tab", "focus editor")
}
mainBinds = append(mainBinds,
tab,
commands,
k.Models,
k.Sessions,
)
if hasSession {
mainBinds = append(mainBinds, k.Chat.NewSession)
}
binds = append(binds, mainBinds)
switch m.focus {
case uiFocusEditor:
editorBinds := []key.Binding{
k.Editor.Newline,
k.Editor.MentionFile,
k.Editor.OpenEditor,
}
if m.currentModelSupportsImages() {
editorBinds = append(editorBinds, k.Editor.AddImage, k.Editor.PasteImage)
}
binds = append(binds, editorBinds)
if hasAttachments {
binds = append(binds,
[]key.Binding{
k.Editor.AttachmentDeleteMode,
k.Editor.DeleteAllAttachments,
k.Editor.Escape,
},
)
}
case uiFocusMain:
binds = append(binds,
[]key.Binding{
k.Chat.UpDown,
k.Chat.UpDownOneItem,
k.Chat.PageUp,
k.Chat.PageDown,
},
[]key.Binding{
k.Chat.HalfPageUp,
k.Chat.HalfPageDown,
k.Chat.Home,
k.Chat.End,
},
[]key.Binding{
k.Chat.Copy,
k.Chat.ClearHighlight,
},
)
if m.pillsExpanded && hasIncompleteTodos(m.session.Todos) && m.promptQueue > 0 {
binds = append(binds, []key.Binding{k.Chat.PillLeft})
}
}
default:
if m.session == nil {
// no session selected
binds = append(binds,
[]key.Binding{
commands,
k.Models,
k.Sessions,
},
)
editorBinds := []key.Binding{
k.Editor.Newline,
k.Editor.MentionFile,
k.Editor.OpenEditor,
}
if m.currentModelSupportsImages() {
editorBinds = append(editorBinds, k.Editor.AddImage, k.Editor.PasteImage)
}
binds = append(binds, editorBinds)
if hasAttachments {
binds = append(binds,
[]key.Binding{
k.Editor.AttachmentDeleteMode,
k.Editor.DeleteAllAttachments,
k.Editor.Escape,
},
)
}
}
}
binds = append(binds,
[]key.Binding{
help,
k.Quit,
},
)
return binds
}
func (m *UI) currentModelSupportsImages() bool {
cfg := m.com.Config()
if cfg == nil {
return false
}
agentCfg, ok := cfg.Agents[config.AgentCoder]
if !ok {
return false
}
model := cfg.GetModelByType(agentCfg.Model)
return model != nil && model.SupportsImages
}
// toggleCompactMode toggles compact mode between uiChat and uiChatCompact states.
func (m *UI) toggleCompactMode() tea.Cmd {
m.forceCompactMode = !m.forceCompactMode
err := m.com.Workspace.SetCompactMode(config.ScopeGlobal, m.forceCompactMode)
if err != nil {
return util.ReportError(err)
}
m.updateLayoutAndSize()
return nil
}
// updateLayoutAndSize updates the layout and sizes of UI components.
func (m *UI) updateLayoutAndSize() {
// Determine if we should be in compact mode
if m.state == uiChat {
if m.forceCompactMode {
m.isCompact = true
} else if m.width < compactModeWidthBreakpoint || m.height < compactModeHeightBreakpoint {
m.isCompact = true
} else {
m.isCompact = false
}
}
// First pass sizes components from the current textarea height.
m.layout = m.generateLayout(m.width, m.height)
prevHeight := m.textarea.Height()
m.updateSize()
// SetWidth can change textarea height due to soft-wrap recalculation.
// If that happens, run one reconciliation pass with the new height.
if m.textarea.Height() != prevHeight {
m.layout = m.generateLayout(m.width, m.height)
m.updateSize()
}
}
// handleTextareaHeightChange checks whether the textarea height changed and,
// if so, recalculates the layout. When the chat is in follow mode it keeps
// the view scrolled to the bottom. The returned command, if non-nil, must be
// batched by the caller.
func (m *UI) handleTextareaHeightChange(prevHeight int) tea.Cmd {
if m.textarea.Height() == prevHeight {
return nil
}
m.updateLayoutAndSize()
if m.state == uiChat && m.chat.Follow() {
return m.chat.ScrollToBottomAndAnimate()
}
return nil
}
// updateTextarea updates the textarea for msg and then reconciles layout if
// the textarea height changed as a result.
func (m *UI) updateTextarea(msg tea.Msg) tea.Cmd {
return m.updateTextareaWithPrevHeight(msg, m.textarea.Height())
}
// updateTextareaWithPrevHeight is for cases when the height of the layout may
// have changed.
//
// Particularly, it's for cases where the textarea changes before
// textarea.Update is called (for example, SetValue, Reset, and InsertRune). We
// pass the height from before those changes took place so we can compare
// "before" vs "after" sizing and recalculate the layout if the textarea grew
// or shrank.
func (m *UI) updateTextareaWithPrevHeight(msg tea.Msg, prevHeight int) tea.Cmd {
ta, cmd := m.textarea.Update(msg)
m.textarea = ta
return tea.Batch(cmd, m.handleTextareaHeightChange(prevHeight))
}
// updateSize updates the sizes of UI components based on the current layout.
func (m *UI) updateSize() {
// Set status width
m.status.SetWidth(m.layout.status.Dx())
m.chat.SetSize(m.layout.main.Dx(), m.layout.main.Dy())
m.textarea.MaxHeight = TextareaMaxHeight
m.textarea.SetWidth(m.layout.editor.Dx())
m.renderPills()
// Handle different app states
switch m.state {
case uiChat:
if !m.isCompact {
m.cacheSidebarLogo(m.layout.sidebar.Dx())
}
}
}
// generateLayout calculates the layout rectangles for all UI components based
// on the current UI state and terminal dimensions.
func (m *UI) generateLayout(w, h int) uiLayout {
// The screen area we're working with
area := image.Rect(0, 0, w, h)
// The help height
helpHeight := 1
// The editor height: textarea height + margin for attachments and bottom spacing.
editorHeight := m.textarea.Height() + editorHeightMargin
// The sidebar width
sidebarWidth := 30
// The header height
const landingHeaderHeight = 4
var helpKeyMap help.KeyMap = m
if m.status != nil && m.status.ShowingAll() {
for _, row := range helpKeyMap.FullHelp() {
helpHeight = max(helpHeight, len(row))
}
}
// Add app margins
var appRect, helpRect image.Rectangle
layout.Vertical(
layout.Len(area.Dy()-helpHeight),
layout.Fill(1),
).Split(area).Assign(&appRect, &helpRect)
appRect.Min.Y += 1
appRect.Max.Y -= 1
helpRect.Min.Y -= 1
appRect.Min.X += 1
appRect.Max.X -= 1
if slices.Contains([]uiState{uiOnboarding, uiInitialize, uiLanding}, m.state) {
// extra padding on left and right for these states
appRect.Min.X += 1
appRect.Max.X -= 1
}
uiLayout := uiLayout{
area: area,
status: helpRect,
}
// Handle different app states
switch m.state {
case uiOnboarding, uiInitialize:
// Layout
//
// header
// ------
// main
// ------
// help
var headerRect, mainRect image.Rectangle
layout.Vertical(
layout.Len(landingHeaderHeight),
layout.Fill(1),
).Split(appRect).Assign(&headerRect, &mainRect)
uiLayout.header = headerRect
uiLayout.main = mainRect
case uiLanding:
// Layout
//
// header
// ------
// main
// ------
// editor
// ------
// help
var headerRect, mainRect image.Rectangle
layout.Vertical(
layout.Len(landingHeaderHeight),
layout.Fill(1),
).Split(appRect).Assign(&headerRect, &mainRect)
var editorRect image.Rectangle
layout.Vertical(
layout.Len(mainRect.Dy()-editorHeight),
layout.Fill(1),
).Split(mainRect).Assign(&mainRect, &editorRect)
// Remove extra padding from editor (but keep it for header and main)
editorRect.Min.X -= 1
editorRect.Max.X += 1
uiLayout.header = headerRect
uiLayout.main = mainRect
uiLayout.editor = editorRect
case uiChat:
if m.isCompact {
// Layout
//
// compact-header
// ------
// main
// ------
// editor
// ------
// help
const compactHeaderHeight = 1
var headerRect, mainRect image.Rectangle
layout.Vertical(
layout.Len(compactHeaderHeight),
layout.Fill(1),
).Split(appRect).Assign(&headerRect, &mainRect)
detailsHeight := min(sessionDetailsMaxHeight, area.Dy()-1) // One row for the header
var sessionDetailsArea image.Rectangle
layout.Vertical(
layout.Len(detailsHeight),
layout.Fill(1),
).Split(appRect).Assign(&sessionDetailsArea, new(image.Rectangle))
uiLayout.sessionDetails = sessionDetailsArea
uiLayout.sessionDetails.Min.Y += compactHeaderHeight // adjust for header
// Add one line gap between header and main content
mainRect.Min.Y += 1
var editorRect image.Rectangle
layout.Vertical(
layout.Len(mainRect.Dy()-editorHeight),
layout.Fill(1),
).Split(mainRect).Assign(&mainRect, &editorRect)
mainRect.Max.X -= 1 // Add padding right
uiLayout.header = headerRect
pillsHeight := m.pillsAreaHeight()
if pillsHeight > 0 {
pillsHeight = min(pillsHeight, mainRect.Dy())
var chatRect, pillsRect image.Rectangle
layout.Vertical(
layout.Len(mainRect.Dy()-pillsHeight),
layout.Fill(1),
).Split(mainRect).Assign(&chatRect, &pillsRect)
uiLayout.main = chatRect
uiLayout.pills = pillsRect
} else {
uiLayout.main = mainRect
}
// Add bottom margin to main
uiLayout.main.Max.Y -= 1
uiLayout.editor = editorRect
} else {
// Layout
//
// ------|---
// main |
// ------| side
// editor|
// ----------
// help
var mainRect, sideRect image.Rectangle
layout.Horizontal(
layout.Len(appRect.Dx()-sidebarWidth),
layout.Fill(1),
).Split(appRect).Assign(&mainRect, &sideRect)
// Add padding left
sideRect.Min.X += 1
var editorRect image.Rectangle
layout.Vertical(
layout.Len(mainRect.Dy()-editorHeight),
layout.Fill(1),
).Split(mainRect).Assign(&mainRect, &editorRect)
mainRect.Max.X -= 1 // Add padding right
uiLayout.sidebar = sideRect
pillsHeight := m.pillsAreaHeight()
if pillsHeight > 0 {
pillsHeight = min(pillsHeight, mainRect.Dy())
var chatRect, pillsRect image.Rectangle
layout.Vertical(
layout.Len(mainRect.Dy()-pillsHeight),
layout.Fill(1),
).Split(mainRect).Assign(&chatRect, &pillsRect)
uiLayout.main = chatRect
uiLayout.pills = pillsRect
} else {
uiLayout.main = mainRect
}
// Add bottom margin to main
uiLayout.main.Max.Y -= 1
uiLayout.editor = editorRect
}
}
return uiLayout
}
// uiLayout defines the positioning of UI elements.
type uiLayout struct {
// area is the overall available area.
area uv.Rectangle
// header is the header shown in special cases
// e.x when the sidebar is collapsed
// or when in the landing page
// or in init/config
header uv.Rectangle
// main is the area for the main pane. (e.x chat, configure, landing)
main uv.Rectangle
// pills is the area for the pills panel.
pills uv.Rectangle
// editor is the area for the editor pane.
editor uv.Rectangle
// sidebar is the area for the sidebar.
sidebar uv.Rectangle
// status is the area for the status view.
status uv.Rectangle
// session details is the area for the session details overlay in compact mode.
sessionDetails uv.Rectangle
}
func (m *UI) openEditor(value string) tea.Cmd {
tmpfile, err := os.CreateTemp("", "msg_*.md")
if err != nil {
return util.ReportError(err)
}
tmpPath := tmpfile.Name()
defer tmpfile.Close() //nolint:errcheck
if _, err := tmpfile.WriteString(value); err != nil {
return util.ReportError(err)
}
cmd, err := editor.Command(
"crush",
tmpPath,
editor.AtPosition(
m.textarea.Line()+1,
m.textarea.Column()+1,
),
)
if err != nil {
return util.ReportError(err)
}
return tea.ExecProcess(cmd, func(err error) tea.Msg {
defer func() {
_ = os.Remove(tmpPath)
}()
if err != nil {
return util.ReportError(err)
}
content, err := os.ReadFile(tmpPath)
if err != nil {
return util.ReportError(err)
}
if len(content) == 0 {
return util.ReportWarn("Message is empty")
}
return openEditorMsg{
Text: strings.TrimSpace(string(content)),
}
})
}
// setEditorPrompt configures the textarea prompt function based on whether
// yolo mode is enabled.
func (m *UI) setEditorPrompt(yolo bool) {
if yolo {
m.textarea.SetPromptFunc(4, m.yoloPromptFunc)
return
}
m.textarea.SetPromptFunc(4, m.normalPromptFunc)
}
// normalPromptFunc returns the normal editor prompt style (" > " on first
// line, "::: " on subsequent lines).
func (m *UI) normalPromptFunc(info textarea.PromptInfo) string {
t := m.com.Styles
if info.LineNumber == 0 {
if info.Focused {
return " > "
}
return "::: "
}
if info.Focused {
return t.Editor.PromptNormalFocused.Render()
}
return t.Editor.PromptNormalBlurred.Render()
}
// yoloPromptFunc returns the yolo mode editor prompt style with warning icon
// and colored dots.
func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string {
t := m.com.Styles
if info.LineNumber == 0 {
if info.Focused {
return t.Editor.PromptYoloIconFocused.Render()
} else {
return t.Editor.PromptYoloIconBlurred.Render()
}
}
if info.Focused {
return t.Editor.PromptYoloDotsFocused.Render()
}
return t.Editor.PromptYoloDotsBlurred.Render()
}
// closeCompletions closes the completions popup and resets state.
func (m *UI) closeCompletions() {
m.completionsOpen = false
m.completionsQuery = ""
m.completionsStartIndex = 0
m.completions.Close()
}
// insertCompletionText replaces the @query in the textarea with the given text.
// Returns false if the replacement cannot be performed.
func (m *UI) insertCompletionText(text string) bool {
value := m.textarea.Value()
if m.completionsStartIndex > len(value) {
return false
}
word := m.textareaWord()
endIdx := min(m.completionsStartIndex+len(word), len(value))
newValue := value[:m.completionsStartIndex] + text + value[endIdx:]
m.textarea.SetValue(newValue)
m.textarea.MoveToEnd()
m.textarea.InsertRune(' ')
return true
}
// insertFileCompletion inserts the selected file path into the textarea,
// replacing the @query, and adds the file as an attachment.
func (m *UI) insertFileCompletion(path string) tea.Cmd {
prevHeight := m.textarea.Height()
if !m.insertCompletionText(path) {
return nil
}
heightCmd := m.handleTextareaHeightChange(prevHeight)
fileCmd := func() tea.Msg {
absPath, _ := filepath.Abs(path)
if m.hasSession() {
// Skip attachment if file was already read and hasn't been modified.
lastRead := m.com.Workspace.FileTrackerLastReadTime(context.Background(), m.session.ID, absPath)
if !lastRead.IsZero() {
if info, err := os.Stat(path); err == nil && !info.ModTime().After(lastRead) {
return nil
}
}
} else if slices.Contains(m.sessionFileReads, absPath) {
return nil
}
m.sessionFileReads = append(m.sessionFileReads, absPath)
// Add file as attachment.
content, err := os.ReadFile(path)
if err != nil {
// If it fails, let the LLM handle it later.
return nil
}
return message.Attachment{
FilePath: path,
FileName: filepath.Base(path),
MimeType: mimeOf(content),
Content: content,
}
}
return tea.Batch(heightCmd, fileCmd)
}
// insertMCPResourceCompletion inserts the selected resource into the textarea,
// replacing the @query, and adds the resource as an attachment.
func (m *UI) insertMCPResourceCompletion(item completions.ResourceCompletionValue) tea.Cmd {
displayText := cmp.Or(item.Title, item.URI)
prevHeight := m.textarea.Height()
if !m.insertCompletionText(displayText) {
return nil
}
heightCmd := m.handleTextareaHeightChange(prevHeight)
resourceCmd := func() tea.Msg {
contents, err := m.com.Workspace.ReadMCPResource(
context.Background(),
item.MCPName,
item.URI,
)
if err != nil {
slog.Warn("Failed to read MCP resource", "uri", item.URI, "error", err)
return nil
}
if len(contents) == 0 {
return nil
}
content := contents[0]
var data []byte
if content.Text != "" {
data = []byte(content.Text)
} else if len(content.Blob) > 0 {
data = content.Blob
}
if len(data) == 0 {
return nil
}
mimeType := item.MIMEType
if mimeType == "" && content.MIMEType != "" {
mimeType = content.MIMEType
}
if mimeType == "" {
mimeType = "text/plain"
}
return message.Attachment{
FilePath: item.URI,
FileName: displayText,
MimeType: mimeType,
Content: data,
}
}
return tea.Batch(heightCmd, resourceCmd)
}
// completionsPosition returns the X and Y position for the completions popup.
func (m *UI) completionsPosition() image.Point {
cur := m.textarea.Cursor()
if cur == nil {
return image.Point{
X: m.layout.editor.Min.X,
Y: m.layout.editor.Min.Y,
}
}
return image.Point{
X: cur.X + m.layout.editor.Min.X,
Y: m.layout.editor.Min.Y + cur.Y,
}
}
// textareaWord returns the current word at the cursor position.
func (m *UI) textareaWord() string {
return m.textarea.Word()
}
// isWhitespace returns true if the byte is a whitespace character.
func isWhitespace(b byte) bool {
return b == ' ' || b == '\t' || b == '\n' || b == '\r'
}
// isAgentBusy returns true if the agent coordinator exists and is currently
// busy processing a request.
func (m *UI) isAgentBusy() bool {
return m.com.Workspace.AgentIsReady() &&
m.com.Workspace.AgentIsBusy()
}
// hasSession returns true if there is an active session with a valid ID.
func (m *UI) hasSession() bool {
return m.session != nil && m.session.ID != ""
}
// mimeOf detects the MIME type of the given content.
func mimeOf(content []byte) string {
mimeBufferSize := min(512, len(content))
return http.DetectContentType(content[:mimeBufferSize])
}
var readyPlaceholders = [...]string{
"Ready!",
"Ready...",
"Ready?",
"Ready for instructions",
}
var workingPlaceholders = [...]string{
"Working!",
"Working...",
"Brrrrr...",
"Prrrrrrrr...",
"Processing...",
"Thinking...",
}
// randomizePlaceholders selects random placeholder text for the textarea's
// ready and working states.
func (m *UI) randomizePlaceholders() {
m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
}
// renderEditorView renders the editor view with attachments if any.
func (m *UI) renderEditorView(width int) string {
var attachmentsView string
if len(m.attachments.List()) > 0 {
attachmentsView = m.attachments.Render(width)
}
return strings.Join([]string{
attachmentsView,
m.textarea.View(),
"", // margin at bottom of editor
}, "\n")
}
// cacheSidebarLogo renders and caches the sidebar logo at the specified width.
func (m *UI) cacheSidebarLogo(width int) {
m.sidebarLogo = renderLogo(m.com.Styles, true, m.com.IsHyper(), width)
}
// applyTheme replaces the active styles with the given theme, drops the
// shared markdown renderer cache, and refreshes every component that
// caches style data.
func (m *UI) applyTheme(s styles.Styles) {
*m.com.Styles = s
common.InvalidateMarkdownRendererCache()
m.refreshStyles()
}
// refreshStyles pushes the current *m.com.Styles into every subcomponent
// that copies or pre-renders style-dependent values at construction time.
func (m *UI) refreshStyles() {
t := m.com.Styles
m.header.refresh()
if m.layout.sidebar.Dx() > 0 {
m.cacheSidebarLogo(m.layout.sidebar.Dx())
}
m.textarea.SetStyles(t.Editor.Textarea)
m.completions.SetStyles(t.Completions.Normal, t.Completions.Focused, t.Completions.Match)
m.attachments.Renderer().SetStyles(
t.Attachments.Normal,
t.Attachments.Deleting,
t.Attachments.Image,
t.Attachments.Text,
)
m.todoSpinner.Style = t.Pills.TodoSpinner
m.status.help.Styles = t.Help
m.chat.InvalidateRenderCaches()
}
// sendMessage sends a message with the given content and attachments.
func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.Cmd {
if !m.com.Workspace.AgentIsReady() {
return util.ReportError(fmt.Errorf("coder agent is not initialized"))
}
var cmds []tea.Cmd
if !m.hasSession() {
newSession, err := m.com.Workspace.CreateSession(context.Background(), "New Session")
if err != nil {
return util.ReportError(err)
}
if m.forceCompactMode {
m.isCompact = true
}
if newSession.ID != "" {
m.session = &newSession
cmds = append(cmds, m.loadSession(newSession.ID))
}
m.setState(uiChat, m.focus)
}
ctx := context.Background()
cmds = append(cmds, func() tea.Msg {
for _, path := range m.sessionFileReads {
m.com.Workspace.FileTrackerRecordRead(ctx, m.session.ID, path)
m.com.Workspace.LSPStart(ctx, path)
}
return nil
})
// Capture session ID to avoid race with main goroutine updating m.session.
sessionID := m.session.ID
cmds = append(cmds, func() tea.Msg {
err := m.com.Workspace.AgentRun(context.Background(), sessionID, content, attachments...)
if err != nil {
isCancelErr := errors.Is(err, context.Canceled)
if isCancelErr {
return nil
}
return util.InfoMsg{
Type: util.InfoTypeError,
Msg: fmt.Sprintf("%v", err),
}
}
return nil
})
return tea.Batch(cmds...)
}
const cancelTimerDuration = 2 * time.Second
// cancelTimerCmd creates a command that expires the cancel timer.
func cancelTimerCmd() tea.Cmd {
return tea.Tick(cancelTimerDuration, func(time.Time) tea.Msg {
return cancelTimerExpiredMsg{}
})
}
// cancelAgent handles the cancel key press. The first press sets isCanceling to true
// and starts a timer. The second press (before the timer expires) actually
// cancels the agent.
func (m *UI) cancelAgent() tea.Cmd {
if !m.hasSession() {
return nil
}
if !m.com.Workspace.AgentIsReady() {
return nil
}
if m.isCanceling {
// Second escape press - actually cancel the agent.
m.isCanceling = false
m.com.Workspace.AgentCancel(m.session.ID)
// Stop the spinning todo indicator.
m.todoIsSpinning = false
m.renderPills()
return nil
}
// Check if there are queued prompts - if so, clear the queue.
if m.com.Workspace.AgentQueuedPrompts(m.session.ID) > 0 {
m.com.Workspace.AgentClearQueue(m.session.ID)
return nil
}
// First escape press - set canceling state and start timer.
m.isCanceling = true
return cancelTimerCmd()
}
// openDialog opens a dialog by its ID.
func (m *UI) openDialog(id string) tea.Cmd {
var cmds []tea.Cmd
switch id {
case dialog.SessionsID:
if cmd := m.openSessionsDialog(); cmd != nil {
cmds = append(cmds, cmd)
}
case dialog.ModelsID:
if cmd := m.openModelsDialog(); cmd != nil {
cmds = append(cmds, cmd)
}
case dialog.CommandsID:
if cmd := m.openCommandsDialog(); cmd != nil {
cmds = append(cmds, cmd)
}
case dialog.ReasoningID:
if cmd := m.openReasoningDialog(); cmd != nil {
cmds = append(cmds, cmd)
}
case dialog.FilePickerID:
if cmd := m.openFilesDialog(); cmd != nil {
cmds = append(cmds, cmd)
}
case dialog.QuitID:
if cmd := m.openQuitDialog(); cmd != nil {
cmds = append(cmds, cmd)
}
default:
// Unknown dialog
break
}
return tea.Batch(cmds...)
}
// openQuitDialog opens the quit confirmation dialog.
func (m *UI) openQuitDialog() tea.Cmd {
if m.dialog.ContainsDialog(dialog.QuitID) {
// Bring to front
m.dialog.BringToFront(dialog.QuitID)
return nil
}
quitDialog := dialog.NewQuit(m.com)
m.dialog.OpenDialog(quitDialog)
return nil
}
// openModelsDialog opens the models dialog.
func (m *UI) openModelsDialog() tea.Cmd {
if m.dialog.ContainsDialog(dialog.ModelsID) {
// Bring to front
m.dialog.BringToFront(dialog.ModelsID)
return nil
}
isOnboarding := m.state == uiOnboarding
modelsDialog, err := dialog.NewModels(m.com, isOnboarding)
if err != nil {
return util.ReportError(err)
}
m.dialog.OpenDialog(modelsDialog)
return nil
}
// openCommandsDialog opens the commands dialog.
func (m *UI) openCommandsDialog() tea.Cmd {
if m.dialog.ContainsDialog(dialog.CommandsID) {
// Bring to front
m.dialog.BringToFront(dialog.CommandsID)
return nil
}
var sessionID string
hasSession := m.session != nil
if hasSession {
sessionID = m.session.ID
}
hasTodos := hasSession && hasIncompleteTodos(m.session.Todos)
hasQueue := m.promptQueue > 0
commands, err := dialog.NewCommands(m.com, sessionID, hasSession, hasTodos, hasQueue, m.customCommands, m.mcpPrompts)
if err != nil {
return util.ReportError(err)
}
m.dialog.OpenDialog(commands)
return commands.InitialCmd()
}
// openReasoningDialog opens the reasoning effort dialog.
func (m *UI) openReasoningDialog() tea.Cmd {
if m.dialog.ContainsDialog(dialog.ReasoningID) {
m.dialog.BringToFront(dialog.ReasoningID)
return nil
}
reasoningDialog, err := dialog.NewReasoning(m.com)
if err != nil {
return util.ReportError(err)
}
m.dialog.OpenDialog(reasoningDialog)
return nil
}
// openSessionsDialog opens the sessions dialog. If the dialog is already open,
// it brings it to the front. Otherwise, it will list all the sessions and open
// the dialog.
func (m *UI) openSessionsDialog() tea.Cmd {
if m.dialog.ContainsDialog(dialog.SessionsID) {
// Bring to front
m.dialog.BringToFront(dialog.SessionsID)
return nil
}
selectedSessionID := ""
if m.session != nil {
selectedSessionID = m.session.ID
}
dialog, err := dialog.NewSessions(m.com, selectedSessionID)
if err != nil {
return util.ReportError(err)
}
m.dialog.OpenDialog(dialog)
return nil
}
// openFilesDialog opens the file picker dialog.
func (m *UI) openFilesDialog() tea.Cmd {
if m.dialog.ContainsDialog(dialog.FilePickerID) {
// Bring to front
m.dialog.BringToFront(dialog.FilePickerID)
return nil
}
filePicker, cmd := dialog.NewFilePicker(m.com)
filePicker.SetImageCapabilities(&m.caps)
m.dialog.OpenDialog(filePicker)
return cmd
}
// openPermissionsDialog opens the permissions dialog for a permission request.
func (m *UI) openPermissionsDialog(perm permission.PermissionRequest) tea.Cmd {
// Close any existing permissions dialog first.
m.dialog.CloseDialog(dialog.PermissionsID)
// Get diff mode from config.
var opts []dialog.PermissionsOption
if diffMode := m.com.Config().Options.TUI.DiffMode; diffMode != "" {
opts = append(opts, dialog.WithDiffMode(diffMode == "split"))
}
permDialog := dialog.NewPermissions(m.com, perm, opts...)
m.dialog.OpenDialog(permDialog)
return nil
}
// handlePermissionNotification updates tool items when permission state changes.
func (m *UI) handlePermissionNotification(notification permission.PermissionNotification) {
toolItem := m.chat.MessageItem(notification.ToolCallID)
if toolItem == nil {
return
}
if permItem, ok := toolItem.(chat.ToolMessageItem); ok {
if notification.Granted {
permItem.SetStatus(chat.ToolStatusRunning)
} else {
permItem.SetStatus(chat.ToolStatusAwaitingPermission)
}
}
}
// handleAgentNotification translates domain agent events into desktop
// notifications using the UI notification backend.
func (m *UI) handleAgentNotification(n notify.Notification) tea.Cmd {
switch n.Type {
case notify.TypeAgentFinished:
var cmds []tea.Cmd
cmds = append(cmds, m.sendNotification(notification.Notification{
Title: "Crush is waiting...",
Message: fmt.Sprintf("Agent's turn completed in \"%s\"", n.SessionTitle),
}))
if m.com.IsHyper() {
cmds = append(cmds, m.fetchHyperCredits())
}
return tea.Batch(cmds...)
case notify.TypeReAuthenticate:
return m.handleReAuthenticate(n.ProviderID)
default:
return nil
}
}
func (m *UI) handleReAuthenticate(providerID string) tea.Cmd {
cfg := m.com.Config()
if cfg == nil {
return nil
}
providerCfg, ok := cfg.Providers.Get(providerID)
if !ok {
return nil
}
agentCfg, ok := cfg.Agents[config.AgentCoder]
if !ok {
return nil
}
return m.openAuthenticationDialog(providerCfg.ToProvider(), cfg.Models[agentCfg.Model], agentCfg.Model)
}
// newSession clears the current session state and prepares for a new session.
// The actual session creation happens when the user sends their first message.
// Returns a command to reload prompt history.
func (m *UI) newSession() tea.Cmd {
if !m.hasSession() {
return nil
}
m.session = nil
m.sessionFiles = nil
m.sessionFileReads = nil
m.setState(uiLanding, uiFocusEditor)
m.textarea.Focus()
m.chat.Blur()
m.chat.ClearMessages()
m.pillsExpanded = false
m.promptQueue = 0
m.pillsView = ""
m.historyReset()
agenttools.ResetCache()
return tea.Batch(
func() tea.Msg {
m.com.Workspace.LSPStopAll(context.Background())
return nil
},
m.loadPromptHistory(),
)
}
// handlePasteMsg handles a paste message.
func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
// Normalize \r\n before the textarea sanitizer sees it.
msg.Content = strings.ReplaceAll(msg.Content, "\r\n", "\n")
if m.dialog.HasDialogs() {
return m.handleDialogMsg(msg)
}
if m.focus != uiFocusEditor {
return nil
}
if hasPasteExceededThreshold(msg) {
return func() tea.Msg {
content := []byte(msg.Content)
if int64(len(content)) > common.MaxAttachmentSize {
return util.ReportWarn("Paste is too big (>5mb)")
}
name := fmt.Sprintf("paste_%d.txt", m.pasteIdx())
mimeBufferSize := min(512, len(content))
mimeType := http.DetectContentType(content[:mimeBufferSize])
return message.Attachment{
FileName: name,
FilePath: name,
MimeType: mimeType,
Content: content,
}
}
}
// Attempt to parse pasted content as file paths. If possible to parse,
// all files exist and are valid, add as attachments.
// Otherwise, paste as text.
paths := fsext.ParsePastedFiles(msg.Content)
allExistsAndValid := func() bool {
if len(paths) == 0 {
return false
}
for _, path := range paths {
if _, err := os.Stat(path); os.IsNotExist(err) {
return false
}
lowerPath := strings.ToLower(path)
isValid := false
for _, ext := range common.AllowedImageTypes {
if strings.HasSuffix(lowerPath, ext) {
isValid = true
break
}
}
if !isValid {
return false
}
}
return true
}
if !allExistsAndValid() {
prevHeight := m.textarea.Height()
return m.updateTextareaWithPrevHeight(msg, prevHeight)
}
var cmds []tea.Cmd
for _, path := range paths {
cmds = append(cmds, m.handleFilePathPaste(path))
}
return tea.Batch(cmds...)
}
func hasPasteExceededThreshold(msg tea.PasteMsg) bool {
var (
lineCount = 0
colCount = 0
)
for line := range strings.SplitSeq(msg.Content, "\n") {
lineCount++
colCount = max(colCount, len(line))
if lineCount > pasteLinesThreshold || colCount > pasteColsThreshold {
return true
}
}
return false
}
// handleFilePathPaste handles a pasted file path.
func (m *UI) handleFilePathPaste(path string) tea.Cmd {
return func() tea.Msg {
fileInfo, err := os.Stat(path)
if err != nil {
return util.ReportError(err)
}
if fileInfo.IsDir() {
return util.ReportWarn("Cannot attach a directory")
}
if fileInfo.Size() > common.MaxAttachmentSize {
return util.ReportWarn("File is too big (>5mb)")
}
content, err := os.ReadFile(path)
if err != nil {
return util.ReportError(err)
}
mimeBufferSize := min(512, len(content))
mimeType := http.DetectContentType(content[:mimeBufferSize])
fileName := filepath.Base(path)
return message.Attachment{
FilePath: path,
FileName: fileName,
MimeType: mimeType,
Content: content,
}
}
}
// pasteImageFromClipboard reads image data from the system clipboard and
// creates an attachment. If no image data is found, it falls back to
// interpreting clipboard text as a file path.
func (m *UI) pasteImageFromClipboard() tea.Msg {
imageData, err := readClipboard(clipboardFormatImage)
if int64(len(imageData)) > common.MaxAttachmentSize {
return util.InfoMsg{
Type: util.InfoTypeError,
Msg: "File too large, max 5MB",
}
}
name := fmt.Sprintf("paste_%d.png", m.pasteIdx())
if err == nil {
return message.Attachment{
FilePath: name,
FileName: name,
MimeType: mimeOf(imageData),
Content: imageData,
}
}
textData, textErr := readClipboard(clipboardFormatText)
if textErr != nil || len(textData) == 0 {
return nil // Clipboard is empty or does not contain an image
}
path := strings.TrimSpace(string(textData))
path = strings.ReplaceAll(path, "\\ ", " ")
if _, statErr := os.Stat(path); statErr != nil {
return nil // Clipboard does not contain an image or valid file path
}
lowerPath := strings.ToLower(path)
isAllowed := false
for _, ext := range common.AllowedImageTypes {
if strings.HasSuffix(lowerPath, ext) {
isAllowed = true
break
}
}
if !isAllowed {
return util.NewInfoMsg("File type is not a supported image format")
}
fileInfo, statErr := os.Stat(path)
if statErr != nil {
return util.InfoMsg{
Type: util.InfoTypeError,
Msg: fmt.Sprintf("Unable to read file: %v", statErr),
}
}
if fileInfo.Size() > common.MaxAttachmentSize {
return util.InfoMsg{
Type: util.InfoTypeError,
Msg: "File too large, max 5MB",
}
}
content, readErr := os.ReadFile(path)
if readErr != nil {
return util.InfoMsg{
Type: util.InfoTypeError,
Msg: fmt.Sprintf("Unable to read file: %v", readErr),
}
}
return message.Attachment{
FilePath: path,
FileName: filepath.Base(path),
MimeType: mimeOf(content),
Content: content,
}
}
var pasteRE = regexp.MustCompile(`paste_(\d+).txt`)
func (m *UI) pasteIdx() int {
result := 0
for _, at := range m.attachments.List() {
found := pasteRE.FindStringSubmatch(at.FileName)
if len(found) == 0 {
continue
}
idx, err := strconv.Atoi(found[1])
if err == nil {
result = max(result, idx)
}
}
return result + 1
}
// drawSessionDetails draws the session details in compact mode.
func (m *UI) drawSessionDetails(scr uv.Screen, area uv.Rectangle) {
if m.session == nil {
return
}
s := m.com.Styles
width := area.Dx() - s.CompactDetails.View.GetHorizontalFrameSize()
height := area.Dy() - s.CompactDetails.View.GetVerticalFrameSize()
title := s.CompactDetails.Title.Width(width).MaxHeight(2).Render(m.session.Title)
blocks := []string{
title,
"",
m.modelInfo(width),
"",
}
detailsHeader := lipgloss.JoinVertical(
lipgloss.Left,
blocks...,
)
version := s.CompactDetails.Version.Width(width).AlignHorizontal(lipgloss.Right).Render(version.Version)
remainingHeight := height - lipgloss.Height(detailsHeader) - lipgloss.Height(version)
const maxSectionWidth = 50
sectionWidth := max(1, min(maxSectionWidth, width/4-2)) // account for spacing between sections
maxItemsPerSection := remainingHeight - 3 // Account for section title and spacing
lspSection := m.lspInfo(sectionWidth, maxItemsPerSection, false)
mcpSection := m.mcpInfo(sectionWidth, maxItemsPerSection, false)
skillsSection := m.skillsInfo(sectionWidth, maxItemsPerSection, false)
filesSection := m.filesInfo(m.com.Workspace.WorkingDir(), sectionWidth, maxItemsPerSection, false)
sections := lipgloss.JoinHorizontal(lipgloss.Top, filesSection, " ", lspSection, " ", mcpSection, " ", skillsSection)
uv.NewStyledString(
s.CompactDetails.View.
Width(area.Dx()).
Render(
lipgloss.JoinVertical(
lipgloss.Left,
detailsHeader,
sections,
version,
),
),
).Draw(scr, area)
}
func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string) tea.Cmd {
load := func() tea.Msg {
prompt, err := m.com.Workspace.GetMCPPrompt(clientID, promptID, arguments)
if err != nil {
// TODO: make this better
return util.ReportError(err)()
}
if prompt == "" {
return nil
}
return sendMessageMsg{
Content: prompt,
}
}
var cmds []tea.Cmd
if cmd := m.dialog.StartLoading(); cmd != nil {
cmds = append(cmds, cmd)
}
cmds = append(cmds, load, func() tea.Msg {
return closeDialogMsg{}
})
return tea.Sequence(cmds...)
}
func (m *UI) handleStateChanged() tea.Cmd {
return func() tea.Msg {
m.com.Workspace.UpdateAgentModel(context.Background())
return mcpStateChangedMsg{
states: m.com.Workspace.MCPGetStates(),
}
}
}
func handleMCPPromptsEvent(ws workspace.Workspace, name string) tea.Cmd {
return func() tea.Msg {
ws.MCPRefreshPrompts(context.Background(), name)
return nil
}
}
func handleMCPToolsEvent(ws workspace.Workspace, name string) tea.Cmd {
return func() tea.Msg {
ws.RefreshMCPTools(context.Background(), name)
return nil
}
}
func handleMCPResourcesEvent(ws workspace.Workspace, name string) tea.Cmd {
return func() tea.Msg {
ws.MCPRefreshResources(context.Background(), name)
return nil
}
}
func (m *UI) copyChatHighlight() tea.Cmd {
text := m.chat.HighlightContent()
return common.CopyToClipboardWithCallback(
text,
"Selected text copied to clipboard",
func() tea.Msg {
m.chat.ClearMouse()
return nil
},
)
}
func (m *UI) enableDockerMCP() tea.Msg {
ctx := context.Background()
if err := m.com.Workspace.EnableDockerMCP(ctx); err != nil {
return util.ReportError(err)()
}
return util.NewInfoMsg("Docker MCP enabled and started successfully")
}
func (m *UI) disableDockerMCP() tea.Msg {
if err := m.com.Workspace.DisableDockerMCP(); err != nil {
return util.ReportError(err)()
}
return util.NewInfoMsg("Docker MCP disabled successfully")
}
// renderLogo renders the Crush logo with the given styles and dimensions.
func renderLogo(t *styles.Styles, compact, hyper bool, width int) string {
return logo.Render(t.Logo.GradCanvas, version.Version, compact, logo.Opts{
FieldColor: t.Logo.FieldColor,
TitleColorA: t.Logo.TitleColorA,
TitleColorB: t.Logo.TitleColorB,
CharmColor: t.Logo.CharmColor,
VersionColor: t.Logo.VersionColor,
Width: width,
Hyper: hyper,
})
}