ui.go

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