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