ui.go

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