ui.go

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