ui.go

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