ui.go

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