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