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