ui.go

   1package model
   2
   3import (
   4	"bytes"
   5	"cmp"
   6	"context"
   7	"errors"
   8	"fmt"
   9	"image"
  10	"log/slog"
  11	"math/rand"
  12	"net/http"
  13	"os"
  14	"path/filepath"
  15	"regexp"
  16	"slices"
  17	"strconv"
  18	"strings"
  19	"time"
  20
  21	"charm.land/bubbles/v2/help"
  22	"charm.land/bubbles/v2/key"
  23	"charm.land/bubbles/v2/spinner"
  24	"charm.land/bubbles/v2/textarea"
  25	tea "charm.land/bubbletea/v2"
  26	"charm.land/catwalk/pkg/catwalk"
  27	"charm.land/lipgloss/v2"
  28	"git.secluded.site/crush/internal/agent/notify"
  29	agenttools "git.secluded.site/crush/internal/agent/tools"
  30	"git.secluded.site/crush/internal/agent/tools/mcp"
  31	"git.secluded.site/crush/internal/app"
  32	"git.secluded.site/crush/internal/commands"
  33	"git.secluded.site/crush/internal/config"
  34	"git.secluded.site/crush/internal/fsext"
  35	"git.secluded.site/crush/internal/history"
  36	"git.secluded.site/crush/internal/home"
  37	"git.secluded.site/crush/internal/message"
  38	"git.secluded.site/crush/internal/permission"
  39	"git.secluded.site/crush/internal/pubsub"
  40	"git.secluded.site/crush/internal/session"
  41	"git.secluded.site/crush/internal/ui/anim"
  42	"git.secluded.site/crush/internal/ui/attachments"
  43	"git.secluded.site/crush/internal/ui/chat"
  44	"git.secluded.site/crush/internal/ui/common"
  45	"git.secluded.site/crush/internal/ui/completions"
  46	"git.secluded.site/crush/internal/ui/dialog"
  47	fimage "git.secluded.site/crush/internal/ui/image"
  48	"git.secluded.site/crush/internal/ui/logo"
  49	"git.secluded.site/crush/internal/ui/notification"
  50	"git.secluded.site/crush/internal/ui/styles"
  51	"git.secluded.site/crush/internal/ui/util"
  52	"git.secluded.site/crush/internal/version"
  53	uv "github.com/charmbracelet/ultraviolet"
  54	"github.com/charmbracelet/ultraviolet/layout"
  55	"github.com/charmbracelet/ultraviolet/screen"
  56	"github.com/charmbracelet/x/editor"
  57)
  58
  59// MouseScrollThreshold defines how many lines to scroll the chat when a mouse
  60// wheel event occurs.
  61const MouseScrollThreshold = 5
  62
  63// Compact mode breakpoints.
  64const (
  65	compactModeWidthBreakpoint  = 120
  66	compactModeHeightBreakpoint = 30
  67)
  68
  69// If pasted text has more than 2 newlines, treat it as a file attachment.
  70const pasteLinesThreshold = 10
  71
  72// Session details panel max height.
  73const sessionDetailsMaxHeight = 20
  74
  75// uiFocusState represents the current focus state of the UI.
  76type uiFocusState uint8
  77
  78// Possible uiFocusState values.
  79const (
  80	uiFocusNone uiFocusState = iota
  81	uiFocusEditor
  82	uiFocusMain
  83)
  84
  85type uiState uint8
  86
  87// Possible uiState values.
  88const (
  89	uiOnboarding uiState = iota
  90	uiInitialize
  91	uiLanding
  92	uiChat
  93)
  94
  95type openEditorMsg struct {
  96	Text string
  97}
  98
  99type (
 100	// cancelTimerExpiredMsg is sent when the cancel timer expires.
 101	cancelTimerExpiredMsg struct{}
 102	// userCommandsLoadedMsg is sent when user commands are loaded.
 103	userCommandsLoadedMsg struct {
 104		Commands []commands.CustomCommand
 105	}
 106	// mcpPromptsLoadedMsg is sent when mcp prompts are loaded.
 107	mcpPromptsLoadedMsg struct {
 108		Prompts []commands.MCPPrompt
 109	}
 110	// mcpStateChangedMsg is sent when there is a change in MCP client states.
 111	mcpStateChangedMsg struct {
 112		states map[string]mcp.ClientInfo
 113	}
 114	// sendMessageMsg is sent to send a message.
 115	// currently only used for mcp prompts.
 116	sendMessageMsg struct {
 117		Content     string
 118		Attachments []message.Attachment
 119	}
 120
 121	// closeDialogMsg is sent to close the current dialog.
 122	closeDialogMsg struct{}
 123
 124	// copyChatHighlightMsg is sent to copy the current chat highlight to clipboard.
 125	copyChatHighlightMsg struct{}
 126
 127	// sessionFilesUpdatesMsg is sent when the files for this session have been updated
 128	sessionFilesUpdatesMsg struct {
 129		sessionFiles []SessionFile
 130	}
 131)
 132
 133// UI represents the main user interface model.
 134type UI struct {
 135	com          *common.Common
 136	session      *session.Session
 137	sessionFiles []SessionFile
 138
 139	// keeps track of read files while we don't have a session id
 140	sessionFileReads []string
 141
 142	lastUserMessageTime int64
 143
 144	// The width and height of the terminal in cells.
 145	width  int
 146	height int
 147	layout uiLayout
 148
 149	isTransparent bool
 150
 151	focus uiFocusState
 152	state uiState
 153
 154	keyMap KeyMap
 155	keyenh tea.KeyboardEnhancementsMsg
 156
 157	dialog *dialog.Overlay
 158	status *Status
 159
 160	// isCanceling tracks whether the user has pressed escape once to cancel.
 161	isCanceling bool
 162
 163	header *header
 164
 165	// sendProgressBar instructs the TUI to send progress bar updates to the
 166	// terminal.
 167	sendProgressBar    bool
 168	progressBarEnabled bool
 169
 170	// caps hold different terminal capabilities that we query for.
 171	caps common.Capabilities
 172
 173	// Editor components
 174	textarea textarea.Model
 175
 176	// Attachment list
 177	attachments *attachments.Attachments
 178
 179	readyPlaceholder   string
 180	workingPlaceholder string
 181
 182	// Completions state
 183	completions              *completions.Completions
 184	completionsOpen          bool
 185	completionsStartIndex    int
 186	completionsQuery         string
 187	completionsPositionStart image.Point // x,y where user typed '@'
 188
 189	// Chat components
 190	chat *Chat
 191
 192	// onboarding state
 193	onboarding struct {
 194		yesInitializeSelected bool
 195	}
 196
 197	// lsp
 198	lspStates map[string]app.LSPClientInfo
 199
 200	// mcp
 201	mcpStates map[string]mcp.ClientInfo
 202
 203	// sidebarLogo keeps a cached version of the sidebar sidebarLogo.
 204	sidebarLogo string
 205
 206	// Notification state
 207	notifyBackend       notification.Backend
 208	notifyWindowFocused bool
 209	// custom commands & mcp commands
 210	customCommands []commands.CustomCommand
 211	mcpPrompts     []commands.MCPPrompt
 212
 213	// forceCompactMode tracks whether compact mode is forced by user toggle
 214	forceCompactMode bool
 215
 216	// isCompact tracks whether we're currently in compact layout mode (either
 217	// by user toggle or auto-switch based on window size)
 218	isCompact bool
 219
 220	// detailsOpen tracks whether the details panel is open (in compact mode)
 221	detailsOpen bool
 222
 223	// pills state
 224	pillsExpanded      bool
 225	focusedPillSection pillSection
 226	promptQueue        int
 227	pillsView          string
 228
 229	// Todo spinner
 230	todoSpinner    spinner.Model
 231	todoIsSpinning bool
 232
 233	// mouse highlighting related state
 234	lastClickTime time.Time
 235
 236	// Prompt history for up/down navigation through previous messages.
 237	promptHistory struct {
 238		messages []string
 239		index    int
 240		draft    string
 241	}
 242}
 243
 244// New creates a new instance of the [UI] model.
 245func New(com *common.Common) *UI {
 246	// Editor components
 247	ta := textarea.New()
 248	ta.SetStyles(com.Styles.TextArea)
 249	ta.ShowLineNumbers = false
 250	ta.CharLimit = -1
 251	ta.SetVirtualCursor(false)
 252	ta.Focus()
 253
 254	ch := NewChat(com)
 255
 256	keyMap := DefaultKeyMap()
 257
 258	// Completions component
 259	comp := completions.New(
 260		com.Styles.Completions.Normal,
 261		com.Styles.Completions.Focused,
 262		com.Styles.Completions.Match,
 263	)
 264
 265	todoSpinner := spinner.New(
 266		spinner.WithSpinner(spinner.MiniDot),
 267		spinner.WithStyle(com.Styles.Pills.TodoSpinner),
 268	)
 269
 270	// Attachments component
 271	attachments := attachments.New(
 272		attachments.NewRenderer(
 273			com.Styles.Attachments.Normal,
 274			com.Styles.Attachments.Deleting,
 275			com.Styles.Attachments.Image,
 276			com.Styles.Attachments.Text,
 277		),
 278		attachments.Keymap{
 279			DeleteMode: keyMap.Editor.AttachmentDeleteMode,
 280			DeleteAll:  keyMap.Editor.DeleteAllAttachments,
 281			Escape:     keyMap.Editor.Escape,
 282		},
 283	)
 284
 285	header := newHeader(com)
 286
 287	ui := &UI{
 288		com:                 com,
 289		dialog:              dialog.NewOverlay(),
 290		keyMap:              keyMap,
 291		textarea:            ta,
 292		chat:                ch,
 293		header:              header,
 294		completions:         comp,
 295		attachments:         attachments,
 296		todoSpinner:         todoSpinner,
 297		lspStates:           make(map[string]app.LSPClientInfo),
 298		mcpStates:           make(map[string]mcp.ClientInfo),
 299		notifyBackend:       notification.NoopBackend{},
 300		notifyWindowFocused: true,
 301	}
 302
 303	status := NewStatus(com, ui)
 304
 305	ui.setEditorPrompt(false)
 306	ui.randomizePlaceholders()
 307	ui.textarea.Placeholder = ui.readyPlaceholder
 308	ui.status = status
 309
 310	// Initialize compact mode from config
 311	ui.forceCompactMode = com.Config().Options.TUI.CompactMode
 312
 313	// set onboarding state defaults
 314	ui.onboarding.yesInitializeSelected = true
 315
 316	desiredState := uiLanding
 317	desiredFocus := uiFocusEditor
 318	if !com.Config().IsConfigured() {
 319		desiredState = uiOnboarding
 320	} else if n, _ := config.ProjectNeedsInitialization(com.Config()); n {
 321		desiredState = uiInitialize
 322	}
 323
 324	// set initial state
 325	ui.setState(desiredState, desiredFocus)
 326
 327	opts := com.Config().Options
 328
 329	// disable indeterminate progress bar
 330	ui.progressBarEnabled = opts.Progress == nil || *opts.Progress
 331	// enable transparent mode
 332	ui.isTransparent = opts.TUI.Transparent != nil && *opts.TUI.Transparent
 333
 334	return ui
 335}
 336
 337// Init initializes the UI model.
 338func (m *UI) Init() tea.Cmd {
 339	var cmds []tea.Cmd
 340	if m.state == uiOnboarding {
 341		if cmd := m.openModelsDialog(); cmd != nil {
 342			cmds = append(cmds, cmd)
 343		}
 344	}
 345	// load the user commands async
 346	cmds = append(cmds, m.loadCustomCommands())
 347	// load prompt history async
 348	cmds = append(cmds, m.loadPromptHistory())
 349	return tea.Batch(cmds...)
 350}
 351
 352// sendNotification returns a command that sends a notification if allowed by policy.
 353func (m *UI) sendNotification(n notification.Notification) tea.Cmd {
 354	if !m.shouldSendNotification() {
 355		return nil
 356	}
 357
 358	backend := m.notifyBackend
 359	return func() tea.Msg {
 360		if err := backend.Send(n); err != nil {
 361			slog.Error("Failed to send notification", "error", err)
 362		}
 363		return nil
 364	}
 365}
 366
 367// shouldSendNotification returns true if notifications should be sent based on
 368// current state. Focus reporting must be supported, window must not focused,
 369// and notifications must not be disabled in config.
 370func (m *UI) shouldSendNotification() bool {
 371	cfg := m.com.Config()
 372	if cfg != nil && cfg.Options != nil && cfg.Options.DisableNotifications {
 373		return false
 374	}
 375	return m.caps.ReportFocusEvents && !m.notifyWindowFocused
 376}
 377
 378// setState changes the UI state and focus.
 379func (m *UI) setState(state uiState, focus uiFocusState) {
 380	if state == uiLanding {
 381		// Always turn off compact mode when going to landing
 382		m.isCompact = false
 383	}
 384	m.state = state
 385	m.focus = focus
 386	// Changing the state may change layout, so update it.
 387	m.updateLayoutAndSize()
 388}
 389
 390// loadCustomCommands loads the custom commands asynchronously.
 391func (m *UI) loadCustomCommands() tea.Cmd {
 392	return func() tea.Msg {
 393		customCommands, err := commands.LoadCustomCommands(m.com.Config())
 394		if err != nil {
 395			slog.Error("Failed to load custom commands", "error", err)
 396		}
 397		return userCommandsLoadedMsg{Commands: customCommands}
 398	}
 399}
 400
 401// loadMCPrompts loads the MCP prompts asynchronously.
 402func (m *UI) loadMCPrompts() tea.Msg {
 403	prompts, err := commands.LoadMCPPrompts()
 404	if err != nil {
 405		slog.Error("Failed to load MCP prompts", "error", err)
 406	}
 407	if prompts == nil {
 408		// flag them as loaded even if there is none or an error
 409		prompts = []commands.MCPPrompt{}
 410	}
 411	return mcpPromptsLoadedMsg{Prompts: prompts}
 412}
 413
 414// Update handles updates to the UI model.
 415func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 416	var cmds []tea.Cmd
 417	if m.hasSession() && m.isAgentBusy() {
 418		queueSize := m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID)
 419		if queueSize != m.promptQueue {
 420			m.promptQueue = queueSize
 421			m.updateLayoutAndSize()
 422		}
 423	}
 424	// Update terminal capabilities
 425	m.caps.Update(msg)
 426	switch msg := msg.(type) {
 427	case tea.EnvMsg:
 428		// Is this Windows Terminal?
 429		if !m.sendProgressBar {
 430			m.sendProgressBar = slices.Contains(msg, "WT_SESSION")
 431		}
 432		cmds = append(cmds, common.QueryCmd(uv.Environ(msg)))
 433	case tea.ModeReportMsg:
 434		if m.caps.ReportFocusEvents {
 435			m.notifyBackend = notification.NewNativeBackend(notification.Icon)
 436		}
 437	case tea.FocusMsg:
 438		m.notifyWindowFocused = true
 439	case tea.BlurMsg:
 440		m.notifyWindowFocused = false
 441	case pubsub.Event[notify.Notification]:
 442		if cmd := m.handleAgentNotification(msg.Payload); cmd != nil {
 443			cmds = append(cmds, cmd)
 444		}
 445	case loadSessionMsg:
 446		if m.forceCompactMode {
 447			m.isCompact = true
 448		}
 449		m.setState(uiChat, m.focus)
 450		m.session = msg.session
 451		m.sessionFiles = msg.files
 452		cmds = append(cmds, m.startLSPs(msg.lspFilePaths()))
 453		msgs, err := m.com.App.Messages.List(context.Background(), m.session.ID)
 454		if err != nil {
 455			cmds = append(cmds, util.ReportError(err))
 456			break
 457		}
 458		if cmd := m.setSessionMessages(msgs); cmd != nil {
 459			cmds = append(cmds, cmd)
 460		}
 461		if hasInProgressTodo(m.session.Todos) {
 462			// only start spinner if there is an in-progress todo
 463			if m.isAgentBusy() {
 464				m.todoIsSpinning = true
 465				cmds = append(cmds, m.todoSpinner.Tick)
 466			}
 467			m.updateLayoutAndSize()
 468		}
 469		// Reload prompt history for the new session.
 470		m.historyReset()
 471		cmds = append(cmds, m.loadPromptHistory())
 472		m.updateLayoutAndSize()
 473
 474	case sessionFilesUpdatesMsg:
 475		m.sessionFiles = msg.sessionFiles
 476		var paths []string
 477		for _, f := range msg.sessionFiles {
 478			paths = append(paths, f.LatestVersion.Path)
 479		}
 480		cmds = append(cmds, m.startLSPs(paths))
 481
 482	case sendMessageMsg:
 483		cmds = append(cmds, m.sendMessage(msg.Content, msg.Attachments...))
 484
 485	case userCommandsLoadedMsg:
 486		m.customCommands = msg.Commands
 487		dia := m.dialog.Dialog(dialog.CommandsID)
 488		if dia == nil {
 489			break
 490		}
 491
 492		commands, ok := dia.(*dialog.Commands)
 493		if ok {
 494			commands.SetCustomCommands(m.customCommands)
 495		}
 496
 497	case mcpStateChangedMsg:
 498		m.mcpStates = msg.states
 499	case mcpPromptsLoadedMsg:
 500		m.mcpPrompts = msg.Prompts
 501		dia := m.dialog.Dialog(dialog.CommandsID)
 502		if dia == nil {
 503			break
 504		}
 505
 506		commands, ok := dia.(*dialog.Commands)
 507		if ok {
 508			commands.SetMCPPrompts(m.mcpPrompts)
 509		}
 510
 511	case promptHistoryLoadedMsg:
 512		m.promptHistory.messages = msg.messages
 513		m.promptHistory.index = -1
 514		m.promptHistory.draft = ""
 515
 516	case closeDialogMsg:
 517		m.dialog.CloseFrontDialog()
 518
 519	case pubsub.Event[session.Session]:
 520		if msg.Type == pubsub.DeletedEvent {
 521			if m.session != nil && m.session.ID == msg.Payload.ID {
 522				if cmd := m.newSession(); cmd != nil {
 523					cmds = append(cmds, cmd)
 524				}
 525			}
 526			break
 527		}
 528		if m.session != nil && msg.Payload.ID == m.session.ID {
 529			prevHasInProgress := hasInProgressTodo(m.session.Todos)
 530			m.session = &msg.Payload
 531			if !prevHasInProgress && hasInProgressTodo(m.session.Todos) {
 532				m.todoIsSpinning = true
 533				cmds = append(cmds, m.todoSpinner.Tick)
 534				m.updateLayoutAndSize()
 535			}
 536		}
 537	case pubsub.Event[message.Message]:
 538		// Check if this is a child session message for an agent tool.
 539		if m.session == nil {
 540			break
 541		}
 542		if msg.Payload.SessionID != m.session.ID {
 543			// This might be a child session message from an agent tool.
 544			if cmd := m.handleChildSessionMessage(msg); cmd != nil {
 545				cmds = append(cmds, cmd)
 546			}
 547			break
 548		}
 549		switch msg.Type {
 550		case pubsub.CreatedEvent:
 551			cmds = append(cmds, m.appendSessionMessage(msg.Payload))
 552		case pubsub.UpdatedEvent:
 553			cmds = append(cmds, m.updateSessionMessage(msg.Payload))
 554		case pubsub.DeletedEvent:
 555			m.chat.RemoveMessage(msg.Payload.ID)
 556		}
 557		// start the spinner if there is a new message
 558		if hasInProgressTodo(m.session.Todos) && m.isAgentBusy() && !m.todoIsSpinning {
 559			m.todoIsSpinning = true
 560			cmds = append(cmds, m.todoSpinner.Tick)
 561		}
 562		// stop the spinner if the agent is not busy anymore
 563		if m.todoIsSpinning && !m.isAgentBusy() {
 564			m.todoIsSpinning = false
 565		}
 566		// there is a number of things that could change the pills here so we want to re-render
 567		m.renderPills()
 568	case pubsub.Event[history.File]:
 569		cmds = append(cmds, m.handleFileEvent(msg.Payload))
 570	case pubsub.Event[app.LSPEvent]:
 571		m.lspStates = app.GetLSPStates()
 572	case pubsub.Event[mcp.Event]:
 573		switch msg.Payload.Type {
 574		case mcp.EventStateChanged:
 575			return m, tea.Batch(
 576				m.handleStateChanged(),
 577				m.loadMCPrompts,
 578			)
 579		case mcp.EventPromptsListChanged:
 580			return m, handleMCPPromptsEvent(msg.Payload.Name)
 581		case mcp.EventToolsListChanged:
 582			return m, handleMCPToolsEvent(m.com.Config(), msg.Payload.Name)
 583		case mcp.EventResourcesListChanged:
 584			return m, handleMCPResourcesEvent(msg.Payload.Name)
 585		}
 586	case pubsub.Event[permission.PermissionRequest]:
 587		if cmd := m.openPermissionsDialog(msg.Payload); cmd != nil {
 588			cmds = append(cmds, cmd)
 589		}
 590		if cmd := m.sendNotification(notification.Notification{
 591			Title:   "Crush is waiting...",
 592			Message: fmt.Sprintf("Permission required to execute \"%s\"", msg.Payload.ToolName),
 593		}); cmd != nil {
 594			cmds = append(cmds, cmd)
 595		}
 596	case pubsub.Event[permission.PermissionNotification]:
 597		m.handlePermissionNotification(msg.Payload)
 598	case cancelTimerExpiredMsg:
 599		m.isCanceling = false
 600	case tea.TerminalVersionMsg:
 601		termVersion := strings.ToLower(msg.Name)
 602		// Only enable progress bar for the following terminals.
 603		if !m.sendProgressBar {
 604			m.sendProgressBar = strings.Contains(termVersion, "ghostty")
 605		}
 606		return m, nil
 607	case tea.WindowSizeMsg:
 608		m.width, m.height = msg.Width, msg.Height
 609		m.updateLayoutAndSize()
 610		if m.state == uiChat && m.chat.Follow() {
 611			if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
 612				cmds = append(cmds, cmd)
 613			}
 614		}
 615	case tea.KeyboardEnhancementsMsg:
 616		m.keyenh = msg
 617		if msg.SupportsKeyDisambiguation() {
 618			m.keyMap.Models.SetHelp("ctrl+m", "models")
 619			m.keyMap.Editor.Newline.SetHelp("shift+enter", "newline")
 620		}
 621	case copyChatHighlightMsg:
 622		cmds = append(cmds, m.copyChatHighlight())
 623	case DelayedClickMsg:
 624		// Handle delayed single-click action (e.g., expansion).
 625		m.chat.HandleDelayedClick(msg)
 626	case tea.MouseClickMsg:
 627		// Pass mouse events to dialogs first if any are open.
 628		if m.dialog.HasDialogs() {
 629			m.dialog.Update(msg)
 630			return m, tea.Batch(cmds...)
 631		}
 632
 633		if cmd := m.handleClickFocus(msg); cmd != nil {
 634			cmds = append(cmds, cmd)
 635		}
 636
 637		switch m.state {
 638		case uiChat:
 639			x, y := msg.X, msg.Y
 640			// Adjust for chat area position
 641			x -= m.layout.main.Min.X
 642			y -= m.layout.main.Min.Y
 643			if !image.Pt(msg.X, msg.Y).In(m.layout.sidebar) {
 644				if handled, cmd := m.chat.HandleMouseDown(x, y); handled {
 645					m.lastClickTime = time.Now()
 646					if cmd != nil {
 647						cmds = append(cmds, cmd)
 648					}
 649				}
 650			}
 651		}
 652
 653	case tea.MouseMotionMsg:
 654		// Pass mouse events to dialogs first if any are open.
 655		if m.dialog.HasDialogs() {
 656			m.dialog.Update(msg)
 657			return m, tea.Batch(cmds...)
 658		}
 659
 660		switch m.state {
 661		case uiChat:
 662			if msg.Y <= 0 {
 663				if cmd := m.chat.ScrollByAndAnimate(-1); cmd != nil {
 664					cmds = append(cmds, cmd)
 665				}
 666				if !m.chat.SelectedItemInView() {
 667					m.chat.SelectPrev()
 668					if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
 669						cmds = append(cmds, cmd)
 670					}
 671				}
 672			} else if msg.Y >= m.chat.Height()-1 {
 673				if cmd := m.chat.ScrollByAndAnimate(1); cmd != nil {
 674					cmds = append(cmds, cmd)
 675				}
 676				if !m.chat.SelectedItemInView() {
 677					m.chat.SelectNext()
 678					if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
 679						cmds = append(cmds, cmd)
 680					}
 681				}
 682			}
 683
 684			x, y := msg.X, msg.Y
 685			// Adjust for chat area position
 686			x -= m.layout.main.Min.X
 687			y -= m.layout.main.Min.Y
 688			m.chat.HandleMouseDrag(x, y)
 689		}
 690
 691	case tea.MouseReleaseMsg:
 692		// Pass mouse events to dialogs first if any are open.
 693		if m.dialog.HasDialogs() {
 694			m.dialog.Update(msg)
 695			return m, tea.Batch(cmds...)
 696		}
 697
 698		switch m.state {
 699		case uiChat:
 700			x, y := msg.X, msg.Y
 701			// Adjust for chat area position
 702			x -= m.layout.main.Min.X
 703			y -= m.layout.main.Min.Y
 704			if m.chat.HandleMouseUp(x, y) && m.chat.HasHighlight() {
 705				cmds = append(cmds, tea.Tick(doubleClickThreshold, func(t time.Time) tea.Msg {
 706					if time.Since(m.lastClickTime) >= doubleClickThreshold {
 707						return copyChatHighlightMsg{}
 708					}
 709					return nil
 710				}))
 711			}
 712		}
 713	case tea.MouseWheelMsg:
 714		// Pass mouse events to dialogs first if any are open.
 715		if m.dialog.HasDialogs() {
 716			m.dialog.Update(msg)
 717			return m, tea.Batch(cmds...)
 718		}
 719
 720		// Otherwise handle mouse wheel for chat.
 721		switch m.state {
 722		case uiChat:
 723			switch msg.Button {
 724			case tea.MouseWheelUp:
 725				if cmd := m.chat.ScrollByAndAnimate(-MouseScrollThreshold); cmd != nil {
 726					cmds = append(cmds, cmd)
 727				}
 728				if !m.chat.SelectedItemInView() {
 729					m.chat.SelectPrev()
 730					if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
 731						cmds = append(cmds, cmd)
 732					}
 733				}
 734			case tea.MouseWheelDown:
 735				if cmd := m.chat.ScrollByAndAnimate(MouseScrollThreshold); cmd != nil {
 736					cmds = append(cmds, cmd)
 737				}
 738				if !m.chat.SelectedItemInView() {
 739					if m.chat.AtBottom() {
 740						m.chat.SelectLast()
 741					} else {
 742						m.chat.SelectNext()
 743					}
 744					if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
 745						cmds = append(cmds, cmd)
 746					}
 747				}
 748			}
 749		}
 750	case anim.StepMsg:
 751		if m.state == uiChat {
 752			if cmd := m.chat.Animate(msg); cmd != nil {
 753				cmds = append(cmds, cmd)
 754			}
 755			if m.chat.Follow() {
 756				if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
 757					cmds = append(cmds, cmd)
 758				}
 759			}
 760		}
 761	case spinner.TickMsg:
 762		if m.dialog.HasDialogs() {
 763			// route to dialog
 764			if cmd := m.handleDialogMsg(msg); cmd != nil {
 765				cmds = append(cmds, cmd)
 766			}
 767		}
 768		if m.state == uiChat && m.hasSession() && hasInProgressTodo(m.session.Todos) && m.todoIsSpinning {
 769			var cmd tea.Cmd
 770			m.todoSpinner, cmd = m.todoSpinner.Update(msg)
 771			if cmd != nil {
 772				m.renderPills()
 773				cmds = append(cmds, cmd)
 774			}
 775		}
 776
 777	case tea.KeyPressMsg:
 778		if cmd := m.handleKeyPressMsg(msg); cmd != nil {
 779			cmds = append(cmds, cmd)
 780		}
 781	case tea.PasteMsg:
 782		if cmd := m.handlePasteMsg(msg); cmd != nil {
 783			cmds = append(cmds, cmd)
 784		}
 785	case openEditorMsg:
 786		var cmd tea.Cmd
 787		m.textarea.SetValue(msg.Text)
 788		m.textarea.MoveToEnd()
 789		m.textarea, cmd = m.textarea.Update(msg)
 790		if cmd != nil {
 791			cmds = append(cmds, cmd)
 792		}
 793	case util.InfoMsg:
 794		m.status.SetInfoMsg(msg)
 795		ttl := msg.TTL
 796		if ttl <= 0 {
 797			ttl = DefaultStatusTTL
 798		}
 799		cmds = append(cmds, clearInfoMsgCmd(ttl))
 800	case util.ClearStatusMsg:
 801		m.status.ClearInfoMsg()
 802	case completions.CompletionItemsLoadedMsg:
 803		if m.completionsOpen {
 804			m.completions.SetItems(msg.Files, msg.Resources)
 805		}
 806	case uv.KittyGraphicsEvent:
 807		if !bytes.HasPrefix(msg.Payload, []byte("OK")) {
 808			slog.Warn("Unexpected Kitty graphics response",
 809				"response", string(msg.Payload),
 810				"options", msg.Options)
 811		}
 812	default:
 813		if m.dialog.HasDialogs() {
 814			if cmd := m.handleDialogMsg(msg); cmd != nil {
 815				cmds = append(cmds, cmd)
 816			}
 817		}
 818	}
 819
 820	// This logic gets triggered on any message type, but should it?
 821	switch m.focus {
 822	case uiFocusMain:
 823	case uiFocusEditor:
 824		// Textarea placeholder logic
 825		if m.isAgentBusy() {
 826			m.textarea.Placeholder = m.workingPlaceholder
 827		} else {
 828			m.textarea.Placeholder = m.readyPlaceholder
 829		}
 830		if m.com.App.Permissions.SkipRequests() {
 831			m.textarea.Placeholder = "Yolo mode!"
 832		}
 833	}
 834
 835	// at this point this can only handle [message.Attachment] message, and we
 836	// should return all cmds anyway.
 837	_ = m.attachments.Update(msg)
 838	return m, tea.Batch(cmds...)
 839}
 840
 841// setSessionMessages sets the messages for the current session in the chat
 842func (m *UI) setSessionMessages(msgs []message.Message) tea.Cmd {
 843	var cmds []tea.Cmd
 844	// Build tool result map to link tool calls with their results
 845	msgPtrs := make([]*message.Message, len(msgs))
 846	for i := range msgs {
 847		msgPtrs[i] = &msgs[i]
 848	}
 849	toolResultMap := chat.BuildToolResultMap(msgPtrs)
 850	if len(msgPtrs) > 0 {
 851		m.lastUserMessageTime = msgPtrs[0].CreatedAt
 852	}
 853
 854	// Add messages to chat with linked tool results
 855	items := make([]chat.MessageItem, 0, len(msgs)*2)
 856	for _, msg := range msgPtrs {
 857		switch msg.Role {
 858		case message.User:
 859			m.lastUserMessageTime = msg.CreatedAt
 860			items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...)
 861		case message.Assistant:
 862			items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...)
 863			if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
 864				infoItem := chat.NewAssistantInfoItem(m.com.Styles, msg, m.com.Config(), time.Unix(m.lastUserMessageTime, 0))
 865				items = append(items, infoItem)
 866			}
 867		default:
 868			items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...)
 869		}
 870	}
 871
 872	// Load nested tool calls for agent/agentic_fetch tools.
 873	m.loadNestedToolCalls(items)
 874
 875	// If the user switches between sessions while the agent is working we want
 876	// to make sure the animations are shown.
 877	for _, item := range items {
 878		if animatable, ok := item.(chat.Animatable); ok {
 879			if cmd := animatable.StartAnimation(); cmd != nil {
 880				cmds = append(cmds, cmd)
 881			}
 882		}
 883	}
 884
 885	m.chat.SetMessages(items...)
 886	if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
 887		cmds = append(cmds, cmd)
 888	}
 889	m.chat.SelectLast()
 890	return tea.Sequence(cmds...)
 891}
 892
 893// loadNestedToolCalls recursively loads nested tool calls for agent/agentic_fetch tools.
 894func (m *UI) loadNestedToolCalls(items []chat.MessageItem) {
 895	for _, item := range items {
 896		nestedContainer, ok := item.(chat.NestedToolContainer)
 897		if !ok {
 898			continue
 899		}
 900		toolItem, ok := item.(chat.ToolMessageItem)
 901		if !ok {
 902			continue
 903		}
 904
 905		tc := toolItem.ToolCall()
 906		messageID := toolItem.MessageID()
 907
 908		// Get the agent tool session ID.
 909		agentSessionID := m.com.App.Sessions.CreateAgentToolSessionID(messageID, tc.ID)
 910
 911		// Fetch nested messages.
 912		nestedMsgs, err := m.com.App.Messages.List(context.Background(), agentSessionID)
 913		if err != nil || len(nestedMsgs) == 0 {
 914			continue
 915		}
 916
 917		// Build tool result map for nested messages.
 918		nestedMsgPtrs := make([]*message.Message, len(nestedMsgs))
 919		for i := range nestedMsgs {
 920			nestedMsgPtrs[i] = &nestedMsgs[i]
 921		}
 922		nestedToolResultMap := chat.BuildToolResultMap(nestedMsgPtrs)
 923
 924		// Extract nested tool items.
 925		var nestedTools []chat.ToolMessageItem
 926		for _, nestedMsg := range nestedMsgPtrs {
 927			nestedItems := chat.ExtractMessageItems(m.com.Styles, nestedMsg, nestedToolResultMap)
 928			for _, nestedItem := range nestedItems {
 929				if nestedToolItem, ok := nestedItem.(chat.ToolMessageItem); ok {
 930					// Mark nested tools as simple (compact) rendering.
 931					if simplifiable, ok := nestedToolItem.(chat.Compactable); ok {
 932						simplifiable.SetCompact(true)
 933					}
 934					nestedTools = append(nestedTools, nestedToolItem)
 935				}
 936			}
 937		}
 938
 939		// Recursively load nested tool calls for any agent tools within.
 940		nestedMessageItems := make([]chat.MessageItem, len(nestedTools))
 941		for i, nt := range nestedTools {
 942			nestedMessageItems[i] = nt
 943		}
 944		m.loadNestedToolCalls(nestedMessageItems)
 945
 946		// Set nested tools on the parent.
 947		nestedContainer.SetNestedTools(nestedTools)
 948	}
 949}
 950
 951// appendSessionMessage appends a new message to the current session in the chat
 952// if the message is a tool result it will update the corresponding tool call message
 953func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
 954	var cmds []tea.Cmd
 955
 956	existing := m.chat.MessageItem(msg.ID)
 957	if existing != nil {
 958		// message already exists, skip
 959		return nil
 960	}
 961
 962	switch msg.Role {
 963	case message.User:
 964		m.lastUserMessageTime = msg.CreatedAt
 965		items := chat.ExtractMessageItems(m.com.Styles, &msg, nil)
 966		for _, item := range items {
 967			if animatable, ok := item.(chat.Animatable); ok {
 968				if cmd := animatable.StartAnimation(); cmd != nil {
 969					cmds = append(cmds, cmd)
 970				}
 971			}
 972		}
 973		m.chat.AppendMessages(items...)
 974		if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
 975			cmds = append(cmds, cmd)
 976		}
 977	case message.Assistant:
 978		items := chat.ExtractMessageItems(m.com.Styles, &msg, nil)
 979		for _, item := range items {
 980			if animatable, ok := item.(chat.Animatable); ok {
 981				if cmd := animatable.StartAnimation(); cmd != nil {
 982					cmds = append(cmds, cmd)
 983				}
 984			}
 985		}
 986		m.chat.AppendMessages(items...)
 987		if m.chat.Follow() {
 988			if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
 989				cmds = append(cmds, cmd)
 990			}
 991		}
 992		if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
 993			infoItem := chat.NewAssistantInfoItem(m.com.Styles, &msg, m.com.Config(), time.Unix(m.lastUserMessageTime, 0))
 994			m.chat.AppendMessages(infoItem)
 995			if m.chat.Follow() {
 996				if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
 997					cmds = append(cmds, cmd)
 998				}
 999			}
1000		}
1001	case message.Tool:
1002		for _, tr := range msg.ToolResults() {
1003			toolItem := m.chat.MessageItem(tr.ToolCallID)
1004			if toolItem == nil {
1005				// we should have an item!
1006				continue
1007			}
1008			if toolMsgItem, ok := toolItem.(chat.ToolMessageItem); ok {
1009				toolMsgItem.SetResult(&tr)
1010				if m.chat.Follow() {
1011					if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
1012						cmds = append(cmds, cmd)
1013					}
1014				}
1015			}
1016		}
1017	}
1018	return tea.Sequence(cmds...)
1019}
1020
1021func (m *UI) handleClickFocus(msg tea.MouseClickMsg) (cmd tea.Cmd) {
1022	switch {
1023	case m.state != uiChat:
1024		return nil
1025	case image.Pt(msg.X, msg.Y).In(m.layout.sidebar):
1026		return nil
1027	case m.focus != uiFocusEditor && image.Pt(msg.X, msg.Y).In(m.layout.editor):
1028		m.focus = uiFocusEditor
1029		cmd = m.textarea.Focus()
1030		m.chat.Blur()
1031	case m.focus != uiFocusMain && image.Pt(msg.X, msg.Y).In(m.layout.main):
1032		m.focus = uiFocusMain
1033		m.textarea.Blur()
1034		m.chat.Focus()
1035	}
1036	return cmd
1037}
1038
1039// updateSessionMessage updates an existing message in the current session in the chat
1040// when an assistant message is updated it may include updated tool calls as well
1041// that is why we need to handle creating/updating each tool call message too
1042func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd {
1043	var cmds []tea.Cmd
1044	existingItem := m.chat.MessageItem(msg.ID)
1045
1046	if existingItem != nil {
1047		if assistantItem, ok := existingItem.(*chat.AssistantMessageItem); ok {
1048			assistantItem.SetMessage(&msg)
1049		}
1050	}
1051
1052	shouldRenderAssistant := chat.ShouldRenderAssistantMessage(&msg)
1053	// if the message of the assistant does not have any  response just tool calls we need to remove it
1054	if !shouldRenderAssistant && len(msg.ToolCalls()) > 0 && existingItem != nil {
1055		m.chat.RemoveMessage(msg.ID)
1056		if infoItem := m.chat.MessageItem(chat.AssistantInfoID(msg.ID)); infoItem != nil {
1057			m.chat.RemoveMessage(chat.AssistantInfoID(msg.ID))
1058		}
1059	}
1060
1061	if shouldRenderAssistant && msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
1062		if infoItem := m.chat.MessageItem(chat.AssistantInfoID(msg.ID)); infoItem == nil {
1063			newInfoItem := chat.NewAssistantInfoItem(m.com.Styles, &msg, m.com.Config(), time.Unix(m.lastUserMessageTime, 0))
1064			m.chat.AppendMessages(newInfoItem)
1065		}
1066	}
1067
1068	var items []chat.MessageItem
1069	for _, tc := range msg.ToolCalls() {
1070		existingToolItem := m.chat.MessageItem(tc.ID)
1071		if toolItem, ok := existingToolItem.(chat.ToolMessageItem); ok {
1072			existingToolCall := toolItem.ToolCall()
1073			// only update if finished state changed or input changed
1074			// to avoid clearing the cache
1075			if (tc.Finished && !existingToolCall.Finished) || tc.Input != existingToolCall.Input {
1076				toolItem.SetToolCall(tc)
1077			}
1078		}
1079		if existingToolItem == nil {
1080			items = append(items, chat.NewToolMessageItem(m.com.Styles, msg.ID, tc, nil, false))
1081		}
1082	}
1083
1084	for _, item := range items {
1085		if animatable, ok := item.(chat.Animatable); ok {
1086			if cmd := animatable.StartAnimation(); cmd != nil {
1087				cmds = append(cmds, cmd)
1088			}
1089		}
1090	}
1091
1092	m.chat.AppendMessages(items...)
1093	if m.chat.Follow() {
1094		if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
1095			cmds = append(cmds, cmd)
1096		}
1097		m.chat.SelectLast()
1098	}
1099
1100	return tea.Sequence(cmds...)
1101}
1102
1103// handleChildSessionMessage handles messages from child sessions (agent tools).
1104func (m *UI) handleChildSessionMessage(event pubsub.Event[message.Message]) tea.Cmd {
1105	var cmds []tea.Cmd
1106
1107	// Only process messages with tool calls or results.
1108	if len(event.Payload.ToolCalls()) == 0 && len(event.Payload.ToolResults()) == 0 {
1109		return nil
1110	}
1111
1112	// Check if this is an agent tool session and parse it.
1113	childSessionID := event.Payload.SessionID
1114	_, toolCallID, ok := m.com.App.Sessions.ParseAgentToolSessionID(childSessionID)
1115	if !ok {
1116		return nil
1117	}
1118
1119	// Find the parent agent tool item.
1120	var agentItem chat.NestedToolContainer
1121	for i := 0; i < m.chat.Len(); i++ {
1122		item := m.chat.MessageItem(toolCallID)
1123		if item == nil {
1124			continue
1125		}
1126		if agent, ok := item.(chat.NestedToolContainer); ok {
1127			if toolMessageItem, ok := item.(chat.ToolMessageItem); ok {
1128				if toolMessageItem.ToolCall().ID == toolCallID {
1129					// Verify this agent belongs to the correct parent message.
1130					// We can't directly check parentMessageID on the item, so we trust the session parsing.
1131					agentItem = agent
1132					break
1133				}
1134			}
1135		}
1136	}
1137
1138	if agentItem == nil {
1139		return nil
1140	}
1141
1142	// Get existing nested tools.
1143	nestedTools := agentItem.NestedTools()
1144
1145	// Update or create nested tool calls.
1146	for _, tc := range event.Payload.ToolCalls() {
1147		found := false
1148		for _, existingTool := range nestedTools {
1149			if existingTool.ToolCall().ID == tc.ID {
1150				existingTool.SetToolCall(tc)
1151				found = true
1152				break
1153			}
1154		}
1155		if !found {
1156			// Create a new nested tool item.
1157			nestedItem := chat.NewToolMessageItem(m.com.Styles, event.Payload.ID, tc, nil, false)
1158			if simplifiable, ok := nestedItem.(chat.Compactable); ok {
1159				simplifiable.SetCompact(true)
1160			}
1161			if animatable, ok := nestedItem.(chat.Animatable); ok {
1162				if cmd := animatable.StartAnimation(); cmd != nil {
1163					cmds = append(cmds, cmd)
1164				}
1165			}
1166			nestedTools = append(nestedTools, nestedItem)
1167		}
1168	}
1169
1170	// Update nested tool results.
1171	for _, tr := range event.Payload.ToolResults() {
1172		for _, nestedTool := range nestedTools {
1173			if nestedTool.ToolCall().ID == tr.ToolCallID {
1174				nestedTool.SetResult(&tr)
1175				break
1176			}
1177		}
1178	}
1179
1180	// Update the agent item with the new nested tools.
1181	agentItem.SetNestedTools(nestedTools)
1182
1183	// Update the chat so it updates the index map for animations to work as expected
1184	m.chat.UpdateNestedToolIDs(toolCallID)
1185
1186	if m.chat.Follow() {
1187		if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
1188			cmds = append(cmds, cmd)
1189		}
1190		m.chat.SelectLast()
1191	}
1192
1193	return tea.Sequence(cmds...)
1194}
1195
1196func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
1197	var cmds []tea.Cmd
1198	action := m.dialog.Update(msg)
1199	if action == nil {
1200		return tea.Batch(cmds...)
1201	}
1202
1203	isOnboarding := m.state == uiOnboarding
1204
1205	switch msg := action.(type) {
1206	// Generic dialog messages
1207	case dialog.ActionClose:
1208		if isOnboarding && m.dialog.ContainsDialog(dialog.ModelsID) {
1209			break
1210		}
1211
1212		if m.dialog.ContainsDialog(dialog.FilePickerID) {
1213			defer fimage.ResetCache()
1214		}
1215
1216		m.dialog.CloseFrontDialog()
1217
1218		if isOnboarding {
1219			if cmd := m.openModelsDialog(); cmd != nil {
1220				cmds = append(cmds, cmd)
1221			}
1222		}
1223
1224		if m.focus == uiFocusEditor {
1225			cmds = append(cmds, m.textarea.Focus())
1226		}
1227	case dialog.ActionCmd:
1228		if msg.Cmd != nil {
1229			cmds = append(cmds, msg.Cmd)
1230		}
1231
1232	// Session dialog messages
1233	case dialog.ActionSelectSession:
1234		m.dialog.CloseDialog(dialog.SessionsID)
1235		cmds = append(cmds, m.loadSession(msg.Session.ID))
1236
1237	// Open dialog message
1238	case dialog.ActionOpenDialog:
1239		m.dialog.CloseDialog(dialog.CommandsID)
1240		if cmd := m.openDialog(msg.DialogID); cmd != nil {
1241			cmds = append(cmds, cmd)
1242		}
1243
1244	// Command dialog messages
1245	case dialog.ActionToggleYoloMode:
1246		yolo := !m.com.App.Permissions.SkipRequests()
1247		m.com.App.Permissions.SetSkipRequests(yolo)
1248		m.setEditorPrompt(yolo)
1249		m.dialog.CloseDialog(dialog.CommandsID)
1250	case dialog.ActionNewSession:
1251		if m.isAgentBusy() {
1252			cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before starting a new session..."))
1253			break
1254		}
1255		if cmd := m.newSession(); cmd != nil {
1256			cmds = append(cmds, cmd)
1257		}
1258		m.dialog.CloseDialog(dialog.CommandsID)
1259	case dialog.ActionSummarize:
1260		if m.isAgentBusy() {
1261			cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before summarizing session..."))
1262			break
1263		}
1264		cmds = append(cmds, func() tea.Msg {
1265			err := m.com.App.AgentCoordinator.Summarize(context.Background(), msg.SessionID)
1266			if err != nil {
1267				return util.ReportError(err)()
1268			}
1269			return nil
1270		})
1271		m.dialog.CloseDialog(dialog.CommandsID)
1272	case dialog.ActionToggleHelp:
1273		m.status.ToggleHelp()
1274		m.dialog.CloseDialog(dialog.CommandsID)
1275	case dialog.ActionExternalEditor:
1276		if m.isAgentBusy() {
1277			cmds = append(cmds, util.ReportWarn("Agent is working, please wait..."))
1278			break
1279		}
1280		cmds = append(cmds, m.openEditor(m.textarea.Value()))
1281		m.dialog.CloseDialog(dialog.CommandsID)
1282	case dialog.ActionToggleCompactMode:
1283		cmds = append(cmds, m.toggleCompactMode())
1284		m.dialog.CloseDialog(dialog.CommandsID)
1285	case dialog.ActionTogglePills:
1286		if cmd := m.togglePillsExpanded(); cmd != nil {
1287			cmds = append(cmds, cmd)
1288		}
1289		m.dialog.CloseDialog(dialog.CommandsID)
1290	case dialog.ActionToggleThinking:
1291		cmds = append(cmds, func() tea.Msg {
1292			cfg := m.com.Config()
1293			if cfg == nil {
1294				return util.ReportError(errors.New("configuration not found"))()
1295			}
1296
1297			agentCfg, ok := cfg.Agents[config.AgentCoder]
1298			if !ok {
1299				return util.ReportError(errors.New("agent configuration not found"))()
1300			}
1301
1302			currentModel := cfg.Models[agentCfg.Model]
1303			currentModel.Think = !currentModel.Think
1304			if err := cfg.UpdatePreferredModel(agentCfg.Model, currentModel); err != nil {
1305				return util.ReportError(err)()
1306			}
1307			m.com.App.UpdateAgentModel(context.TODO())
1308			status := "disabled"
1309			if currentModel.Think {
1310				status = "enabled"
1311			}
1312			return util.NewInfoMsg("Thinking mode " + status)
1313		})
1314		m.dialog.CloseDialog(dialog.CommandsID)
1315	case dialog.ActionQuit:
1316		cmds = append(cmds, tea.Quit)
1317	case dialog.ActionInitializeProject:
1318		if m.isAgentBusy() {
1319			cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before summarizing session..."))
1320			break
1321		}
1322		cmds = append(cmds, m.initializeProject())
1323		m.dialog.CloseDialog(dialog.CommandsID)
1324
1325	case dialog.ActionSelectModel:
1326		if m.isAgentBusy() {
1327			cmds = append(cmds, util.ReportWarn("Agent is busy, please wait..."))
1328			break
1329		}
1330
1331		cfg := m.com.Config()
1332		if cfg == nil {
1333			cmds = append(cmds, util.ReportError(errors.New("configuration not found")))
1334			break
1335		}
1336
1337		var (
1338			providerID   = msg.Model.Provider
1339			isCopilot    = providerID == string(catwalk.InferenceProviderCopilot)
1340			isConfigured = func() bool { _, ok := cfg.Providers.Get(providerID); return ok }
1341		)
1342
1343		// Attempt to import GitHub Copilot tokens from VSCode if available.
1344		if isCopilot && !isConfigured() && !msg.ReAuthenticate {
1345			m.com.Config().ImportCopilot()
1346		}
1347
1348		if !isConfigured() || msg.ReAuthenticate {
1349			m.dialog.CloseDialog(dialog.ModelsID)
1350			if cmd := m.openAuthenticationDialog(msg.Provider, msg.Model, msg.ModelType); cmd != nil {
1351				cmds = append(cmds, cmd)
1352			}
1353			break
1354		}
1355
1356		if err := cfg.UpdatePreferredModel(msg.ModelType, msg.Model); err != nil {
1357			cmds = append(cmds, util.ReportError(err))
1358		} else if _, ok := cfg.Models[config.SelectedModelTypeSmall]; !ok {
1359			// Ensure small model is set is unset.
1360			smallModel := m.com.App.GetDefaultSmallModel(providerID)
1361			if err := cfg.UpdatePreferredModel(config.SelectedModelTypeSmall, smallModel); err != nil {
1362				cmds = append(cmds, util.ReportError(err))
1363			}
1364		}
1365
1366		cmds = append(cmds, func() tea.Msg {
1367			if err := m.com.App.UpdateAgentModel(context.TODO()); err != nil {
1368				return util.ReportError(err)
1369			}
1370
1371			modelMsg := fmt.Sprintf("%s model changed to %s", msg.ModelType, msg.Model.Model)
1372
1373			return util.NewInfoMsg(modelMsg)
1374		})
1375
1376		m.dialog.CloseDialog(dialog.APIKeyInputID)
1377		m.dialog.CloseDialog(dialog.OAuthID)
1378		m.dialog.CloseDialog(dialog.ModelsID)
1379
1380		if isOnboarding {
1381			m.setState(uiLanding, uiFocusEditor)
1382			m.com.Config().SetupAgents()
1383			if err := m.com.App.InitCoderAgent(context.TODO()); err != nil {
1384				cmds = append(cmds, util.ReportError(err))
1385			}
1386		}
1387	case dialog.ActionSelectReasoningEffort:
1388		if m.isAgentBusy() {
1389			cmds = append(cmds, util.ReportWarn("Agent is busy, please wait..."))
1390			break
1391		}
1392
1393		cfg := m.com.Config()
1394		if cfg == nil {
1395			cmds = append(cmds, util.ReportError(errors.New("configuration not found")))
1396			break
1397		}
1398
1399		agentCfg, ok := cfg.Agents[config.AgentCoder]
1400		if !ok {
1401			cmds = append(cmds, util.ReportError(errors.New("agent configuration not found")))
1402			break
1403		}
1404
1405		currentModel := cfg.Models[agentCfg.Model]
1406		currentModel.ReasoningEffort = msg.Effort
1407		if err := cfg.UpdatePreferredModel(agentCfg.Model, currentModel); err != nil {
1408			cmds = append(cmds, util.ReportError(err))
1409			break
1410		}
1411
1412		cmds = append(cmds, func() tea.Msg {
1413			m.com.App.UpdateAgentModel(context.TODO())
1414			return util.NewInfoMsg("Reasoning effort set to " + msg.Effort)
1415		})
1416		m.dialog.CloseDialog(dialog.ReasoningID)
1417	case dialog.ActionPermissionResponse:
1418		m.dialog.CloseDialog(dialog.PermissionsID)
1419		switch msg.Action {
1420		case dialog.PermissionAllow:
1421			m.com.App.Permissions.Grant(msg.Permission)
1422		case dialog.PermissionAllowForSession:
1423			m.com.App.Permissions.GrantPersistent(msg.Permission)
1424		case dialog.PermissionDeny:
1425			m.com.App.Permissions.Deny(msg.Permission)
1426		}
1427
1428	case dialog.ActionFilePickerSelected:
1429		cmds = append(cmds, tea.Sequence(
1430			msg.Cmd(),
1431			func() tea.Msg {
1432				m.dialog.CloseDialog(dialog.FilePickerID)
1433				return nil
1434			},
1435			func() tea.Msg {
1436				fimage.ResetCache()
1437				return nil
1438			},
1439		))
1440
1441	case dialog.ActionRunCustomCommand:
1442		if len(msg.Arguments) > 0 && msg.Args == nil {
1443			m.dialog.CloseFrontDialog()
1444			argsDialog := dialog.NewArguments(
1445				m.com,
1446				"Custom Command Arguments",
1447				"",
1448				msg.Arguments,
1449				msg, // Pass the action as the result
1450			)
1451			m.dialog.OpenDialog(argsDialog)
1452			break
1453		}
1454		content := msg.Content
1455		if msg.Args != nil {
1456			content = substituteArgs(content, msg.Args)
1457		}
1458		cmds = append(cmds, m.sendMessage(content))
1459		m.dialog.CloseFrontDialog()
1460	case dialog.ActionRunMCPPrompt:
1461		if len(msg.Arguments) > 0 && msg.Args == nil {
1462			m.dialog.CloseFrontDialog()
1463			title := cmp.Or(msg.Title, "MCP Prompt Arguments")
1464			argsDialog := dialog.NewArguments(
1465				m.com,
1466				title,
1467				msg.Description,
1468				msg.Arguments,
1469				msg, // Pass the action as the result
1470			)
1471			m.dialog.OpenDialog(argsDialog)
1472			break
1473		}
1474		cmds = append(cmds, m.runMCPPrompt(msg.ClientID, msg.PromptID, msg.Args))
1475	default:
1476		cmds = append(cmds, util.CmdHandler(msg))
1477	}
1478
1479	return tea.Batch(cmds...)
1480}
1481
1482// substituteArgs replaces $ARG_NAME placeholders in content with actual values.
1483func substituteArgs(content string, args map[string]string) string {
1484	for name, value := range args {
1485		placeholder := "$" + name
1486		content = strings.ReplaceAll(content, placeholder, value)
1487	}
1488	return content
1489}
1490
1491func (m *UI) openAuthenticationDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd {
1492	var (
1493		dlg dialog.Dialog
1494		cmd tea.Cmd
1495
1496		isOnboarding = m.state == uiOnboarding
1497	)
1498
1499	switch provider.ID {
1500	case "hyper":
1501		dlg, cmd = dialog.NewOAuthHyper(m.com, isOnboarding, provider, model, modelType)
1502	case catwalk.InferenceProviderCopilot:
1503		dlg, cmd = dialog.NewOAuthCopilot(m.com, isOnboarding, provider, model, modelType)
1504	default:
1505		dlg, cmd = dialog.NewAPIKeyInput(m.com, isOnboarding, provider, model, modelType)
1506	}
1507
1508	if m.dialog.ContainsDialog(dlg.ID()) {
1509		m.dialog.BringToFront(dlg.ID())
1510		return nil
1511	}
1512
1513	m.dialog.OpenDialog(dlg)
1514	return cmd
1515}
1516
1517func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
1518	var cmds []tea.Cmd
1519
1520	handleGlobalKeys := func(msg tea.KeyPressMsg) bool {
1521		switch {
1522		case key.Matches(msg, m.keyMap.Help):
1523			m.status.ToggleHelp()
1524			m.updateLayoutAndSize()
1525			return true
1526		case key.Matches(msg, m.keyMap.Commands):
1527			if cmd := m.openCommandsDialog(); cmd != nil {
1528				cmds = append(cmds, cmd)
1529			}
1530			return true
1531		case key.Matches(msg, m.keyMap.Models):
1532			if cmd := m.openModelsDialog(); cmd != nil {
1533				cmds = append(cmds, cmd)
1534			}
1535			return true
1536		case key.Matches(msg, m.keyMap.Sessions):
1537			if cmd := m.openSessionsDialog(); cmd != nil {
1538				cmds = append(cmds, cmd)
1539			}
1540			return true
1541		case key.Matches(msg, m.keyMap.Chat.Details) && m.isCompact:
1542			m.detailsOpen = !m.detailsOpen
1543			m.updateLayoutAndSize()
1544			return true
1545		case key.Matches(msg, m.keyMap.Chat.TogglePills):
1546			if m.state == uiChat && m.hasSession() {
1547				if cmd := m.togglePillsExpanded(); cmd != nil {
1548					cmds = append(cmds, cmd)
1549				}
1550				return true
1551			}
1552		case key.Matches(msg, m.keyMap.Chat.PillLeft):
1553			if m.state == uiChat && m.hasSession() && m.pillsExpanded && m.focus != uiFocusEditor {
1554				if cmd := m.switchPillSection(-1); cmd != nil {
1555					cmds = append(cmds, cmd)
1556				}
1557				return true
1558			}
1559		case key.Matches(msg, m.keyMap.Chat.PillRight):
1560			if m.state == uiChat && m.hasSession() && m.pillsExpanded && m.focus != uiFocusEditor {
1561				if cmd := m.switchPillSection(1); cmd != nil {
1562					cmds = append(cmds, cmd)
1563				}
1564				return true
1565			}
1566		case key.Matches(msg, m.keyMap.Suspend):
1567			if m.isAgentBusy() {
1568				cmds = append(cmds, util.ReportWarn("Agent is busy, please wait..."))
1569				return true
1570			}
1571			cmds = append(cmds, tea.Suspend)
1572			return true
1573		}
1574		return false
1575	}
1576
1577	if key.Matches(msg, m.keyMap.Quit) && !m.dialog.ContainsDialog(dialog.QuitID) {
1578		// Always handle quit keys first
1579		if cmd := m.openQuitDialog(); cmd != nil {
1580			cmds = append(cmds, cmd)
1581		}
1582
1583		return tea.Batch(cmds...)
1584	}
1585
1586	// Route all messages to dialog if one is open.
1587	if m.dialog.HasDialogs() {
1588		return m.handleDialogMsg(msg)
1589	}
1590
1591	// Handle cancel key when agent is busy.
1592	if key.Matches(msg, m.keyMap.Chat.Cancel) {
1593		if m.isAgentBusy() {
1594			if cmd := m.cancelAgent(); cmd != nil {
1595				cmds = append(cmds, cmd)
1596			}
1597			return tea.Batch(cmds...)
1598		}
1599	}
1600
1601	switch m.state {
1602	case uiOnboarding:
1603		return tea.Batch(cmds...)
1604	case uiInitialize:
1605		cmds = append(cmds, m.updateInitializeView(msg)...)
1606		return tea.Batch(cmds...)
1607	case uiChat, uiLanding:
1608		switch m.focus {
1609		case uiFocusEditor:
1610			// Handle completions if open.
1611			if m.completionsOpen {
1612				if msg, ok := m.completions.Update(msg); ok {
1613					switch msg := msg.(type) {
1614					case completions.SelectionMsg[completions.FileCompletionValue]:
1615						cmds = append(cmds, m.insertFileCompletion(msg.Value.Path))
1616						if !msg.KeepOpen {
1617							m.closeCompletions()
1618						}
1619					case completions.SelectionMsg[completions.ResourceCompletionValue]:
1620						cmds = append(cmds, m.insertMCPResourceCompletion(msg.Value))
1621						if !msg.KeepOpen {
1622							m.closeCompletions()
1623						}
1624					case completions.ClosedMsg:
1625						m.completionsOpen = false
1626					}
1627					return tea.Batch(cmds...)
1628				}
1629			}
1630
1631			if ok := m.attachments.Update(msg); ok {
1632				return tea.Batch(cmds...)
1633			}
1634
1635			switch {
1636			case key.Matches(msg, m.keyMap.Editor.AddImage):
1637				if cmd := m.openFilesDialog(); cmd != nil {
1638					cmds = append(cmds, cmd)
1639				}
1640
1641			case key.Matches(msg, m.keyMap.Editor.PasteImage):
1642				cmds = append(cmds, m.pasteImageFromClipboard)
1643
1644			case key.Matches(msg, m.keyMap.Editor.SendMessage):
1645				value := m.textarea.Value()
1646				if before, ok := strings.CutSuffix(value, "\\"); ok {
1647					// If the last character is a backslash, remove it and add a newline.
1648					m.textarea.SetValue(before)
1649					break
1650				}
1651
1652				// Otherwise, send the message
1653				m.textarea.Reset()
1654
1655				value = strings.TrimSpace(value)
1656				if value == "exit" || value == "quit" {
1657					return m.openQuitDialog()
1658				}
1659
1660				attachments := m.attachments.List()
1661				m.attachments.Reset()
1662				if len(value) == 0 && !message.ContainsTextAttachment(attachments) {
1663					return nil
1664				}
1665
1666				m.randomizePlaceholders()
1667				m.historyReset()
1668
1669				return tea.Batch(m.sendMessage(value, attachments...), m.loadPromptHistory())
1670			case key.Matches(msg, m.keyMap.Chat.NewSession):
1671				if !m.hasSession() {
1672					break
1673				}
1674				if m.isAgentBusy() {
1675					cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before starting a new session..."))
1676					break
1677				}
1678				if cmd := m.newSession(); cmd != nil {
1679					cmds = append(cmds, cmd)
1680				}
1681			case key.Matches(msg, m.keyMap.Tab):
1682				if m.state != uiLanding {
1683					m.setState(m.state, uiFocusMain)
1684					m.textarea.Blur()
1685					m.chat.Focus()
1686					m.chat.SetSelected(m.chat.Len() - 1)
1687				}
1688			case key.Matches(msg, m.keyMap.Editor.OpenEditor):
1689				if m.isAgentBusy() {
1690					cmds = append(cmds, util.ReportWarn("Agent is working, please wait..."))
1691					break
1692				}
1693				cmds = append(cmds, m.openEditor(m.textarea.Value()))
1694			case key.Matches(msg, m.keyMap.Editor.Newline):
1695				m.textarea.InsertRune('\n')
1696				m.closeCompletions()
1697				ta, cmd := m.textarea.Update(msg)
1698				m.textarea = ta
1699				cmds = append(cmds, cmd)
1700			case key.Matches(msg, m.keyMap.Editor.HistoryPrev):
1701				cmd := m.handleHistoryUp(msg)
1702				if cmd != nil {
1703					cmds = append(cmds, cmd)
1704				}
1705			case key.Matches(msg, m.keyMap.Editor.HistoryNext):
1706				cmd := m.handleHistoryDown(msg)
1707				if cmd != nil {
1708					cmds = append(cmds, cmd)
1709				}
1710			case key.Matches(msg, m.keyMap.Editor.Escape):
1711				cmd := m.handleHistoryEscape(msg)
1712				if cmd != nil {
1713					cmds = append(cmds, cmd)
1714				}
1715			case key.Matches(msg, m.keyMap.Editor.Commands) && m.textarea.Value() == "":
1716				if cmd := m.openCommandsDialog(); cmd != nil {
1717					cmds = append(cmds, cmd)
1718				}
1719			default:
1720				if handleGlobalKeys(msg) {
1721					// Handle global keys first before passing to textarea.
1722					break
1723				}
1724
1725				// Check for @ trigger before passing to textarea.
1726				curValue := m.textarea.Value()
1727				curIdx := len(curValue)
1728
1729				// Trigger completions on @.
1730				if msg.String() == "@" && !m.completionsOpen {
1731					// Only show if beginning of prompt or after whitespace.
1732					if curIdx == 0 || (curIdx > 0 && isWhitespace(curValue[curIdx-1])) {
1733						m.completionsOpen = true
1734						m.completionsQuery = ""
1735						m.completionsStartIndex = curIdx
1736						m.completionsPositionStart = m.completionsPosition()
1737						depth, limit := m.com.Config().Options.TUI.Completions.Limits()
1738						cmds = append(cmds, m.completions.Open(depth, limit))
1739					}
1740				}
1741
1742				// remove the details if they are open when user starts typing
1743				if m.detailsOpen {
1744					m.detailsOpen = false
1745					m.updateLayoutAndSize()
1746				}
1747
1748				ta, cmd := m.textarea.Update(msg)
1749				m.textarea = ta
1750				cmds = append(cmds, cmd)
1751
1752				// Any text modification becomes the current draft.
1753				m.updateHistoryDraft(curValue)
1754
1755				// After updating textarea, check if we need to filter completions.
1756				// Skip filtering on the initial @ keystroke since items are loading async.
1757				if m.completionsOpen && msg.String() != "@" {
1758					newValue := m.textarea.Value()
1759					newIdx := len(newValue)
1760
1761					// Close completions if cursor moved before start.
1762					if newIdx <= m.completionsStartIndex {
1763						m.closeCompletions()
1764					} else if msg.String() == "space" {
1765						// Close on space.
1766						m.closeCompletions()
1767					} else {
1768						// Extract current word and filter.
1769						word := m.textareaWord()
1770						if strings.HasPrefix(word, "@") {
1771							m.completionsQuery = word[1:]
1772							m.completions.Filter(m.completionsQuery)
1773						} else if m.completionsOpen {
1774							m.closeCompletions()
1775						}
1776					}
1777				}
1778			}
1779		case uiFocusMain:
1780			switch {
1781			case key.Matches(msg, m.keyMap.Tab):
1782				m.focus = uiFocusEditor
1783				cmds = append(cmds, m.textarea.Focus())
1784				m.chat.Blur()
1785			case key.Matches(msg, m.keyMap.Chat.NewSession):
1786				if !m.hasSession() {
1787					break
1788				}
1789				if m.isAgentBusy() {
1790					cmds = append(cmds, util.ReportWarn("Agent is busy, please wait before starting a new session..."))
1791					break
1792				}
1793				m.focus = uiFocusEditor
1794				if cmd := m.newSession(); cmd != nil {
1795					cmds = append(cmds, cmd)
1796				}
1797			case key.Matches(msg, m.keyMap.Chat.Expand):
1798				m.chat.ToggleExpandedSelectedItem()
1799			case key.Matches(msg, m.keyMap.Chat.Up):
1800				if cmd := m.chat.ScrollByAndAnimate(-1); cmd != nil {
1801					cmds = append(cmds, cmd)
1802				}
1803				if !m.chat.SelectedItemInView() {
1804					m.chat.SelectPrev()
1805					if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1806						cmds = append(cmds, cmd)
1807					}
1808				}
1809			case key.Matches(msg, m.keyMap.Chat.Down):
1810				if cmd := m.chat.ScrollByAndAnimate(1); cmd != nil {
1811					cmds = append(cmds, cmd)
1812				}
1813				if !m.chat.SelectedItemInView() {
1814					m.chat.SelectNext()
1815					if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1816						cmds = append(cmds, cmd)
1817					}
1818				}
1819			case key.Matches(msg, m.keyMap.Chat.UpOneItem):
1820				m.chat.SelectPrev()
1821				if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1822					cmds = append(cmds, cmd)
1823				}
1824			case key.Matches(msg, m.keyMap.Chat.DownOneItem):
1825				m.chat.SelectNext()
1826				if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
1827					cmds = append(cmds, cmd)
1828				}
1829			case key.Matches(msg, m.keyMap.Chat.HalfPageUp):
1830				if cmd := m.chat.ScrollByAndAnimate(-m.chat.Height() / 2); cmd != nil {
1831					cmds = append(cmds, cmd)
1832				}
1833				m.chat.SelectFirstInView()
1834			case key.Matches(msg, m.keyMap.Chat.HalfPageDown):
1835				if cmd := m.chat.ScrollByAndAnimate(m.chat.Height() / 2); cmd != nil {
1836					cmds = append(cmds, cmd)
1837				}
1838				m.chat.SelectLastInView()
1839			case key.Matches(msg, m.keyMap.Chat.PageUp):
1840				if cmd := m.chat.ScrollByAndAnimate(-m.chat.Height()); cmd != nil {
1841					cmds = append(cmds, cmd)
1842				}
1843				m.chat.SelectFirstInView()
1844			case key.Matches(msg, m.keyMap.Chat.PageDown):
1845				if cmd := m.chat.ScrollByAndAnimate(m.chat.Height()); cmd != nil {
1846					cmds = append(cmds, cmd)
1847				}
1848				m.chat.SelectLastInView()
1849			case key.Matches(msg, m.keyMap.Chat.Home):
1850				if cmd := m.chat.ScrollToTopAndAnimate(); cmd != nil {
1851					cmds = append(cmds, cmd)
1852				}
1853				m.chat.SelectFirst()
1854			case key.Matches(msg, m.keyMap.Chat.End):
1855				if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
1856					cmds = append(cmds, cmd)
1857				}
1858				m.chat.SelectLast()
1859			default:
1860				if ok, cmd := m.chat.HandleKeyMsg(msg); ok {
1861					cmds = append(cmds, cmd)
1862				} else {
1863					handleGlobalKeys(msg)
1864				}
1865			}
1866		default:
1867			handleGlobalKeys(msg)
1868		}
1869	default:
1870		handleGlobalKeys(msg)
1871	}
1872
1873	return tea.Sequence(cmds...)
1874}
1875
1876// drawHeader draws the header section of the UI.
1877func (m *UI) drawHeader(scr uv.Screen, area uv.Rectangle) {
1878	m.header.drawHeader(
1879		scr,
1880		area,
1881		m.session,
1882		m.isCompact,
1883		m.detailsOpen,
1884		area.Dx(),
1885	)
1886}
1887
1888// Draw implements [uv.Drawable] and draws the UI model.
1889func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
1890	layout := m.generateLayout(area.Dx(), area.Dy())
1891
1892	if m.layout != layout {
1893		m.layout = layout
1894		m.updateSize()
1895	}
1896
1897	// Clear the screen first
1898	screen.Clear(scr)
1899
1900	switch m.state {
1901	case uiOnboarding:
1902		m.drawHeader(scr, layout.header)
1903
1904		// NOTE: Onboarding flow will be rendered as dialogs below, but
1905		// positioned at the bottom left of the screen.
1906
1907	case uiInitialize:
1908		m.drawHeader(scr, layout.header)
1909
1910		main := uv.NewStyledString(m.initializeView())
1911		main.Draw(scr, layout.main)
1912
1913	case uiLanding:
1914		m.drawHeader(scr, layout.header)
1915		main := uv.NewStyledString(m.landingView())
1916		main.Draw(scr, layout.main)
1917
1918		editor := uv.NewStyledString(m.renderEditorView(scr.Bounds().Dx()))
1919		editor.Draw(scr, layout.editor)
1920
1921	case uiChat:
1922		if m.isCompact {
1923			m.drawHeader(scr, layout.header)
1924		} else {
1925			m.drawSidebar(scr, layout.sidebar)
1926		}
1927
1928		m.chat.Draw(scr, layout.main)
1929		if layout.pills.Dy() > 0 && m.pillsView != "" {
1930			uv.NewStyledString(m.pillsView).Draw(scr, layout.pills)
1931		}
1932
1933		editorWidth := scr.Bounds().Dx()
1934		if !m.isCompact {
1935			editorWidth -= layout.sidebar.Dx()
1936		}
1937		editor := uv.NewStyledString(m.renderEditorView(editorWidth))
1938		editor.Draw(scr, layout.editor)
1939
1940		// Draw details overlay in compact mode when open
1941		if m.isCompact && m.detailsOpen {
1942			m.drawSessionDetails(scr, layout.sessionDetails)
1943		}
1944	}
1945
1946	isOnboarding := m.state == uiOnboarding
1947
1948	// Add status and help layer
1949	m.status.SetHideHelp(isOnboarding)
1950	m.status.Draw(scr, layout.status)
1951
1952	// Draw completions popup if open
1953	if !isOnboarding && m.completionsOpen && m.completions.HasItems() {
1954		w, h := m.completions.Size()
1955		x := m.completionsPositionStart.X
1956		y := m.completionsPositionStart.Y - h
1957
1958		screenW := area.Dx()
1959		if x+w > screenW {
1960			x = screenW - w
1961		}
1962		x = max(0, x)
1963		y = max(0, y+1) // Offset for attachments row
1964
1965		completionsView := uv.NewStyledString(m.completions.Render())
1966		completionsView.Draw(scr, image.Rectangle{
1967			Min: image.Pt(x, y),
1968			Max: image.Pt(x+w, y+h),
1969		})
1970	}
1971
1972	// Debugging rendering (visually see when the tui rerenders)
1973	if os.Getenv("CRUSH_UI_DEBUG") == "true" {
1974		debugView := lipgloss.NewStyle().Background(lipgloss.ANSIColor(rand.Intn(256))).Width(4).Height(2)
1975		debug := uv.NewStyledString(debugView.String())
1976		debug.Draw(scr, image.Rectangle{
1977			Min: image.Pt(4, 1),
1978			Max: image.Pt(8, 3),
1979		})
1980	}
1981
1982	// This needs to come last to overlay on top of everything. We always pass
1983	// the full screen bounds because the dialogs will position themselves
1984	// accordingly.
1985	if m.dialog.HasDialogs() {
1986		return m.dialog.Draw(scr, scr.Bounds())
1987	}
1988
1989	switch m.focus {
1990	case uiFocusEditor:
1991		if m.layout.editor.Dy() <= 0 {
1992			// Don't show cursor if editor is not visible
1993			return nil
1994		}
1995		if m.detailsOpen && m.isCompact {
1996			// Don't show cursor if details overlay is open
1997			return nil
1998		}
1999
2000		if m.textarea.Focused() {
2001			cur := m.textarea.Cursor()
2002			cur.X++                            // Adjust for app margins
2003			cur.Y += m.layout.editor.Min.Y + 1 // Offset for attachments row
2004			return cur
2005		}
2006	}
2007	return nil
2008}
2009
2010// View renders the UI model's view.
2011func (m *UI) View() tea.View {
2012	var v tea.View
2013	v.AltScreen = true
2014	if !m.isTransparent {
2015		v.BackgroundColor = m.com.Styles.Background
2016	}
2017	v.MouseMode = tea.MouseModeCellMotion
2018	v.ReportFocus = m.caps.ReportFocusEvents
2019	v.WindowTitle = "crush " + home.Short(m.com.Config().WorkingDir())
2020
2021	canvas := uv.NewScreenBuffer(m.width, m.height)
2022	v.Cursor = m.Draw(canvas, canvas.Bounds())
2023
2024	content := strings.ReplaceAll(canvas.Render(), "\r\n", "\n") // normalize newlines
2025	contentLines := strings.Split(content, "\n")
2026	for i, line := range contentLines {
2027		// Trim trailing spaces for concise rendering
2028		contentLines[i] = strings.TrimRight(line, " ")
2029	}
2030
2031	content = strings.Join(contentLines, "\n")
2032
2033	v.Content = content
2034	if m.progressBarEnabled && m.sendProgressBar && m.isAgentBusy() {
2035		// HACK: use a random percentage to prevent ghostty from hiding it
2036		// after a timeout.
2037		v.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
2038	}
2039
2040	return v
2041}
2042
2043// ShortHelp implements [help.KeyMap].
2044func (m *UI) ShortHelp() []key.Binding {
2045	var binds []key.Binding
2046	k := &m.keyMap
2047	tab := k.Tab
2048	commands := k.Commands
2049	if m.focus == uiFocusEditor && m.textarea.Value() == "" {
2050		commands.SetHelp("/ or ctrl+p", "commands")
2051	}
2052
2053	switch m.state {
2054	case uiInitialize:
2055		binds = append(binds, k.Quit)
2056	case uiChat:
2057		// Show cancel binding if agent is busy.
2058		if m.isAgentBusy() {
2059			cancelBinding := k.Chat.Cancel
2060			if m.isCanceling {
2061				cancelBinding.SetHelp("esc", "press again to cancel")
2062			} else if m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID) > 0 {
2063				cancelBinding.SetHelp("esc", "clear queue")
2064			}
2065			binds = append(binds, cancelBinding)
2066		}
2067
2068		if m.focus == uiFocusEditor {
2069			tab.SetHelp("tab", "focus chat")
2070		} else {
2071			tab.SetHelp("tab", "focus editor")
2072		}
2073
2074		binds = append(binds,
2075			tab,
2076			commands,
2077			k.Models,
2078		)
2079
2080		switch m.focus {
2081		case uiFocusEditor:
2082			binds = append(binds,
2083				k.Editor.Newline,
2084			)
2085		case uiFocusMain:
2086			binds = append(binds,
2087				k.Chat.UpDown,
2088				k.Chat.UpDownOneItem,
2089				k.Chat.PageUp,
2090				k.Chat.PageDown,
2091				k.Chat.Copy,
2092			)
2093			if m.pillsExpanded && hasIncompleteTodos(m.session.Todos) && m.promptQueue > 0 {
2094				binds = append(binds, k.Chat.PillLeft)
2095			}
2096		}
2097	default:
2098		// TODO: other states
2099		// if m.session == nil {
2100		// no session selected
2101		binds = append(binds,
2102			commands,
2103			k.Models,
2104			k.Editor.Newline,
2105		)
2106	}
2107
2108	binds = append(binds,
2109		k.Quit,
2110		k.Help,
2111	)
2112
2113	return binds
2114}
2115
2116// FullHelp implements [help.KeyMap].
2117func (m *UI) FullHelp() [][]key.Binding {
2118	var binds [][]key.Binding
2119	k := &m.keyMap
2120	help := k.Help
2121	help.SetHelp("ctrl+g", "less")
2122	hasAttachments := len(m.attachments.List()) > 0
2123	hasSession := m.hasSession()
2124	commands := k.Commands
2125	if m.focus == uiFocusEditor && m.textarea.Value() == "" {
2126		commands.SetHelp("/ or ctrl+p", "commands")
2127	}
2128
2129	switch m.state {
2130	case uiInitialize:
2131		binds = append(binds,
2132			[]key.Binding{
2133				k.Quit,
2134			})
2135	case uiChat:
2136		// Show cancel binding if agent is busy.
2137		if m.isAgentBusy() {
2138			cancelBinding := k.Chat.Cancel
2139			if m.isCanceling {
2140				cancelBinding.SetHelp("esc", "press again to cancel")
2141			} else if m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID) > 0 {
2142				cancelBinding.SetHelp("esc", "clear queue")
2143			}
2144			binds = append(binds, []key.Binding{cancelBinding})
2145		}
2146
2147		mainBinds := []key.Binding{}
2148		tab := k.Tab
2149		if m.focus == uiFocusEditor {
2150			tab.SetHelp("tab", "focus chat")
2151		} else {
2152			tab.SetHelp("tab", "focus editor")
2153		}
2154
2155		mainBinds = append(mainBinds,
2156			tab,
2157			commands,
2158			k.Models,
2159			k.Sessions,
2160		)
2161		if hasSession {
2162			mainBinds = append(mainBinds, k.Chat.NewSession)
2163		}
2164
2165		binds = append(binds, mainBinds)
2166
2167		switch m.focus {
2168		case uiFocusEditor:
2169			binds = append(binds,
2170				[]key.Binding{
2171					k.Editor.Newline,
2172					k.Editor.AddImage,
2173					k.Editor.PasteImage,
2174					k.Editor.MentionFile,
2175					k.Editor.OpenEditor,
2176				},
2177			)
2178			if hasAttachments {
2179				binds = append(binds,
2180					[]key.Binding{
2181						k.Editor.AttachmentDeleteMode,
2182						k.Editor.DeleteAllAttachments,
2183						k.Editor.Escape,
2184					},
2185				)
2186			}
2187		case uiFocusMain:
2188			binds = append(binds,
2189				[]key.Binding{
2190					k.Chat.UpDown,
2191					k.Chat.UpDownOneItem,
2192					k.Chat.PageUp,
2193					k.Chat.PageDown,
2194				},
2195				[]key.Binding{
2196					k.Chat.HalfPageUp,
2197					k.Chat.HalfPageDown,
2198					k.Chat.Home,
2199					k.Chat.End,
2200				},
2201				[]key.Binding{
2202					k.Chat.Copy,
2203					k.Chat.ClearHighlight,
2204				},
2205			)
2206			if m.pillsExpanded && hasIncompleteTodos(m.session.Todos) && m.promptQueue > 0 {
2207				binds = append(binds, []key.Binding{k.Chat.PillLeft})
2208			}
2209		}
2210	default:
2211		if m.session == nil {
2212			// no session selected
2213			binds = append(binds,
2214				[]key.Binding{
2215					commands,
2216					k.Models,
2217					k.Sessions,
2218				},
2219				[]key.Binding{
2220					k.Editor.Newline,
2221					k.Editor.AddImage,
2222					k.Editor.PasteImage,
2223					k.Editor.MentionFile,
2224					k.Editor.OpenEditor,
2225				},
2226			)
2227			if hasAttachments {
2228				binds = append(binds,
2229					[]key.Binding{
2230						k.Editor.AttachmentDeleteMode,
2231						k.Editor.DeleteAllAttachments,
2232						k.Editor.Escape,
2233					},
2234				)
2235			}
2236			binds = append(binds,
2237				[]key.Binding{
2238					help,
2239				},
2240			)
2241		}
2242	}
2243
2244	binds = append(binds,
2245		[]key.Binding{
2246			help,
2247			k.Quit,
2248		},
2249	)
2250
2251	return binds
2252}
2253
2254// toggleCompactMode toggles compact mode between uiChat and uiChatCompact states.
2255func (m *UI) toggleCompactMode() tea.Cmd {
2256	m.forceCompactMode = !m.forceCompactMode
2257
2258	err := m.com.Config().SetCompactMode(m.forceCompactMode)
2259	if err != nil {
2260		return util.ReportError(err)
2261	}
2262
2263	m.updateLayoutAndSize()
2264
2265	return nil
2266}
2267
2268// updateLayoutAndSize updates the layout and sizes of UI components.
2269func (m *UI) updateLayoutAndSize() {
2270	// Determine if we should be in compact mode
2271	if m.state == uiChat {
2272		if m.forceCompactMode {
2273			m.isCompact = true
2274			return
2275		}
2276		if m.width < compactModeWidthBreakpoint || m.height < compactModeHeightBreakpoint {
2277			m.isCompact = true
2278		} else {
2279			m.isCompact = false
2280		}
2281	}
2282
2283	m.layout = m.generateLayout(m.width, m.height)
2284	m.updateSize()
2285}
2286
2287// updateSize updates the sizes of UI components based on the current layout.
2288func (m *UI) updateSize() {
2289	// Set status width
2290	m.status.SetWidth(m.layout.status.Dx())
2291
2292	m.chat.SetSize(m.layout.main.Dx(), m.layout.main.Dy())
2293	m.textarea.SetWidth(m.layout.editor.Dx())
2294	// TODO: Abstract the textarea and attachments into a single editor
2295	// component so we don't have to manually account for the attachments
2296	// height here.
2297	m.textarea.SetHeight(m.layout.editor.Dy() - 2) // Account for top margin/attachments and bottom margin
2298	m.renderPills()
2299
2300	// Handle different app states
2301	switch m.state {
2302	case uiChat:
2303		if !m.isCompact {
2304			m.cacheSidebarLogo(m.layout.sidebar.Dx())
2305		}
2306	}
2307}
2308
2309// generateLayout calculates the layout rectangles for all UI components based
2310// on the current UI state and terminal dimensions.
2311func (m *UI) generateLayout(w, h int) uiLayout {
2312	// The screen area we're working with
2313	area := image.Rect(0, 0, w, h)
2314
2315	// The help height
2316	helpHeight := 1
2317	// The editor height
2318	editorHeight := 5
2319	// The sidebar width
2320	sidebarWidth := 30
2321	// The header height
2322	const landingHeaderHeight = 4
2323
2324	var helpKeyMap help.KeyMap = m
2325	if m.status != nil && m.status.ShowingAll() {
2326		for _, row := range helpKeyMap.FullHelp() {
2327			helpHeight = max(helpHeight, len(row))
2328		}
2329	}
2330
2331	// Add app margins
2332	appRect, helpRect := layout.SplitVertical(area, layout.Fixed(area.Dy()-helpHeight))
2333	appRect.Min.Y += 1
2334	appRect.Max.Y -= 1
2335	helpRect.Min.Y -= 1
2336	appRect.Min.X += 1
2337	appRect.Max.X -= 1
2338
2339	if slices.Contains([]uiState{uiOnboarding, uiInitialize, uiLanding}, m.state) {
2340		// extra padding on left and right for these states
2341		appRect.Min.X += 1
2342		appRect.Max.X -= 1
2343	}
2344
2345	uiLayout := uiLayout{
2346		area:   area,
2347		status: helpRect,
2348	}
2349
2350	// Handle different app states
2351	switch m.state {
2352	case uiOnboarding, uiInitialize:
2353		// Layout
2354		//
2355		// header
2356		// ------
2357		// main
2358		// ------
2359		// help
2360
2361		headerRect, mainRect := layout.SplitVertical(appRect, layout.Fixed(landingHeaderHeight))
2362		uiLayout.header = headerRect
2363		uiLayout.main = mainRect
2364
2365	case uiLanding:
2366		// Layout
2367		//
2368		// header
2369		// ------
2370		// main
2371		// ------
2372		// editor
2373		// ------
2374		// help
2375		headerRect, mainRect := layout.SplitVertical(appRect, layout.Fixed(landingHeaderHeight))
2376		mainRect, editorRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-editorHeight))
2377		// Remove extra padding from editor (but keep it for header and main)
2378		editorRect.Min.X -= 1
2379		editorRect.Max.X += 1
2380		uiLayout.header = headerRect
2381		uiLayout.main = mainRect
2382		uiLayout.editor = editorRect
2383
2384	case uiChat:
2385		if m.isCompact {
2386			// Layout
2387			//
2388			// compact-header
2389			// ------
2390			// main
2391			// ------
2392			// editor
2393			// ------
2394			// help
2395			const compactHeaderHeight = 1
2396			headerRect, mainRect := layout.SplitVertical(appRect, layout.Fixed(compactHeaderHeight))
2397			detailsHeight := min(sessionDetailsMaxHeight, area.Dy()-1) // One row for the header
2398			sessionDetailsArea, _ := layout.SplitVertical(appRect, layout.Fixed(detailsHeight))
2399			uiLayout.sessionDetails = sessionDetailsArea
2400			uiLayout.sessionDetails.Min.Y += compactHeaderHeight // adjust for header
2401			// Add one line gap between header and main content
2402			mainRect.Min.Y += 1
2403			mainRect, editorRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-editorHeight))
2404			mainRect.Max.X -= 1 // Add padding right
2405			uiLayout.header = headerRect
2406			pillsHeight := m.pillsAreaHeight()
2407			if pillsHeight > 0 {
2408				pillsHeight = min(pillsHeight, mainRect.Dy())
2409				chatRect, pillsRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-pillsHeight))
2410				uiLayout.main = chatRect
2411				uiLayout.pills = pillsRect
2412			} else {
2413				uiLayout.main = mainRect
2414			}
2415			// Add bottom margin to main
2416			uiLayout.main.Max.Y -= 1
2417			uiLayout.editor = editorRect
2418		} else {
2419			// Layout
2420			//
2421			// ------|---
2422			// main  |
2423			// ------| side
2424			// editor|
2425			// ----------
2426			// help
2427
2428			mainRect, sideRect := layout.SplitHorizontal(appRect, layout.Fixed(appRect.Dx()-sidebarWidth))
2429			// Add padding left
2430			sideRect.Min.X += 1
2431			mainRect, editorRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-editorHeight))
2432			mainRect.Max.X -= 1 // Add padding right
2433			uiLayout.sidebar = sideRect
2434			pillsHeight := m.pillsAreaHeight()
2435			if pillsHeight > 0 {
2436				pillsHeight = min(pillsHeight, mainRect.Dy())
2437				chatRect, pillsRect := layout.SplitVertical(mainRect, layout.Fixed(mainRect.Dy()-pillsHeight))
2438				uiLayout.main = chatRect
2439				uiLayout.pills = pillsRect
2440			} else {
2441				uiLayout.main = mainRect
2442			}
2443			// Add bottom margin to main
2444			uiLayout.main.Max.Y -= 1
2445			uiLayout.editor = editorRect
2446		}
2447	}
2448
2449	return uiLayout
2450}
2451
2452// uiLayout defines the positioning of UI elements.
2453type uiLayout struct {
2454	// area is the overall available area.
2455	area uv.Rectangle
2456
2457	// header is the header shown in special cases
2458	// e.x when the sidebar is collapsed
2459	// or when in the landing page
2460	// or in init/config
2461	header uv.Rectangle
2462
2463	// main is the area for the main pane. (e.x chat, configure, landing)
2464	main uv.Rectangle
2465
2466	// pills is the area for the pills panel.
2467	pills uv.Rectangle
2468
2469	// editor is the area for the editor pane.
2470	editor uv.Rectangle
2471
2472	// sidebar is the area for the sidebar.
2473	sidebar uv.Rectangle
2474
2475	// status is the area for the status view.
2476	status uv.Rectangle
2477
2478	// session details is the area for the session details overlay in compact mode.
2479	sessionDetails uv.Rectangle
2480}
2481
2482func (m *UI) openEditor(value string) tea.Cmd {
2483	tmpfile, err := os.CreateTemp("", "msg_*.md")
2484	if err != nil {
2485		return util.ReportError(err)
2486	}
2487	defer tmpfile.Close() //nolint:errcheck
2488	if _, err := tmpfile.WriteString(value); err != nil {
2489		return util.ReportError(err)
2490	}
2491	cmd, err := editor.Command(
2492		"crush",
2493		tmpfile.Name(),
2494		editor.AtPosition(
2495			m.textarea.Line()+1,
2496			m.textarea.Column()+1,
2497		),
2498	)
2499	if err != nil {
2500		return util.ReportError(err)
2501	}
2502	return tea.ExecProcess(cmd, func(err error) tea.Msg {
2503		if err != nil {
2504			return util.ReportError(err)
2505		}
2506		content, err := os.ReadFile(tmpfile.Name())
2507		if err != nil {
2508			return util.ReportError(err)
2509		}
2510		if len(content) == 0 {
2511			return util.ReportWarn("Message is empty")
2512		}
2513		os.Remove(tmpfile.Name())
2514		return openEditorMsg{
2515			Text: strings.TrimSpace(string(content)),
2516		}
2517	})
2518}
2519
2520// setEditorPrompt configures the textarea prompt function based on whether
2521// yolo mode is enabled.
2522func (m *UI) setEditorPrompt(yolo bool) {
2523	if yolo {
2524		m.textarea.SetPromptFunc(4, m.yoloPromptFunc)
2525		return
2526	}
2527	m.textarea.SetPromptFunc(4, m.normalPromptFunc)
2528}
2529
2530// normalPromptFunc returns the normal editor prompt style ("  > " on first
2531// line, "::: " on subsequent lines).
2532func (m *UI) normalPromptFunc(info textarea.PromptInfo) string {
2533	t := m.com.Styles
2534	if info.LineNumber == 0 {
2535		if info.Focused {
2536			return "  > "
2537		}
2538		return "::: "
2539	}
2540	if info.Focused {
2541		return t.EditorPromptNormalFocused.Render()
2542	}
2543	return t.EditorPromptNormalBlurred.Render()
2544}
2545
2546// yoloPromptFunc returns the yolo mode editor prompt style with warning icon
2547// and colored dots.
2548func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string {
2549	t := m.com.Styles
2550	if info.LineNumber == 0 {
2551		if info.Focused {
2552			return t.EditorPromptYoloIconFocused.Render()
2553		} else {
2554			return t.EditorPromptYoloIconBlurred.Render()
2555		}
2556	}
2557	if info.Focused {
2558		return t.EditorPromptYoloDotsFocused.Render()
2559	}
2560	return t.EditorPromptYoloDotsBlurred.Render()
2561}
2562
2563// closeCompletions closes the completions popup and resets state.
2564func (m *UI) closeCompletions() {
2565	m.completionsOpen = false
2566	m.completionsQuery = ""
2567	m.completionsStartIndex = 0
2568	m.completions.Close()
2569}
2570
2571// insertCompletionText replaces the @query in the textarea with the given text.
2572// Returns false if the replacement cannot be performed.
2573func (m *UI) insertCompletionText(text string) bool {
2574	value := m.textarea.Value()
2575	if m.completionsStartIndex > len(value) {
2576		return false
2577	}
2578
2579	word := m.textareaWord()
2580	endIdx := min(m.completionsStartIndex+len(word), len(value))
2581	newValue := value[:m.completionsStartIndex] + text + value[endIdx:]
2582	m.textarea.SetValue(newValue)
2583	m.textarea.MoveToEnd()
2584	m.textarea.InsertRune(' ')
2585	return true
2586}
2587
2588// insertFileCompletion inserts the selected file path into the textarea,
2589// replacing the @query, and adds the file as an attachment.
2590func (m *UI) insertFileCompletion(path string) tea.Cmd {
2591	if !m.insertCompletionText(path) {
2592		return nil
2593	}
2594
2595	return func() tea.Msg {
2596		absPath, _ := filepath.Abs(path)
2597
2598		if m.hasSession() {
2599			// Skip attachment if file was already read and hasn't been modified.
2600			lastRead := m.com.App.FileTracker.LastReadTime(context.Background(), m.session.ID, absPath)
2601			if !lastRead.IsZero() {
2602				if info, err := os.Stat(path); err == nil && !info.ModTime().After(lastRead) {
2603					return nil
2604				}
2605			}
2606		} else if slices.Contains(m.sessionFileReads, absPath) {
2607			return nil
2608		}
2609
2610		m.sessionFileReads = append(m.sessionFileReads, absPath)
2611
2612		// Add file as attachment.
2613		content, err := os.ReadFile(path)
2614		if err != nil {
2615			// If it fails, let the LLM handle it later.
2616			return nil
2617		}
2618
2619		return message.Attachment{
2620			FilePath: path,
2621			FileName: filepath.Base(path),
2622			MimeType: mimeOf(content),
2623			Content:  content,
2624		}
2625	}
2626}
2627
2628// insertMCPResourceCompletion inserts the selected resource into the textarea,
2629// replacing the @query, and adds the resource as an attachment.
2630func (m *UI) insertMCPResourceCompletion(item completions.ResourceCompletionValue) tea.Cmd {
2631	displayText := cmp.Or(item.Title, item.URI)
2632
2633	if !m.insertCompletionText(displayText) {
2634		return nil
2635	}
2636
2637	return func() tea.Msg {
2638		contents, err := mcp.ReadResource(
2639			context.Background(),
2640			m.com.Config(),
2641			item.MCPName,
2642			item.URI,
2643		)
2644		if err != nil {
2645			slog.Warn("Failed to read MCP resource", "uri", item.URI, "error", err)
2646			return nil
2647		}
2648		if len(contents) == 0 {
2649			return nil
2650		}
2651
2652		content := contents[0]
2653		var data []byte
2654		if content.Text != "" {
2655			data = []byte(content.Text)
2656		} else if len(content.Blob) > 0 {
2657			data = content.Blob
2658		}
2659		if len(data) == 0 {
2660			return nil
2661		}
2662
2663		mimeType := item.MIMEType
2664		if mimeType == "" && content.MIMEType != "" {
2665			mimeType = content.MIMEType
2666		}
2667		if mimeType == "" {
2668			mimeType = "text/plain"
2669		}
2670
2671		return message.Attachment{
2672			FilePath: item.URI,
2673			FileName: displayText,
2674			MimeType: mimeType,
2675			Content:  data,
2676		}
2677	}
2678}
2679
2680// completionsPosition returns the X and Y position for the completions popup.
2681func (m *UI) completionsPosition() image.Point {
2682	cur := m.textarea.Cursor()
2683	if cur == nil {
2684		return image.Point{
2685			X: m.layout.editor.Min.X,
2686			Y: m.layout.editor.Min.Y,
2687		}
2688	}
2689	return image.Point{
2690		X: cur.X + m.layout.editor.Min.X,
2691		Y: m.layout.editor.Min.Y + cur.Y,
2692	}
2693}
2694
2695// textareaWord returns the current word at the cursor position.
2696func (m *UI) textareaWord() string {
2697	return m.textarea.Word()
2698}
2699
2700// isWhitespace returns true if the byte is a whitespace character.
2701func isWhitespace(b byte) bool {
2702	return b == ' ' || b == '\t' || b == '\n' || b == '\r'
2703}
2704
2705// isAgentBusy returns true if the agent coordinator exists and is currently
2706// busy processing a request.
2707func (m *UI) isAgentBusy() bool {
2708	return m.com.App != nil &&
2709		m.com.App.AgentCoordinator != nil &&
2710		m.com.App.AgentCoordinator.IsBusy()
2711}
2712
2713// hasSession returns true if there is an active session with a valid ID.
2714func (m *UI) hasSession() bool {
2715	return m.session != nil && m.session.ID != ""
2716}
2717
2718// mimeOf detects the MIME type of the given content.
2719func mimeOf(content []byte) string {
2720	mimeBufferSize := min(512, len(content))
2721	return http.DetectContentType(content[:mimeBufferSize])
2722}
2723
2724var readyPlaceholders = [...]string{
2725	"Ready!",
2726	"Ready...",
2727	"Ready?",
2728	"Ready for instructions",
2729}
2730
2731var workingPlaceholders = [...]string{
2732	"Working!",
2733	"Working...",
2734	"Brrrrr...",
2735	"Prrrrrrrr...",
2736	"Processing...",
2737	"Thinking...",
2738}
2739
2740// randomizePlaceholders selects random placeholder text for the textarea's
2741// ready and working states.
2742func (m *UI) randomizePlaceholders() {
2743	m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
2744	m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
2745}
2746
2747// renderEditorView renders the editor view with attachments if any.
2748func (m *UI) renderEditorView(width int) string {
2749	var attachmentsView string
2750	if len(m.attachments.List()) > 0 {
2751		attachmentsView = m.attachments.Render(width)
2752	}
2753	return strings.Join([]string{
2754		attachmentsView,
2755		m.textarea.View(),
2756		"", // margin at bottom of editor
2757	}, "\n")
2758}
2759
2760// cacheSidebarLogo renders and caches the sidebar logo at the specified width.
2761func (m *UI) cacheSidebarLogo(width int) {
2762	m.sidebarLogo = renderLogo(m.com.Styles, true, width)
2763}
2764
2765// sendMessage sends a message with the given content and attachments.
2766func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.Cmd {
2767	if m.com.App.AgentCoordinator == nil {
2768		return util.ReportError(fmt.Errorf("coder agent is not initialized"))
2769	}
2770
2771	var cmds []tea.Cmd
2772	if !m.hasSession() {
2773		newSession, err := m.com.App.Sessions.Create(context.Background(), "New Session")
2774		if err != nil {
2775			return util.ReportError(err)
2776		}
2777		if m.forceCompactMode {
2778			m.isCompact = true
2779		}
2780		if newSession.ID != "" {
2781			m.session = &newSession
2782			cmds = append(cmds, m.loadSession(newSession.ID))
2783		}
2784		m.setState(uiChat, m.focus)
2785	}
2786
2787	ctx := context.Background()
2788	cmds = append(cmds, func() tea.Msg {
2789		for _, path := range m.sessionFileReads {
2790			m.com.App.FileTracker.RecordRead(ctx, m.session.ID, path)
2791			m.com.App.LSPManager.Start(ctx, path)
2792		}
2793		return nil
2794	})
2795
2796	// Capture session ID to avoid race with main goroutine updating m.session.
2797	sessionID := m.session.ID
2798	cmds = append(cmds, func() tea.Msg {
2799		_, err := m.com.App.AgentCoordinator.Run(context.Background(), sessionID, content, attachments...)
2800		if err != nil {
2801			isCancelErr := errors.Is(err, context.Canceled)
2802			isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)
2803			if isCancelErr || isPermissionErr {
2804				return nil
2805			}
2806			return util.InfoMsg{
2807				Type: util.InfoTypeError,
2808				Msg:  err.Error(),
2809			}
2810		}
2811		return nil
2812	})
2813	return tea.Batch(cmds...)
2814}
2815
2816const cancelTimerDuration = 2 * time.Second
2817
2818// cancelTimerCmd creates a command that expires the cancel timer.
2819func cancelTimerCmd() tea.Cmd {
2820	return tea.Tick(cancelTimerDuration, func(time.Time) tea.Msg {
2821		return cancelTimerExpiredMsg{}
2822	})
2823}
2824
2825// cancelAgent handles the cancel key press. The first press sets isCanceling to true
2826// and starts a timer. The second press (before the timer expires) actually
2827// cancels the agent.
2828func (m *UI) cancelAgent() tea.Cmd {
2829	if !m.hasSession() {
2830		return nil
2831	}
2832
2833	coordinator := m.com.App.AgentCoordinator
2834	if coordinator == nil {
2835		return nil
2836	}
2837
2838	if m.isCanceling {
2839		// Second escape press - actually cancel the agent.
2840		m.isCanceling = false
2841		coordinator.Cancel(m.session.ID)
2842		// Stop the spinning todo indicator.
2843		m.todoIsSpinning = false
2844		m.renderPills()
2845		return nil
2846	}
2847
2848	// Check if there are queued prompts - if so, clear the queue.
2849	if coordinator.QueuedPrompts(m.session.ID) > 0 {
2850		coordinator.ClearQueue(m.session.ID)
2851		return nil
2852	}
2853
2854	// First escape press - set canceling state and start timer.
2855	m.isCanceling = true
2856	return cancelTimerCmd()
2857}
2858
2859// openDialog opens a dialog by its ID.
2860func (m *UI) openDialog(id string) tea.Cmd {
2861	var cmds []tea.Cmd
2862	switch id {
2863	case dialog.SessionsID:
2864		if cmd := m.openSessionsDialog(); cmd != nil {
2865			cmds = append(cmds, cmd)
2866		}
2867	case dialog.ModelsID:
2868		if cmd := m.openModelsDialog(); cmd != nil {
2869			cmds = append(cmds, cmd)
2870		}
2871	case dialog.CommandsID:
2872		if cmd := m.openCommandsDialog(); cmd != nil {
2873			cmds = append(cmds, cmd)
2874		}
2875	case dialog.ReasoningID:
2876		if cmd := m.openReasoningDialog(); cmd != nil {
2877			cmds = append(cmds, cmd)
2878		}
2879	case dialog.QuitID:
2880		if cmd := m.openQuitDialog(); cmd != nil {
2881			cmds = append(cmds, cmd)
2882		}
2883	default:
2884		// Unknown dialog
2885		break
2886	}
2887	return tea.Batch(cmds...)
2888}
2889
2890// openQuitDialog opens the quit confirmation dialog.
2891func (m *UI) openQuitDialog() tea.Cmd {
2892	if m.dialog.ContainsDialog(dialog.QuitID) {
2893		// Bring to front
2894		m.dialog.BringToFront(dialog.QuitID)
2895		return nil
2896	}
2897
2898	quitDialog := dialog.NewQuit(m.com)
2899	m.dialog.OpenDialog(quitDialog)
2900	return nil
2901}
2902
2903// openModelsDialog opens the models dialog.
2904func (m *UI) openModelsDialog() tea.Cmd {
2905	if m.dialog.ContainsDialog(dialog.ModelsID) {
2906		// Bring to front
2907		m.dialog.BringToFront(dialog.ModelsID)
2908		return nil
2909	}
2910
2911	isOnboarding := m.state == uiOnboarding
2912	modelsDialog, err := dialog.NewModels(m.com, isOnboarding)
2913	if err != nil {
2914		return util.ReportError(err)
2915	}
2916
2917	m.dialog.OpenDialog(modelsDialog)
2918
2919	return nil
2920}
2921
2922// openCommandsDialog opens the commands dialog.
2923func (m *UI) openCommandsDialog() tea.Cmd {
2924	if m.dialog.ContainsDialog(dialog.CommandsID) {
2925		// Bring to front
2926		m.dialog.BringToFront(dialog.CommandsID)
2927		return nil
2928	}
2929
2930	var sessionID string
2931	hasSession := m.session != nil
2932	if hasSession {
2933		sessionID = m.session.ID
2934	}
2935	hasTodos := hasSession && hasIncompleteTodos(m.session.Todos)
2936	hasQueue := m.promptQueue > 0
2937
2938	commands, err := dialog.NewCommands(m.com, sessionID, hasSession, hasTodos, hasQueue, m.customCommands, m.mcpPrompts)
2939	if err != nil {
2940		return util.ReportError(err)
2941	}
2942
2943	m.dialog.OpenDialog(commands)
2944
2945	return nil
2946}
2947
2948// openReasoningDialog opens the reasoning effort dialog.
2949func (m *UI) openReasoningDialog() tea.Cmd {
2950	if m.dialog.ContainsDialog(dialog.ReasoningID) {
2951		m.dialog.BringToFront(dialog.ReasoningID)
2952		return nil
2953	}
2954
2955	reasoningDialog, err := dialog.NewReasoning(m.com)
2956	if err != nil {
2957		return util.ReportError(err)
2958	}
2959
2960	m.dialog.OpenDialog(reasoningDialog)
2961	return nil
2962}
2963
2964// openSessionsDialog opens the sessions dialog. If the dialog is already open,
2965// it brings it to the front. Otherwise, it will list all the sessions and open
2966// the dialog.
2967func (m *UI) openSessionsDialog() tea.Cmd {
2968	if m.dialog.ContainsDialog(dialog.SessionsID) {
2969		// Bring to front
2970		m.dialog.BringToFront(dialog.SessionsID)
2971		return nil
2972	}
2973
2974	selectedSessionID := ""
2975	if m.session != nil {
2976		selectedSessionID = m.session.ID
2977	}
2978
2979	dialog, err := dialog.NewSessions(m.com, selectedSessionID)
2980	if err != nil {
2981		return util.ReportError(err)
2982	}
2983
2984	m.dialog.OpenDialog(dialog)
2985	return nil
2986}
2987
2988// openFilesDialog opens the file picker dialog.
2989func (m *UI) openFilesDialog() tea.Cmd {
2990	if m.dialog.ContainsDialog(dialog.FilePickerID) {
2991		// Bring to front
2992		m.dialog.BringToFront(dialog.FilePickerID)
2993		return nil
2994	}
2995
2996	filePicker, cmd := dialog.NewFilePicker(m.com)
2997	filePicker.SetImageCapabilities(&m.caps)
2998	m.dialog.OpenDialog(filePicker)
2999
3000	return cmd
3001}
3002
3003// openPermissionsDialog opens the permissions dialog for a permission request.
3004func (m *UI) openPermissionsDialog(perm permission.PermissionRequest) tea.Cmd {
3005	// Close any existing permissions dialog first.
3006	m.dialog.CloseDialog(dialog.PermissionsID)
3007
3008	// Get diff mode from config.
3009	var opts []dialog.PermissionsOption
3010	if diffMode := m.com.Config().Options.TUI.DiffMode; diffMode != "" {
3011		opts = append(opts, dialog.WithDiffMode(diffMode == "split"))
3012	}
3013
3014	permDialog := dialog.NewPermissions(m.com, perm, opts...)
3015	m.dialog.OpenDialog(permDialog)
3016	return nil
3017}
3018
3019// handlePermissionNotification updates tool items when permission state changes.
3020func (m *UI) handlePermissionNotification(notification permission.PermissionNotification) {
3021	toolItem := m.chat.MessageItem(notification.ToolCallID)
3022	if toolItem == nil {
3023		return
3024	}
3025
3026	if permItem, ok := toolItem.(chat.ToolMessageItem); ok {
3027		if notification.Granted {
3028			permItem.SetStatus(chat.ToolStatusRunning)
3029		} else {
3030			permItem.SetStatus(chat.ToolStatusAwaitingPermission)
3031		}
3032	}
3033}
3034
3035// handleAgentNotification translates domain agent events into desktop
3036// notifications using the UI notification backend.
3037func (m *UI) handleAgentNotification(n notify.Notification) tea.Cmd {
3038	switch n.Type {
3039	case notify.TypeAgentFinished:
3040		return m.sendNotification(notification.Notification{
3041			Title:   "Crush is waiting...",
3042			Message: fmt.Sprintf("Agent's turn completed in \"%s\"", n.SessionTitle),
3043		})
3044	default:
3045		return nil
3046	}
3047}
3048
3049// newSession clears the current session state and prepares for a new session.
3050// The actual session creation happens when the user sends their first message.
3051// Returns a command to reload prompt history.
3052func (m *UI) newSession() tea.Cmd {
3053	if !m.hasSession() {
3054		return nil
3055	}
3056
3057	m.session = nil
3058	m.sessionFiles = nil
3059	m.sessionFileReads = nil
3060	m.setState(uiLanding, uiFocusEditor)
3061	m.textarea.Focus()
3062	m.chat.Blur()
3063	m.chat.ClearMessages()
3064	m.pillsExpanded = false
3065	m.promptQueue = 0
3066	m.pillsView = ""
3067	m.historyReset()
3068	agenttools.ResetCache()
3069	return tea.Batch(
3070		func() tea.Msg {
3071			m.com.App.LSPManager.StopAll(context.Background())
3072			return nil
3073		},
3074		m.loadPromptHistory(),
3075	)
3076}
3077
3078// handlePasteMsg handles a paste message.
3079func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
3080	if m.dialog.HasDialogs() {
3081		return m.handleDialogMsg(msg)
3082	}
3083
3084	if m.focus != uiFocusEditor {
3085		return nil
3086	}
3087
3088	if strings.Count(msg.Content, "\n") > pasteLinesThreshold {
3089		return func() tea.Msg {
3090			content := []byte(msg.Content)
3091			if int64(len(content)) > common.MaxAttachmentSize {
3092				return util.ReportWarn("Paste is too big (>5mb)")
3093			}
3094			name := fmt.Sprintf("paste_%d.txt", m.pasteIdx())
3095			mimeBufferSize := min(512, len(content))
3096			mimeType := http.DetectContentType(content[:mimeBufferSize])
3097			return message.Attachment{
3098				FileName: name,
3099				FilePath: name,
3100				MimeType: mimeType,
3101				Content:  content,
3102			}
3103		}
3104	}
3105
3106	// Attempt to parse pasted content as file paths. If possible to parse,
3107	// all files exist and are valid, add as attachments.
3108	// Otherwise, paste as text.
3109	paths := fsext.ParsePastedFiles(msg.Content)
3110	allExistsAndValid := func() bool {
3111		if len(paths) == 0 {
3112			return false
3113		}
3114		for _, path := range paths {
3115			if _, err := os.Stat(path); os.IsNotExist(err) {
3116				return false
3117			}
3118
3119			lowerPath := strings.ToLower(path)
3120			isValid := false
3121			for _, ext := range common.AllowedImageTypes {
3122				if strings.HasSuffix(lowerPath, ext) {
3123					isValid = true
3124					break
3125				}
3126			}
3127			if !isValid {
3128				return false
3129			}
3130		}
3131		return true
3132	}
3133	if !allExistsAndValid() {
3134		var cmd tea.Cmd
3135		m.textarea, cmd = m.textarea.Update(msg)
3136		return cmd
3137	}
3138
3139	var cmds []tea.Cmd
3140	for _, path := range paths {
3141		cmds = append(cmds, m.handleFilePathPaste(path))
3142	}
3143	return tea.Batch(cmds...)
3144}
3145
3146// handleFilePathPaste handles a pasted file path.
3147func (m *UI) handleFilePathPaste(path string) tea.Cmd {
3148	return func() tea.Msg {
3149		fileInfo, err := os.Stat(path)
3150		if err != nil {
3151			return util.ReportError(err)
3152		}
3153		if fileInfo.IsDir() {
3154			return util.ReportWarn("Cannot attach a directory")
3155		}
3156		if fileInfo.Size() > common.MaxAttachmentSize {
3157			return util.ReportWarn("File is too big (>5mb)")
3158		}
3159
3160		content, err := os.ReadFile(path)
3161		if err != nil {
3162			return util.ReportError(err)
3163		}
3164
3165		mimeBufferSize := min(512, len(content))
3166		mimeType := http.DetectContentType(content[:mimeBufferSize])
3167		fileName := filepath.Base(path)
3168		return message.Attachment{
3169			FilePath: path,
3170			FileName: fileName,
3171			MimeType: mimeType,
3172			Content:  content,
3173		}
3174	}
3175}
3176
3177// pasteImageFromClipboard reads image data from the system clipboard and
3178// creates an attachment. If no image data is found, it falls back to
3179// interpreting clipboard text as a file path.
3180func (m *UI) pasteImageFromClipboard() tea.Msg {
3181	imageData, err := readClipboard(clipboardFormatImage)
3182	if int64(len(imageData)) > common.MaxAttachmentSize {
3183		return util.InfoMsg{
3184			Type: util.InfoTypeError,
3185			Msg:  "File too large, max 5MB",
3186		}
3187	}
3188	name := fmt.Sprintf("paste_%d.png", m.pasteIdx())
3189	if err == nil {
3190		return message.Attachment{
3191			FilePath: name,
3192			FileName: name,
3193			MimeType: mimeOf(imageData),
3194			Content:  imageData,
3195		}
3196	}
3197
3198	textData, textErr := readClipboard(clipboardFormatText)
3199	if textErr != nil || len(textData) == 0 {
3200		return nil // Clipboard is empty or does not contain an image
3201	}
3202
3203	path := strings.TrimSpace(string(textData))
3204	path = strings.ReplaceAll(path, "\\ ", " ")
3205	if _, statErr := os.Stat(path); statErr != nil {
3206		return nil // Clipboard does not contain an image or valid file path
3207	}
3208
3209	lowerPath := strings.ToLower(path)
3210	isAllowed := false
3211	for _, ext := range common.AllowedImageTypes {
3212		if strings.HasSuffix(lowerPath, ext) {
3213			isAllowed = true
3214			break
3215		}
3216	}
3217	if !isAllowed {
3218		return util.NewInfoMsg("File type is not a supported image format")
3219	}
3220
3221	fileInfo, statErr := os.Stat(path)
3222	if statErr != nil {
3223		return util.InfoMsg{
3224			Type: util.InfoTypeError,
3225			Msg:  fmt.Sprintf("Unable to read file: %v", statErr),
3226		}
3227	}
3228	if fileInfo.Size() > common.MaxAttachmentSize {
3229		return util.InfoMsg{
3230			Type: util.InfoTypeError,
3231			Msg:  "File too large, max 5MB",
3232		}
3233	}
3234
3235	content, readErr := os.ReadFile(path)
3236	if readErr != nil {
3237		return util.InfoMsg{
3238			Type: util.InfoTypeError,
3239			Msg:  fmt.Sprintf("Unable to read file: %v", readErr),
3240		}
3241	}
3242
3243	return message.Attachment{
3244		FilePath: path,
3245		FileName: filepath.Base(path),
3246		MimeType: mimeOf(content),
3247		Content:  content,
3248	}
3249}
3250
3251var pasteRE = regexp.MustCompile(`paste_(\d+).txt`)
3252
3253func (m *UI) pasteIdx() int {
3254	result := 0
3255	for _, at := range m.attachments.List() {
3256		found := pasteRE.FindStringSubmatch(at.FileName)
3257		if len(found) == 0 {
3258			continue
3259		}
3260		idx, err := strconv.Atoi(found[1])
3261		if err == nil {
3262			result = max(result, idx)
3263		}
3264	}
3265	return result + 1
3266}
3267
3268// drawSessionDetails draws the session details in compact mode.
3269func (m *UI) drawSessionDetails(scr uv.Screen, area uv.Rectangle) {
3270	if m.session == nil {
3271		return
3272	}
3273
3274	s := m.com.Styles
3275
3276	width := area.Dx() - s.CompactDetails.View.GetHorizontalFrameSize()
3277	height := area.Dy() - s.CompactDetails.View.GetVerticalFrameSize()
3278
3279	title := s.CompactDetails.Title.Width(width).MaxHeight(2).Render(m.session.Title)
3280	blocks := []string{
3281		title,
3282		"",
3283		m.modelInfo(width),
3284		"",
3285	}
3286
3287	detailsHeader := lipgloss.JoinVertical(
3288		lipgloss.Left,
3289		blocks...,
3290	)
3291
3292	version := s.CompactDetails.Version.Foreground(s.Border).Width(width).AlignHorizontal(lipgloss.Right).Render(version.Version)
3293
3294	remainingHeight := height - lipgloss.Height(detailsHeader) - lipgloss.Height(version)
3295
3296	const maxSectionWidth = 50
3297	sectionWidth := min(maxSectionWidth, width/3-2) // account for 2 spaces
3298	maxItemsPerSection := remainingHeight - 3       // Account for section title and spacing
3299
3300	lspSection := m.lspInfo(sectionWidth, maxItemsPerSection, false)
3301	mcpSection := m.mcpInfo(sectionWidth, maxItemsPerSection, false)
3302	filesSection := m.filesInfo(m.com.Config().WorkingDir(), sectionWidth, maxItemsPerSection, false)
3303	sections := lipgloss.JoinHorizontal(lipgloss.Top, filesSection, " ", lspSection, " ", mcpSection)
3304	uv.NewStyledString(
3305		s.CompactDetails.View.
3306			Width(area.Dx()).
3307			Render(
3308				lipgloss.JoinVertical(
3309					lipgloss.Left,
3310					detailsHeader,
3311					sections,
3312					version,
3313				),
3314			),
3315	).Draw(scr, area)
3316}
3317
3318func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string) tea.Cmd {
3319	load := func() tea.Msg {
3320		prompt, err := commands.GetMCPPrompt(m.com.Config(), clientID, promptID, arguments)
3321		if err != nil {
3322			// TODO: make this better
3323			return util.ReportError(err)()
3324		}
3325
3326		if prompt == "" {
3327			return nil
3328		}
3329		return sendMessageMsg{
3330			Content: prompt,
3331		}
3332	}
3333
3334	var cmds []tea.Cmd
3335	if cmd := m.dialog.StartLoading(); cmd != nil {
3336		cmds = append(cmds, cmd)
3337	}
3338	cmds = append(cmds, load, func() tea.Msg {
3339		return closeDialogMsg{}
3340	})
3341
3342	return tea.Sequence(cmds...)
3343}
3344
3345func (m *UI) handleStateChanged() tea.Cmd {
3346	return func() tea.Msg {
3347		m.com.App.UpdateAgentModel(context.Background())
3348		return mcpStateChangedMsg{
3349			states: mcp.GetStates(),
3350		}
3351	}
3352}
3353
3354func handleMCPPromptsEvent(name string) tea.Cmd {
3355	return func() tea.Msg {
3356		mcp.RefreshPrompts(context.Background(), name)
3357		return nil
3358	}
3359}
3360
3361func handleMCPToolsEvent(cfg *config.Config, name string) tea.Cmd {
3362	return func() tea.Msg {
3363		mcp.RefreshTools(
3364			context.Background(),
3365			cfg,
3366			name,
3367		)
3368		return nil
3369	}
3370}
3371
3372func handleMCPResourcesEvent(name string) tea.Cmd {
3373	return func() tea.Msg {
3374		mcp.RefreshResources(context.Background(), name)
3375		return nil
3376	}
3377}
3378
3379func (m *UI) copyChatHighlight() tea.Cmd {
3380	text := m.chat.HighlightContent()
3381	return common.CopyToClipboardWithCallback(
3382		text,
3383		"Selected text copied to clipboard",
3384		func() tea.Msg {
3385			m.chat.ClearMouse()
3386			return nil
3387		},
3388	)
3389}
3390
3391// renderLogo renders the Crush logo with the given styles and dimensions.
3392func renderLogo(t *styles.Styles, compact bool, width int) string {
3393	return logo.Render(t, version.Version, compact, logo.Opts{
3394		FieldColor:   t.LogoFieldColor,
3395		TitleColorA:  t.LogoTitleColorA,
3396		TitleColorB:  t.LogoTitleColorB,
3397		CharmColor:   t.LogoCharmColor,
3398		VersionColor: t.LogoVersionColor,
3399		Width:        width,
3400	})
3401}