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