ui.go

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