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