ui.go

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