mirror of
https://github.com/charmbracelet/crush.git
synced 2026-05-30 18:47:33 +00:00
Resolve race condition by implementing a package-level cache for skill discovery states. Add ~/.agents/skills/ and ~/.claude/skills/ as global skill scan paths.
3821 lines
104 KiB
Go
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,
|
|
})
|
|
}
|