|
|
@@ -0,0 +1,2944 @@
|
|
|
+package model
|
|
|
+
|
|
|
+import (
|
|
|
+ "bytes"
|
|
|
+ "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/lipgloss/v2"
|
|
|
+ "github.com/atotto/clipboard"
|
|
|
+ "github.com/charmbracelet/catwalk/pkg/catwalk"
|
|
|
+ "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/filetracker"
|
|
|
+ "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/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"
|
|
|
+ timage "github.com/charmbracelet/crush/internal/ui/image"
|
|
|
+ "github.com/charmbracelet/crush/internal/ui/logo"
|
|
|
+ "github.com/charmbracelet/crush/internal/ui/styles"
|
|
|
+ "github.com/charmbracelet/crush/internal/uiutil"
|
|
|
+ "github.com/charmbracelet/crush/internal/version"
|
|
|
+ uv "github.com/charmbracelet/ultraviolet"
|
|
|
+ "github.com/charmbracelet/ultraviolet/screen"
|
|
|
+ "github.com/charmbracelet/x/editor"
|
|
|
+)
|
|
|
+
|
|
|
+// Compact mode breakpoints.
|
|
|
+const (
|
|
|
+ compactModeWidthBreakpoint = 120
|
|
|
+ compactModeHeightBreakpoint = 30
|
|
|
+)
|
|
|
+
|
|
|
+// If pasted text has more than 2 newlines, treat it as a file attachment.
|
|
|
+const pasteLinesThreshold = 10
|
|
|
+
|
|
|
+// Session details panel max height.
|
|
|
+const sessionDetailsMaxHeight = 20
|
|
|
+
|
|
|
+// 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 (
|
|
|
+ uiConfigure 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
|
|
|
+ }
|
|
|
+ // 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{}
|
|
|
+
|
|
|
+ // copyChatHighlightMsg is sent to copy the current chat highlight to clipboard.
|
|
|
+ copyChatHighlightMsg struct{}
|
|
|
+)
|
|
|
+
|
|
|
+// UI represents the main user interface model.
|
|
|
+type UI struct {
|
|
|
+ com *common.Common
|
|
|
+ session *session.Session
|
|
|
+ sessionFiles []SessionFile
|
|
|
+
|
|
|
+ lastUserMessageTime int64
|
|
|
+
|
|
|
+ // The width and height of the terminal in cells.
|
|
|
+ width int
|
|
|
+ height int
|
|
|
+ layout layout
|
|
|
+
|
|
|
+ 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 is the last cached header logo
|
|
|
+ header string
|
|
|
+
|
|
|
+ // sendProgressBar instructs the TUI to send progress bar updates to the
|
|
|
+ // terminal.
|
|
|
+ sendProgressBar bool
|
|
|
+
|
|
|
+ // QueryVersion instructs the TUI to query for the terminal version when it
|
|
|
+ // starts.
|
|
|
+ QueryVersion bool
|
|
|
+
|
|
|
+ // 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
|
|
|
+
|
|
|
+ // sidebarLogo keeps a cached version of the sidebar sidebarLogo.
|
|
|
+ sidebarLogo string
|
|
|
+
|
|
|
+ // imgCaps stores the terminal image capabilities.
|
|
|
+ imgCaps timage.Capabilities
|
|
|
+
|
|
|
+ // 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
|
|
|
+}
|
|
|
+
|
|
|
+// New creates a new instance of the [UI] model.
|
|
|
+func New(com *common.Common) *UI {
|
|
|
+ // Editor components
|
|
|
+ ta := textarea.New()
|
|
|
+ ta.SetStyles(com.Styles.TextArea)
|
|
|
+ ta.ShowLineNumbers = false
|
|
|
+ ta.CharLimit = -1
|
|
|
+ ta.SetVirtualCursor(false)
|
|
|
+ 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,
|
|
|
+ },
|
|
|
+ )
|
|
|
+
|
|
|
+ ui := &UI{
|
|
|
+ com: com,
|
|
|
+ dialog: dialog.NewOverlay(),
|
|
|
+ keyMap: keyMap,
|
|
|
+ focus: uiFocusNone,
|
|
|
+ state: uiConfigure,
|
|
|
+ textarea: ta,
|
|
|
+ chat: ch,
|
|
|
+ completions: comp,
|
|
|
+ attachments: attachments,
|
|
|
+ todoSpinner: todoSpinner,
|
|
|
+ lspStates: make(map[string]app.LSPClientInfo),
|
|
|
+ mcpStates: make(map[string]mcp.ClientInfo),
|
|
|
+ }
|
|
|
+
|
|
|
+ status := NewStatus(com, ui)
|
|
|
+
|
|
|
+ // set onboarding state defaults
|
|
|
+ ui.onboarding.yesInitializeSelected = true
|
|
|
+
|
|
|
+ // If no provider is configured show the user the provider list
|
|
|
+ if !com.Config().IsConfigured() {
|
|
|
+ ui.state = uiConfigure
|
|
|
+ // if the project needs initialization show the user the question
|
|
|
+ } else if n, _ := config.ProjectNeedsInitialization(); n {
|
|
|
+ ui.state = uiInitialize
|
|
|
+ // otherwise go to the landing UI
|
|
|
+ } else {
|
|
|
+ ui.state = uiLanding
|
|
|
+ ui.focus = uiFocusEditor
|
|
|
+ }
|
|
|
+
|
|
|
+ ui.setEditorPrompt(false)
|
|
|
+ ui.randomizePlaceholders()
|
|
|
+ ui.textarea.Placeholder = ui.readyPlaceholder
|
|
|
+ ui.status = status
|
|
|
+
|
|
|
+ // Initialize compact mode from config
|
|
|
+ ui.forceCompactMode = com.Config().Options.TUI.CompactMode
|
|
|
+
|
|
|
+ return ui
|
|
|
+}
|
|
|
+
|
|
|
+// Init initializes the UI model.
|
|
|
+func (m *UI) Init() tea.Cmd {
|
|
|
+ var cmds []tea.Cmd
|
|
|
+ if m.QueryVersion {
|
|
|
+ cmds = append(cmds, tea.RequestTerminalVersion)
|
|
|
+ }
|
|
|
+ // load the user commands async
|
|
|
+ cmds = append(cmds, m.loadCustomCommands())
|
|
|
+ return tea.Batch(cmds...)
|
|
|
+}
|
|
|
+
|
|
|
+// 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.Cmd {
|
|
|
+ return func() 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.App.AgentCoordinator.QueuedPrompts(m.session.ID)
|
|
|
+ if queueSize != m.promptQueue {
|
|
|
+ m.promptQueue = queueSize
|
|
|
+ m.updateLayoutAndSize()
|
|
|
+ }
|
|
|
+ }
|
|
|
+ switch msg := msg.(type) {
|
|
|
+ case tea.EnvMsg:
|
|
|
+ // Is this Windows Terminal?
|
|
|
+ if !m.sendProgressBar {
|
|
|
+ m.sendProgressBar = slices.Contains(msg, "WT_SESSION")
|
|
|
+ }
|
|
|
+ m.imgCaps.Env = uv.Environ(msg)
|
|
|
+ // XXX: Right now, we're using the same logic to determine image
|
|
|
+ // support. Terminals like Apple Terminal and possibly others might
|
|
|
+ // bleed characters when querying for Kitty graphics via APC escape
|
|
|
+ // sequences.
|
|
|
+ cmds = append(cmds, timage.RequestCapabilities(m.imgCaps.Env))
|
|
|
+ case loadSessionMsg:
|
|
|
+ m.state = uiChat
|
|
|
+ if m.forceCompactMode {
|
|
|
+ m.isCompact = true
|
|
|
+ }
|
|
|
+ m.session = msg.session
|
|
|
+ m.sessionFiles = msg.files
|
|
|
+ msgs, err := m.com.App.Messages.List(context.Background(), m.session.ID)
|
|
|
+ if err != nil {
|
|
|
+ cmds = append(cmds, uiutil.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()
|
|
|
+ }
|
|
|
+
|
|
|
+ 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 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 closeDialogMsg:
|
|
|
+ m.dialog.CloseFrontDialog()
|
|
|
+
|
|
|
+ case pubsub.Event[session.Session]:
|
|
|
+ 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[mcp.Event]:
|
|
|
+ m.mcpStates = mcp.GetStates()
|
|
|
+ // check if all mcps are initialized
|
|
|
+ initialized := true
|
|
|
+ for _, state := range m.mcpStates {
|
|
|
+ if state.State == mcp.StateStarting {
|
|
|
+ initialized = false
|
|
|
+ break
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if initialized && m.mcpPrompts == nil {
|
|
|
+ cmds = append(cmds, m.loadMCPrompts())
|
|
|
+ }
|
|
|
+ case pubsub.Event[permission.PermissionRequest]:
|
|
|
+ if cmd := m.openPermissionsDialog(msg.Payload); 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 = strings.Contains(termVersion, "ghostty")
|
|
|
+ }
|
|
|
+ return m, nil
|
|
|
+ case tea.WindowSizeMsg:
|
|
|
+ m.width, m.height = msg.Width, msg.Height
|
|
|
+ m.handleCompactMode(m.width, m.height)
|
|
|
+ m.updateLayoutAndSize()
|
|
|
+ // XXX: We need to store cell dimensions for image rendering.
|
|
|
+ m.imgCaps.Columns, m.imgCaps.Rows = msg.Width, msg.Height
|
|
|
+ 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 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...)
|
|
|
+ }
|
|
|
+ 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.HandleMouseDown(x, y) {
|
|
|
+ m.lastClickTime = time.Now()
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ 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...)
|
|
|
+ }
|
|
|
+ const doubleClickThreshold = 500 * time.Millisecond
|
|
|
+
|
|
|
+ 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(-5); 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(5); 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 anim.StepMsg:
|
|
|
+ if m.state == uiChat {
|
|
|
+ if cmd := m.chat.Animate(msg); 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:
|
|
|
+ m.textarea.SetValue(msg.Text)
|
|
|
+ m.textarea.MoveToEnd()
|
|
|
+ case uiutil.InfoMsg:
|
|
|
+ m.status.SetInfoMsg(msg)
|
|
|
+ ttl := msg.TTL
|
|
|
+ if ttl <= 0 {
|
|
|
+ ttl = DefaultStatusTTL
|
|
|
+ }
|
|
|
+ cmds = append(cmds, clearInfoMsgCmd(ttl))
|
|
|
+ case uiutil.ClearStatusMsg:
|
|
|
+ m.status.ClearInfoMsg()
|
|
|
+ case completions.FilesLoadedMsg:
|
|
|
+ // Handle async file loading for completions.
|
|
|
+ if m.completionsOpen {
|
|
|
+ m.completions.SetFiles(msg.Files)
|
|
|
+ }
|
|
|
+ case uv.WindowPixelSizeEvent:
|
|
|
+ // [timage.RequestCapabilities] requests the terminal to send a window
|
|
|
+ // size event to help determine pixel dimensions.
|
|
|
+ m.imgCaps.PixelWidth = msg.Width
|
|
|
+ m.imgCaps.PixelHeight = msg.Height
|
|
|
+ case uv.KittyGraphicsEvent:
|
|
|
+ // [timage.RequestCapabilities] sends a Kitty graphics query and this
|
|
|
+ // captures the response. Any response means the terminal understands
|
|
|
+ // the protocol.
|
|
|
+ m.imgCaps.SupportsKittyGraphics = true
|
|
|
+ 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.App.Permissions.SkipRequests() {
|
|
|
+ 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, 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.Batch(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.App.Sessions.CreateAgentToolSessionID(messageID, tc.ID)
|
|
|
+
|
|
|
+ // Fetch nested messages.
|
|
|
+ nestedMsgs, err := m.com.App.Messages.List(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 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, time.Unix(m.lastUserMessageTime, 0))
|
|
|
+ m.chat.AppendMessages(infoItem)
|
|
|
+ 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)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return tea.Batch(cmds...)
|
|
|
+}
|
|
|
+
|
|
|
+// 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)
|
|
|
+ atBottom := m.chat.list.AtBottom()
|
|
|
+
|
|
|
+ if existingItem != nil {
|
|
|
+ if assistantItem, ok := existingItem.(*chat.AssistantMessageItem); ok {
|
|
|
+ assistantItem.SetMessage(&msg)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ shouldRenderAssistant := chat.ShouldRenderAssistantMessage(&msg)
|
|
|
+ // if the message of the assistant does not have any response just tool calls we need to remove it
|
|
|
+ if !shouldRenderAssistant && len(msg.ToolCalls()) > 0 && existingItem != nil {
|
|
|
+ m.chat.RemoveMessage(msg.ID)
|
|
|
+ if infoItem := m.chat.MessageItem(chat.AssistantInfoID(msg.ID)); infoItem != nil {
|
|
|
+ m.chat.RemoveMessage(chat.AssistantInfoID(msg.ID))
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if shouldRenderAssistant && msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
|
|
|
+ if infoItem := m.chat.MessageItem(chat.AssistantInfoID(msg.ID)); infoItem == nil {
|
|
|
+ newInfoItem := chat.NewAssistantInfoItem(m.com.Styles, &msg, 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 atBottom {
|
|
|
+ if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
|
|
|
+ cmds = append(cmds, cmd)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return tea.Batch(cmds...)
|
|
|
+}
|
|
|
+
|
|
|
+// handleChildSessionMessage handles messages from child sessions (agent tools).
|
|
|
+func (m *UI) handleChildSessionMessage(event pubsub.Event[message.Message]) tea.Cmd {
|
|
|
+ var cmds []tea.Cmd
|
|
|
+
|
|
|
+ atBottom := m.chat.list.AtBottom()
|
|
|
+ // 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.App.Sessions.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 atBottom {
|
|
|
+ if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
|
|
|
+ cmds = append(cmds, cmd)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return tea.Batch(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...)
|
|
|
+ }
|
|
|
+
|
|
|
+ switch msg := action.(type) {
|
|
|
+ // Generic dialog messages
|
|
|
+ case dialog.ActionClose:
|
|
|
+ m.dialog.CloseFrontDialog()
|
|
|
+ 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.App.Permissions.SkipRequests()
|
|
|
+ m.com.App.Permissions.SetSkipRequests(yolo)
|
|
|
+ m.setEditorPrompt(yolo)
|
|
|
+ m.dialog.CloseDialog(dialog.CommandsID)
|
|
|
+ case dialog.ActionNewSession:
|
|
|
+ if m.isAgentBusy() {
|
|
|
+ cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session..."))
|
|
|
+ break
|
|
|
+ }
|
|
|
+ m.newSession()
|
|
|
+ m.dialog.CloseDialog(dialog.CommandsID)
|
|
|
+ case dialog.ActionSummarize:
|
|
|
+ if m.isAgentBusy() {
|
|
|
+ cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before summarizing session..."))
|
|
|
+ break
|
|
|
+ }
|
|
|
+ cmds = append(cmds, func() tea.Msg {
|
|
|
+ err := m.com.App.AgentCoordinator.Summarize(context.Background(), msg.SessionID)
|
|
|
+ if err != nil {
|
|
|
+ return uiutil.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, uiutil.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.ActionToggleThinking:
|
|
|
+ if m.isAgentBusy() {
|
|
|
+ cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait..."))
|
|
|
+ break
|
|
|
+ }
|
|
|
+
|
|
|
+ cmds = append(cmds, func() tea.Msg {
|
|
|
+ cfg := m.com.Config()
|
|
|
+ if cfg == nil {
|
|
|
+ return uiutil.ReportError(errors.New("configuration not found"))()
|
|
|
+ }
|
|
|
+
|
|
|
+ agentCfg, ok := cfg.Agents[config.AgentCoder]
|
|
|
+ if !ok {
|
|
|
+ return uiutil.ReportError(errors.New("agent configuration not found"))()
|
|
|
+ }
|
|
|
+
|
|
|
+ currentModel := cfg.Models[agentCfg.Model]
|
|
|
+ currentModel.Think = !currentModel.Think
|
|
|
+ if err := cfg.UpdatePreferredModel(agentCfg.Model, currentModel); err != nil {
|
|
|
+ return uiutil.ReportError(err)()
|
|
|
+ }
|
|
|
+ m.com.App.UpdateAgentModel(context.TODO())
|
|
|
+ status := "disabled"
|
|
|
+ if currentModel.Think {
|
|
|
+ status = "enabled"
|
|
|
+ }
|
|
|
+ return uiutil.NewInfoMsg("Thinking mode " + status)
|
|
|
+ })
|
|
|
+ m.dialog.CloseDialog(dialog.CommandsID)
|
|
|
+ case dialog.ActionQuit:
|
|
|
+ cmds = append(cmds, tea.Quit)
|
|
|
+ case dialog.ActionInitializeProject:
|
|
|
+ if m.isAgentBusy() {
|
|
|
+ cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before summarizing session..."))
|
|
|
+ break
|
|
|
+ }
|
|
|
+ cmds = append(cmds, m.initializeProject())
|
|
|
+ m.dialog.CloseDialog(dialog.CommandsID)
|
|
|
+
|
|
|
+ case dialog.ActionSelectModel:
|
|
|
+ if m.isAgentBusy() {
|
|
|
+ cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait..."))
|
|
|
+ break
|
|
|
+ }
|
|
|
+
|
|
|
+ cfg := m.com.Config()
|
|
|
+ if cfg == nil {
|
|
|
+ cmds = append(cmds, uiutil.ReportError(errors.New("configuration not found")))
|
|
|
+ break
|
|
|
+ }
|
|
|
+
|
|
|
+ var (
|
|
|
+ providerID = msg.Model.Provider
|
|
|
+ isCopilot = providerID == string(catwalk.InferenceProviderCopilot)
|
|
|
+ isConfigured = func() bool { _, ok := cfg.Providers.Get(providerID); return ok }
|
|
|
+ )
|
|
|
+
|
|
|
+ // Attempt to import GitHub Copilot tokens from VSCode if available.
|
|
|
+ if isCopilot && !isConfigured() {
|
|
|
+ config.Get().ImportCopilot()
|
|
|
+ }
|
|
|
+
|
|
|
+ if !isConfigured() {
|
|
|
+ m.dialog.CloseDialog(dialog.ModelsID)
|
|
|
+ if cmd := m.openAuthenticationDialog(msg.Provider, msg.Model, msg.ModelType); cmd != nil {
|
|
|
+ cmds = append(cmds, cmd)
|
|
|
+ }
|
|
|
+ break
|
|
|
+ }
|
|
|
+
|
|
|
+ if err := cfg.UpdatePreferredModel(msg.ModelType, msg.Model); err != nil {
|
|
|
+ cmds = append(cmds, uiutil.ReportError(err))
|
|
|
+ }
|
|
|
+
|
|
|
+ cmds = append(cmds, func() tea.Msg {
|
|
|
+ m.com.App.UpdateAgentModel(context.TODO())
|
|
|
+
|
|
|
+ modelMsg := fmt.Sprintf("%s model changed to %s", msg.ModelType, msg.Model.Model)
|
|
|
+
|
|
|
+ return uiutil.NewInfoMsg(modelMsg)
|
|
|
+ })
|
|
|
+
|
|
|
+ m.dialog.CloseDialog(dialog.APIKeyInputID)
|
|
|
+ m.dialog.CloseDialog(dialog.OAuthID)
|
|
|
+ m.dialog.CloseDialog(dialog.ModelsID)
|
|
|
+ case dialog.ActionSelectReasoningEffort:
|
|
|
+ if m.isAgentBusy() {
|
|
|
+ cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait..."))
|
|
|
+ break
|
|
|
+ }
|
|
|
+
|
|
|
+ cfg := m.com.Config()
|
|
|
+ if cfg == nil {
|
|
|
+ cmds = append(cmds, uiutil.ReportError(errors.New("configuration not found")))
|
|
|
+ break
|
|
|
+ }
|
|
|
+
|
|
|
+ agentCfg, ok := cfg.Agents[config.AgentCoder]
|
|
|
+ if !ok {
|
|
|
+ cmds = append(cmds, uiutil.ReportError(errors.New("agent configuration not found")))
|
|
|
+ break
|
|
|
+ }
|
|
|
+
|
|
|
+ currentModel := cfg.Models[agentCfg.Model]
|
|
|
+ currentModel.ReasoningEffort = msg.Effort
|
|
|
+ if err := cfg.UpdatePreferredModel(agentCfg.Model, currentModel); err != nil {
|
|
|
+ cmds = append(cmds, uiutil.ReportError(err))
|
|
|
+ break
|
|
|
+ }
|
|
|
+
|
|
|
+ cmds = append(cmds, func() tea.Msg {
|
|
|
+ m.com.App.UpdateAgentModel(context.TODO())
|
|
|
+ return uiutil.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.App.Permissions.Grant(msg.Permission)
|
|
|
+ case dialog.PermissionAllowForSession:
|
|
|
+ m.com.App.Permissions.GrantPersistent(msg.Permission)
|
|
|
+ case dialog.PermissionDeny:
|
|
|
+ m.com.App.Permissions.Deny(msg.Permission)
|
|
|
+ }
|
|
|
+
|
|
|
+ case dialog.ActionFilePickerSelected:
|
|
|
+ cmds = append(cmds, tea.Sequence(
|
|
|
+ msg.Cmd(),
|
|
|
+ func() tea.Msg {
|
|
|
+ m.dialog.CloseDialog(dialog.FilePickerID)
|
|
|
+ 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 := msg.Title
|
|
|
+ if title == "" {
|
|
|
+ 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, uiutil.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
|
|
|
+}
|
|
|
+
|
|
|
+func (m *UI) openAuthenticationDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd {
|
|
|
+ var (
|
|
|
+ dlg dialog.Dialog
|
|
|
+ cmd tea.Cmd
|
|
|
+ )
|
|
|
+
|
|
|
+ switch provider.ID {
|
|
|
+ case "hyper":
|
|
|
+ dlg, cmd = dialog.NewOAuthHyper(m.com, provider, model, modelType)
|
|
|
+ case catwalk.InferenceProviderCopilot:
|
|
|
+ dlg, cmd = dialog.NewOAuthCopilot(m.com, provider, model, modelType)
|
|
|
+ default:
|
|
|
+ dlg, cmd = dialog.NewAPIKeyInput(m.com, 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 {
|
|
|
+ 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 {
|
|
|
+ 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, uiutil.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 uiConfigure:
|
|
|
+ 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:
|
|
|
+ // Handle file completion selection.
|
|
|
+ if item, ok := msg.Value.(completions.FileCompletionValue); ok {
|
|
|
+ cmds = append(cmds, m.insertFileCompletion(item.Path))
|
|
|
+ }
|
|
|
+ if !msg.Insert {
|
|
|
+ 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 cmd := m.openFilesDialog(); cmd != nil {
|
|
|
+ cmds = append(cmds, cmd)
|
|
|
+ }
|
|
|
+
|
|
|
+ case key.Matches(msg, m.keyMap.Editor.SendMessage):
|
|
|
+ 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)
|
|
|
+ break
|
|
|
+ }
|
|
|
+
|
|
|
+ // Otherwise, send the message
|
|
|
+ m.textarea.Reset()
|
|
|
+
|
|
|
+ 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()
|
|
|
+
|
|
|
+ return m.sendMessage(value, attachments...)
|
|
|
+ case key.Matches(msg, m.keyMap.Chat.NewSession):
|
|
|
+ if !m.hasSession() {
|
|
|
+ break
|
|
|
+ }
|
|
|
+ if m.isAgentBusy() {
|
|
|
+ cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session..."))
|
|
|
+ break
|
|
|
+ }
|
|
|
+ m.newSession()
|
|
|
+ case key.Matches(msg, m.keyMap.Tab):
|
|
|
+ if m.state != uiLanding {
|
|
|
+ m.focus = 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, uiutil.ReportWarn("Agent is working, please wait..."))
|
|
|
+ break
|
|
|
+ }
|
|
|
+ cmds = append(cmds, m.openEditor(m.textarea.Value()))
|
|
|
+ case key.Matches(msg, m.keyMap.Editor.Newline):
|
|
|
+ m.textarea.InsertRune('\n')
|
|
|
+ m.closeCompletions()
|
|
|
+ ta, cmd := m.textarea.Update(msg)
|
|
|
+ m.textarea = ta
|
|
|
+ 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.OpenWithFiles(depth, limit))
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // remove the details if they are open when user starts typing
|
|
|
+ if m.detailsOpen {
|
|
|
+ m.detailsOpen = false
|
|
|
+ m.updateLayoutAndSize()
|
|
|
+ }
|
|
|
+
|
|
|
+ ta, cmd := m.textarea.Update(msg)
|
|
|
+ m.textarea = ta
|
|
|
+ cmds = append(cmds, cmd)
|
|
|
+
|
|
|
+ // 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, uiutil.ReportWarn("Agent is busy, please wait before starting a new session..."))
|
|
|
+ break
|
|
|
+ }
|
|
|
+ m.focus = uiFocusEditor
|
|
|
+ m.newSession()
|
|
|
+ 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:
|
|
|
+ handleGlobalKeys(msg)
|
|
|
+ }
|
|
|
+ default:
|
|
|
+ handleGlobalKeys(msg)
|
|
|
+ }
|
|
|
+ default:
|
|
|
+ handleGlobalKeys(msg)
|
|
|
+ }
|
|
|
+
|
|
|
+ return tea.Batch(cmds...)
|
|
|
+}
|
|
|
+
|
|
|
+// 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 uiConfigure:
|
|
|
+ header := uv.NewStyledString(m.header)
|
|
|
+ header.Draw(scr, layout.header)
|
|
|
+
|
|
|
+ mainView := lipgloss.NewStyle().Width(layout.main.Dx()).
|
|
|
+ Height(layout.main.Dy()).
|
|
|
+ Background(lipgloss.ANSIColor(rand.Intn(256))).
|
|
|
+ Render(" Configure ")
|
|
|
+ main := uv.NewStyledString(mainView)
|
|
|
+ main.Draw(scr, layout.main)
|
|
|
+
|
|
|
+ case uiInitialize:
|
|
|
+ header := uv.NewStyledString(m.header)
|
|
|
+ header.Draw(scr, layout.header)
|
|
|
+
|
|
|
+ main := uv.NewStyledString(m.initializeView())
|
|
|
+ main.Draw(scr, layout.main)
|
|
|
+
|
|
|
+ case uiLanding:
|
|
|
+ header := uv.NewStyledString(m.header)
|
|
|
+ header.Draw(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 {
|
|
|
+ header := uv.NewStyledString(m.header)
|
|
|
+ header.Draw(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)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Add status and help layer
|
|
|
+ m.status.Draw(scr, layout.status)
|
|
|
+
|
|
|
+ // Draw completions popup if open
|
|
|
+ if 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)
|
|
|
+
|
|
|
+ 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
|
|
|
+ // Offset for attachment row if present.
|
|
|
+ if len(m.attachments.List()) > 0 {
|
|
|
+ cur.Y++
|
|
|
+ }
|
|
|
+ return cur
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return nil
|
|
|
+}
|
|
|
+
|
|
|
+// View renders the UI model's view.
|
|
|
+func (m *UI) View() tea.View {
|
|
|
+ var v tea.View
|
|
|
+ v.AltScreen = true
|
|
|
+ v.BackgroundColor = m.com.Styles.Background
|
|
|
+ v.MouseMode = tea.MouseModeCellMotion
|
|
|
+ v.WindowTitle = "crush " + home.Short(m.com.Config().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.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.LineCount() == 0 {
|
|
|
+ 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.App.AgentCoordinator.QueuedPrompts(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.LineCount() == 0 {
|
|
|
+ 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.App.AgentCoordinator.QueuedPrompts(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:
|
|
|
+ binds = append(binds,
|
|
|
+ []key.Binding{
|
|
|
+ k.Editor.Newline,
|
|
|
+ k.Editor.AddImage,
|
|
|
+ k.Editor.MentionFile,
|
|
|
+ k.Editor.OpenEditor,
|
|
|
+ },
|
|
|
+ )
|
|
|
+ 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,
|
|
|
+ },
|
|
|
+ []key.Binding{
|
|
|
+ k.Editor.Newline,
|
|
|
+ k.Editor.AddImage,
|
|
|
+ k.Editor.MentionFile,
|
|
|
+ k.Editor.OpenEditor,
|
|
|
+ },
|
|
|
+ )
|
|
|
+ if hasAttachments {
|
|
|
+ binds = append(binds,
|
|
|
+ []key.Binding{
|
|
|
+ k.Editor.AttachmentDeleteMode,
|
|
|
+ k.Editor.DeleteAllAttachments,
|
|
|
+ k.Editor.Escape,
|
|
|
+ },
|
|
|
+ )
|
|
|
+ }
|
|
|
+ binds = append(binds,
|
|
|
+ []key.Binding{
|
|
|
+ help,
|
|
|
+ },
|
|
|
+ )
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ binds = append(binds,
|
|
|
+ []key.Binding{
|
|
|
+ help,
|
|
|
+ k.Quit,
|
|
|
+ },
|
|
|
+ )
|
|
|
+
|
|
|
+ return binds
|
|
|
+}
|
|
|
+
|
|
|
+// toggleCompactMode toggles compact mode between uiChat and uiChatCompact states.
|
|
|
+func (m *UI) toggleCompactMode() tea.Cmd {
|
|
|
+ m.forceCompactMode = !m.forceCompactMode
|
|
|
+
|
|
|
+ err := m.com.Config().SetCompactMode(m.forceCompactMode)
|
|
|
+ if err != nil {
|
|
|
+ return uiutil.ReportError(err)
|
|
|
+ }
|
|
|
+
|
|
|
+ m.handleCompactMode(m.width, m.height)
|
|
|
+ m.updateLayoutAndSize()
|
|
|
+
|
|
|
+ return nil
|
|
|
+}
|
|
|
+
|
|
|
+// handleCompactMode updates the UI state based on window size and compact mode setting.
|
|
|
+func (m *UI) handleCompactMode(newWidth, newHeight int) {
|
|
|
+ if m.state == uiChat {
|
|
|
+ if m.forceCompactMode {
|
|
|
+ m.isCompact = true
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if newWidth < compactModeWidthBreakpoint || newHeight < compactModeHeightBreakpoint {
|
|
|
+ m.isCompact = true
|
|
|
+ } else {
|
|
|
+ m.isCompact = false
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// updateLayoutAndSize updates the layout and sizes of UI components.
|
|
|
+func (m *UI) updateLayoutAndSize() {
|
|
|
+ m.layout = m.generateLayout(m.width, m.height)
|
|
|
+ m.updateSize()
|
|
|
+}
|
|
|
+
|
|
|
+// 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.SetWidth(m.layout.editor.Dx())
|
|
|
+ m.textarea.SetHeight(m.layout.editor.Dy())
|
|
|
+ m.renderPills()
|
|
|
+
|
|
|
+ // Handle different app states
|
|
|
+ switch m.state {
|
|
|
+ case uiConfigure, uiInitialize, uiLanding:
|
|
|
+ m.renderHeader(false, m.layout.header.Dx())
|
|
|
+
|
|
|
+ case uiChat:
|
|
|
+ if m.isCompact {
|
|
|
+ m.renderHeader(true, m.layout.header.Dx())
|
|
|
+ } else {
|
|
|
+ m.renderSidebarLogo(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) layout {
|
|
|
+ // The screen area we're working with
|
|
|
+ area := image.Rect(0, 0, w, h)
|
|
|
+
|
|
|
+ // The help height
|
|
|
+ helpHeight := 1
|
|
|
+ // The editor height
|
|
|
+ editorHeight := 5
|
|
|
+ // The sidebar width
|
|
|
+ sidebarWidth := 30
|
|
|
+ // The header height
|
|
|
+ const landingHeaderHeight = 4
|
|
|
+
|
|
|
+ var helpKeyMap help.KeyMap = m
|
|
|
+ if m.status.ShowingAll() {
|
|
|
+ for _, row := range helpKeyMap.FullHelp() {
|
|
|
+ helpHeight = max(helpHeight, len(row))
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Add app margins
|
|
|
+ appRect, helpRect := uv.SplitVertical(area, uv.Fixed(area.Dy()-helpHeight))
|
|
|
+ appRect.Min.Y += 1
|
|
|
+ appRect.Max.Y -= 1
|
|
|
+ helpRect.Min.Y -= 1
|
|
|
+ appRect.Min.X += 1
|
|
|
+ appRect.Max.X -= 1
|
|
|
+
|
|
|
+ if slices.Contains([]uiState{uiConfigure, uiInitialize, uiLanding}, m.state) {
|
|
|
+ // extra padding on left and right for these states
|
|
|
+ appRect.Min.X += 1
|
|
|
+ appRect.Max.X -= 1
|
|
|
+ }
|
|
|
+
|
|
|
+ layout := layout{
|
|
|
+ area: area,
|
|
|
+ status: helpRect,
|
|
|
+ }
|
|
|
+
|
|
|
+ // Handle different app states
|
|
|
+ switch m.state {
|
|
|
+ case uiConfigure, uiInitialize:
|
|
|
+ // Layout
|
|
|
+ //
|
|
|
+ // header
|
|
|
+ // ------
|
|
|
+ // main
|
|
|
+ // ------
|
|
|
+ // help
|
|
|
+
|
|
|
+ headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(landingHeaderHeight))
|
|
|
+ layout.header = headerRect
|
|
|
+ layout.main = mainRect
|
|
|
+
|
|
|
+ case uiLanding:
|
|
|
+ // Layout
|
|
|
+ //
|
|
|
+ // header
|
|
|
+ // ------
|
|
|
+ // main
|
|
|
+ // ------
|
|
|
+ // editor
|
|
|
+ // ------
|
|
|
+ // help
|
|
|
+ headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(landingHeaderHeight))
|
|
|
+ mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
|
|
|
+ // Remove extra padding from editor (but keep it for header and main)
|
|
|
+ editorRect.Min.X -= 1
|
|
|
+ editorRect.Max.X += 1
|
|
|
+ layout.header = headerRect
|
|
|
+ layout.main = mainRect
|
|
|
+ layout.editor = editorRect
|
|
|
+
|
|
|
+ case uiChat:
|
|
|
+ if m.isCompact {
|
|
|
+ // Layout
|
|
|
+ //
|
|
|
+ // compact-header
|
|
|
+ // ------
|
|
|
+ // main
|
|
|
+ // ------
|
|
|
+ // editor
|
|
|
+ // ------
|
|
|
+ // help
|
|
|
+ const compactHeaderHeight = 1
|
|
|
+ headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(compactHeaderHeight))
|
|
|
+ detailsHeight := min(sessionDetailsMaxHeight, area.Dy()-1) // One row for the header
|
|
|
+ sessionDetailsArea, _ := uv.SplitVertical(appRect, uv.Fixed(detailsHeight))
|
|
|
+ layout.sessionDetails = sessionDetailsArea
|
|
|
+ layout.sessionDetails.Min.Y += compactHeaderHeight // adjust for header
|
|
|
+ // Add one line gap between header and main content
|
|
|
+ mainRect.Min.Y += 1
|
|
|
+ mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
|
|
|
+ mainRect.Max.X -= 1 // Add padding right
|
|
|
+ layout.header = headerRect
|
|
|
+ pillsHeight := m.pillsAreaHeight()
|
|
|
+ if pillsHeight > 0 {
|
|
|
+ pillsHeight = min(pillsHeight, mainRect.Dy())
|
|
|
+ chatRect, pillsRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-pillsHeight))
|
|
|
+ layout.main = chatRect
|
|
|
+ layout.pills = pillsRect
|
|
|
+ } else {
|
|
|
+ layout.main = mainRect
|
|
|
+ }
|
|
|
+ // Add bottom margin to main
|
|
|
+ layout.main.Max.Y -= 1
|
|
|
+ layout.editor = editorRect
|
|
|
+ } else {
|
|
|
+ // Layout
|
|
|
+ //
|
|
|
+ // ------|---
|
|
|
+ // main |
|
|
|
+ // ------| side
|
|
|
+ // editor|
|
|
|
+ // ----------
|
|
|
+ // help
|
|
|
+
|
|
|
+ mainRect, sideRect := uv.SplitHorizontal(appRect, uv.Fixed(appRect.Dx()-sidebarWidth))
|
|
|
+ // Add padding left
|
|
|
+ sideRect.Min.X += 1
|
|
|
+ mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
|
|
|
+ mainRect.Max.X -= 1 // Add padding right
|
|
|
+ layout.sidebar = sideRect
|
|
|
+ pillsHeight := m.pillsAreaHeight()
|
|
|
+ if pillsHeight > 0 {
|
|
|
+ pillsHeight = min(pillsHeight, mainRect.Dy())
|
|
|
+ chatRect, pillsRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-pillsHeight))
|
|
|
+ layout.main = chatRect
|
|
|
+ layout.pills = pillsRect
|
|
|
+ } else {
|
|
|
+ layout.main = mainRect
|
|
|
+ }
|
|
|
+ // Add bottom margin to main
|
|
|
+ layout.main.Max.Y -= 1
|
|
|
+ layout.editor = editorRect
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if !layout.editor.Empty() {
|
|
|
+ // Add editor margins 1 top and bottom
|
|
|
+ layout.editor.Min.Y += 1
|
|
|
+ layout.editor.Max.Y -= 1
|
|
|
+ }
|
|
|
+
|
|
|
+ return layout
|
|
|
+}
|
|
|
+
|
|
|
+// layout defines the positioning of UI elements.
|
|
|
+type layout 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 uiutil.ReportError(err)
|
|
|
+ }
|
|
|
+ defer tmpfile.Close() //nolint:errcheck
|
|
|
+ if _, err := tmpfile.WriteString(value); err != nil {
|
|
|
+ return uiutil.ReportError(err)
|
|
|
+ }
|
|
|
+ cmd, err := editor.Command(
|
|
|
+ "crush",
|
|
|
+ tmpfile.Name(),
|
|
|
+ editor.AtPosition(
|
|
|
+ m.textarea.Line()+1,
|
|
|
+ m.textarea.Column()+1,
|
|
|
+ ),
|
|
|
+ )
|
|
|
+ if err != nil {
|
|
|
+ return uiutil.ReportError(err)
|
|
|
+ }
|
|
|
+ return tea.ExecProcess(cmd, func(err error) tea.Msg {
|
|
|
+ if err != nil {
|
|
|
+ return uiutil.ReportError(err)
|
|
|
+ }
|
|
|
+ content, err := os.ReadFile(tmpfile.Name())
|
|
|
+ if err != nil {
|
|
|
+ return uiutil.ReportError(err)
|
|
|
+ }
|
|
|
+ if len(content) == 0 {
|
|
|
+ return uiutil.ReportWarn("Message is empty")
|
|
|
+ }
|
|
|
+ os.Remove(tmpfile.Name())
|
|
|
+ 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.EditorPromptNormalFocused.Render()
|
|
|
+ }
|
|
|
+ return t.EditorPromptNormalBlurred.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.EditorPromptYoloIconFocused.Render()
|
|
|
+ } else {
|
|
|
+ return t.EditorPromptYoloIconBlurred.Render()
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if info.Focused {
|
|
|
+ return t.EditorPromptYoloDotsFocused.Render()
|
|
|
+ }
|
|
|
+ return t.EditorPromptYoloDotsBlurred.Render()
|
|
|
+}
|
|
|
+
|
|
|
+// closeCompletions closes the completions popup and resets state.
|
|
|
+func (m *UI) closeCompletions() {
|
|
|
+ m.completionsOpen = false
|
|
|
+ m.completionsQuery = ""
|
|
|
+ m.completionsStartIndex = 0
|
|
|
+ m.completions.Close()
|
|
|
+}
|
|
|
+
|
|
|
+// 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 {
|
|
|
+ value := m.textarea.Value()
|
|
|
+ word := m.textareaWord()
|
|
|
+
|
|
|
+ // Find the @ and query to replace.
|
|
|
+ if m.completionsStartIndex > len(value) {
|
|
|
+ return nil
|
|
|
+ }
|
|
|
+
|
|
|
+ // Build the new value: everything before @, the path, everything after query.
|
|
|
+ endIdx := min(m.completionsStartIndex+len(word), len(value))
|
|
|
+
|
|
|
+ newValue := value[:m.completionsStartIndex] + path + value[endIdx:]
|
|
|
+ m.textarea.SetValue(newValue)
|
|
|
+ m.textarea.MoveToEnd()
|
|
|
+ m.textarea.InsertRune(' ')
|
|
|
+
|
|
|
+ return func() tea.Msg {
|
|
|
+ absPath, _ := filepath.Abs(path)
|
|
|
+ // Skip attachment if file was already read and hasn't been modified.
|
|
|
+ lastRead := filetracker.LastReadTime(absPath)
|
|
|
+ if !lastRead.IsZero() {
|
|
|
+ if info, err := os.Stat(path); err == nil && !info.ModTime().After(lastRead) {
|
|
|
+ return nil
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Add file as attachment.
|
|
|
+ content, err := os.ReadFile(path)
|
|
|
+ if err != nil {
|
|
|
+ // If it fails, let the LLM handle it later.
|
|
|
+ return nil
|
|
|
+ }
|
|
|
+ filetracker.RecordRead(absPath)
|
|
|
+
|
|
|
+ return message.Attachment{
|
|
|
+ FilePath: path,
|
|
|
+ FileName: filepath.Base(path),
|
|
|
+ MimeType: mimeOf(content),
|
|
|
+ Content: content,
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 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.App != nil &&
|
|
|
+ m.com.App.AgentCoordinator != nil &&
|
|
|
+ m.com.App.AgentCoordinator.IsBusy()
|
|
|
+}
|
|
|
+
|
|
|
+// 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 {
|
|
|
+ if len(m.attachments.List()) == 0 {
|
|
|
+ return m.textarea.View()
|
|
|
+ }
|
|
|
+ return lipgloss.JoinVertical(
|
|
|
+ lipgloss.Top,
|
|
|
+ m.attachments.Render(width),
|
|
|
+ m.textarea.View(),
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+// renderHeader renders and caches the header logo at the specified width.
|
|
|
+func (m *UI) renderHeader(compact bool, width int) {
|
|
|
+ if compact && m.session != nil && m.com.App != nil {
|
|
|
+ m.header = renderCompactHeader(m.com, m.session, m.com.App.LSPClients, m.detailsOpen, width)
|
|
|
+ } else {
|
|
|
+ m.header = renderLogo(m.com.Styles, compact, width)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// renderSidebarLogo renders and caches the sidebar logo at the specified
|
|
|
+// width.
|
|
|
+func (m *UI) renderSidebarLogo(width int) {
|
|
|
+ m.sidebarLogo = renderLogo(m.com.Styles, true, width)
|
|
|
+}
|
|
|
+
|
|
|
+// sendMessage sends a message with the given content and attachments.
|
|
|
+func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.Cmd {
|
|
|
+ if m.com.App.AgentCoordinator == nil {
|
|
|
+ return uiutil.ReportError(fmt.Errorf("coder agent is not initialized"))
|
|
|
+ }
|
|
|
+
|
|
|
+ var cmds []tea.Cmd
|
|
|
+ if !m.hasSession() {
|
|
|
+ newSession, err := m.com.App.Sessions.Create(context.Background(), "New Session")
|
|
|
+ if err != nil {
|
|
|
+ return uiutil.ReportError(err)
|
|
|
+ }
|
|
|
+ m.state = uiChat
|
|
|
+ if m.forceCompactMode {
|
|
|
+ m.isCompact = true
|
|
|
+ }
|
|
|
+ if newSession.ID != "" {
|
|
|
+ m.session = &newSession
|
|
|
+ cmds = append(cmds, m.loadSession(newSession.ID))
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 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.App.AgentCoordinator.Run(context.Background(), sessionID, content, attachments...)
|
|
|
+ if err != nil {
|
|
|
+ isCancelErr := errors.Is(err, context.Canceled)
|
|
|
+ isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)
|
|
|
+ if isCancelErr || isPermissionErr {
|
|
|
+ return nil
|
|
|
+ }
|
|
|
+ return uiutil.InfoMsg{
|
|
|
+ Type: uiutil.InfoTypeError,
|
|
|
+ Msg: err.Error(),
|
|
|
+ }
|
|
|
+ }
|
|
|
+ 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
|
|
|
+ }
|
|
|
+
|
|
|
+ coordinator := m.com.App.AgentCoordinator
|
|
|
+ if coordinator == nil {
|
|
|
+ return nil
|
|
|
+ }
|
|
|
+
|
|
|
+ if m.isCanceling {
|
|
|
+ // Second escape press - actually cancel the agent.
|
|
|
+ m.isCanceling = false
|
|
|
+ coordinator.Cancel(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 coordinator.QueuedPrompts(m.session.ID) > 0 {
|
|
|
+ coordinator.ClearQueue(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.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
|
|
|
+ }
|
|
|
+
|
|
|
+ modelsDialog, err := dialog.NewModels(m.com)
|
|
|
+ if err != nil {
|
|
|
+ return uiutil.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
|
|
|
+ }
|
|
|
+
|
|
|
+ sessionID := ""
|
|
|
+ if m.session != nil {
|
|
|
+ sessionID = m.session.ID
|
|
|
+ }
|
|
|
+
|
|
|
+ commands, err := dialog.NewCommands(m.com, sessionID, m.customCommands, m.mcpPrompts)
|
|
|
+ if err != nil {
|
|
|
+ return uiutil.ReportError(err)
|
|
|
+ }
|
|
|
+
|
|
|
+ m.dialog.OpenDialog(commands)
|
|
|
+
|
|
|
+ return nil
|
|
|
+}
|
|
|
+
|
|
|
+// 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 uiutil.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 uiutil.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.imgCaps)
|
|
|
+ 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)
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// newSession clears the current session state and prepares for a new session.
|
|
|
+// The actual session creation happens when the user sends their first message.
|
|
|
+func (m *UI) newSession() {
|
|
|
+ if !m.hasSession() {
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ m.session = nil
|
|
|
+ m.sessionFiles = nil
|
|
|
+ m.state = uiLanding
|
|
|
+ m.focus = uiFocusEditor
|
|
|
+ m.textarea.Focus()
|
|
|
+ m.chat.Blur()
|
|
|
+ m.chat.ClearMessages()
|
|
|
+ m.pillsExpanded = false
|
|
|
+ m.promptQueue = 0
|
|
|
+ m.pillsView = ""
|
|
|
+}
|
|
|
+
|
|
|
+// handlePasteMsg handles a paste message.
|
|
|
+func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
|
|
|
+ if m.dialog.HasDialogs() {
|
|
|
+ return m.handleDialogMsg(msg)
|
|
|
+ }
|
|
|
+
|
|
|
+ if m.focus != uiFocusEditor {
|
|
|
+ return nil
|
|
|
+ }
|
|
|
+
|
|
|
+ if strings.Count(msg.Content, "\n") > pasteLinesThreshold {
|
|
|
+ return func() tea.Msg {
|
|
|
+ content := []byte(msg.Content)
|
|
|
+ if int64(len(content)) > common.MaxAttachmentSize {
|
|
|
+ return uiutil.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,
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ var cmd tea.Cmd
|
|
|
+ path := strings.ReplaceAll(msg.Content, "\\ ", " ")
|
|
|
+ // Try to get an image.
|
|
|
+ path, err := filepath.Abs(strings.TrimSpace(path))
|
|
|
+ if err != nil {
|
|
|
+ m.textarea, cmd = m.textarea.Update(msg)
|
|
|
+ return cmd
|
|
|
+ }
|
|
|
+
|
|
|
+ // Check if file has an allowed image extension.
|
|
|
+ isAllowedType := false
|
|
|
+ lowerPath := strings.ToLower(path)
|
|
|
+ for _, ext := range common.AllowedImageTypes {
|
|
|
+ if strings.HasSuffix(lowerPath, ext) {
|
|
|
+ isAllowedType = true
|
|
|
+ break
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if !isAllowedType {
|
|
|
+ m.textarea, cmd = m.textarea.Update(msg)
|
|
|
+ return cmd
|
|
|
+ }
|
|
|
+
|
|
|
+ return func() tea.Msg {
|
|
|
+ fileInfo, err := os.Stat(path)
|
|
|
+ if err != nil {
|
|
|
+ return uiutil.ReportError(err)
|
|
|
+ }
|
|
|
+ if fileInfo.Size() > common.MaxAttachmentSize {
|
|
|
+ return uiutil.ReportWarn("File is too big (>5mb)")
|
|
|
+ }
|
|
|
+
|
|
|
+ content, err := os.ReadFile(path)
|
|
|
+ if err != nil {
|
|
|
+ return uiutil.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,
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+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.Foreground(s.Border).Width(width).AlignHorizontal(lipgloss.Right).Render(version.Version)
|
|
|
+
|
|
|
+ remainingHeight := height - lipgloss.Height(detailsHeader) - lipgloss.Height(version)
|
|
|
+
|
|
|
+ const maxSectionWidth = 50
|
|
|
+ sectionWidth := min(maxSectionWidth, width/3-2) // account for 2 spaces
|
|
|
+ maxItemsPerSection := remainingHeight - 3 // Account for section title and spacing
|
|
|
+
|
|
|
+ lspSection := m.lspInfo(sectionWidth, maxItemsPerSection, false)
|
|
|
+ mcpSection := m.mcpInfo(sectionWidth, maxItemsPerSection, false)
|
|
|
+ filesSection := m.filesInfo(m.com.Config().WorkingDir(), sectionWidth, maxItemsPerSection, false)
|
|
|
+ sections := lipgloss.JoinHorizontal(lipgloss.Top, filesSection, " ", lspSection, " ", mcpSection)
|
|
|
+ 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 := commands.GetMCPPrompt(clientID, promptID, arguments)
|
|
|
+ if err != nil {
|
|
|
+ // TODO: make this better
|
|
|
+ return uiutil.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) copyChatHighlight() tea.Cmd {
|
|
|
+ text := m.chat.HighlightContent()
|
|
|
+ return tea.Sequence(
|
|
|
+ tea.SetClipboard(text),
|
|
|
+ func() tea.Msg {
|
|
|
+ _ = clipboard.WriteAll(text)
|
|
|
+ return nil
|
|
|
+ },
|
|
|
+ func() tea.Msg {
|
|
|
+ m.chat.ClearMouse()
|
|
|
+ return nil
|
|
|
+ },
|
|
|
+ uiutil.ReportInfo("Selected text copied to clipboard"),
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+// renderLogo renders the Crush logo with the given styles and dimensions.
|
|
|
+func renderLogo(t *styles.Styles, compact bool, width int) string {
|
|
|
+ return logo.Render(version.Version, compact, logo.Opts{
|
|
|
+ FieldColor: t.LogoFieldColor,
|
|
|
+ TitleColorA: t.LogoTitleColorA,
|
|
|
+ TitleColorB: t.LogoTitleColorB,
|
|
|
+ CharmColor: t.LogoCharmColor,
|
|
|
+ VersionColor: t.LogoVersionColor,
|
|
|
+ Width: width,
|
|
|
+ })
|
|
|
+}
|