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