ui.go

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