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