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