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