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