package tui

import (
	"bytes"
	"fmt"
	"os"
	"regexp"
	"strconv"
	"strings"
	"time"

	tea "charm.land/bubbletea/v2"
	"charm.land/lipgloss/v2"

	"github.com/smallnest/pigo/internal/agentcore"
	"github.com/smallnest/pigo/internal/cli"
	"github.com/smallnest/pigo/internal/cli/memstatus"
	"github.com/smallnest/pigo/internal/cli/status"
	"github.com/smallnest/pigo/internal/memory"
	"github.com/smallnest/pigo/internal/runtime"
)

// Model is the root Bubble Tea model for the full-screen TUI. It composes a
// scrolling transcript (US-005) with the persistent status bar (#386) and a
// minimal input line, and owns the run lifecycle: on prompt submit it starts an
// agent run through the event bridge (bridge.go) and pumps the resulting tea.Msg
// stream into the transcript one message at a time. Downstream nodes grow the
// input into a full textarea (#390), render tool cards (#389), and wire the real
// session/run assembly (#392); the Init/Update/View contract and the alt-screen
// + quit-key handling stay stable.
type Model struct {
	opts  Options
	theme Theme

	// width and height track the terminal size reported by tea.WindowSizeMsg.
	// They are zero until the first size message arrives; View degrades to a
	// minimal render in that window.
	width  int
	height int

	// transcript is the scrolling message log (user / assistant / system turns).
	transcript transcript

	// input is the multi-line prompt editor (#390). It wraps a bubbles textarea
	// so CJK / emoji are edited by rune (no dropped-byte bug), Enter submits and
	// Shift+Enter inserts a newline. It is blurred while a run is in flight.
	input input

	// history holds previously submitted inputs (prompts and slash commands, in
	// order), and histIdx is the browse cursor into it: len(history) means "not
	// browsing — on the live draft", any smaller index points at a recalled entry.
	// histDraft stashes the in-progress buffer when browsing begins so ↓ past the
	// newest entry restores it. ↑/↓ walk history when the caret is on the first /
	// last line of the composer, so multi-line editing is unaffected.
	history   []string
	histIdx   int
	histDraft string

	// running is true while an agent run is draining through runCh. Input submit
	// is gated on it so a new run cannot start mid-run.
	running bool
	// runCh is the bridge channel for the in-flight run, or nil when idle. Update
	// re-issues waitForEvent(runCh) after every bridged msg except runEndMsg.
	runCh chan tea.Msg

	// startRunFn launches an agent run for the submitted prompt, returning the
	// bridge channel and the first waitForEvent Cmd (see bridge.startRun). It is
	// bound to runSession.startRun by withSession (#392): the real binding
	// constructs an AgentContext + RunConfig from opts and the live session. It is
	// nil for a session-less model (the pure constructor / tests), in which case a
	// submit records the prompt but starts no run.
	startRunFn func(prompt string) (chan tea.Msg, tea.Cmd)

	// session is the assembled run/persistence state (store, header, growing
	// context, live config). It is nil for a session-less model; when set, the
	// model persists the conversation to ~/.pigo/sessions after each turn ends.
	session *runSession

	// interruptFn cancels the in-flight run (the first stage of the two-stage
	// interrupt, FR-14): pressing Esc / Ctrl+C while running signals the run to
	// stop rather than quitting the program. It is a seam wired alongside
	// startRunFn by session assembly (#392) — typically the run ctx's cancel
	// func. Until then it may be nil, in which case an interrupt while running is
	// a safe no-op (the pump keeps draining until it ends on its own).
	interruptFn func()

	// quitting is set when a quit key (Ctrl+C / Ctrl+D) is seen, so View can be a
	// no-op on the final frame while the program tears down and restores the
	// terminal.
	quitting bool

	// statusBar renders the persistent bottom line (#386, US-003). It is fed the
	// terminal width, telemetry-derived context usage, and the async git probe
	// result; View renders it just above the input line.
	statusBar statusBar

	// cwd is the launch directory, captured once at construction and reused for
	// the git probe and the status bar's path display.
	cwd string

	// slash is the shared slash-command registry (#383) the TUI consults exactly
	// as the REPL does: /model, /help, user templates, plugin commands and skills.
	// It is bound to live so a /model switch mutates the same config the run loop
	// reads. Built in NewModel (built-ins + disk templates) and rebuilt in
	// withSession against the session's live config.
	slash *runtime.SlashRegistry
	// live is the mutable run configuration the /model command switches. In a
	// session-bound model it is the SAME pointer the run loop reads (set by
	// withSession), so a switch takes effect on the next turn.
	live *cli.LiveConfig
	// menu is the autocomplete popup shown while a "/name" is being typed (#391).
	// It filters slash by the typed prefix; the model intercepts arrow/Tab/Enter
	// keys to drive it before delegating to the textarea.
	menu slashMenu

	// toolCards indexes the rich tool-call cards (#389, US-006) by tool-call id so
	// a toolEndMsg can locate the card started earlier and flip its state / attach
	// the parsed response. Each card is also appended to the transcript as an
	// ordered block (by pointer), so mutating one here re-renders it inline on the
	// next reflow.
	toolCards map[string]*toolCard
	// lastToolCard points at the most recently started card; Ctrl+O toggles its
	// expanded state and re-flows the transcript.
	lastToolCard *toolCard

	// draggingScrollbar is set while the left mouse button is held after pressing
	// on the transcript scrollbar column, so subsequent motion events drag the
	// thumb (and scroll the viewport) until the button is released.
	draggingScrollbar bool

	// sel is the current mouse text selection over the rendered shell (screen
	// cells). A left-press off the scrollbar starts it, drag extends it, and it
	// persists after release so Ctrl+C can copy the highlighted text.
	sel selection

	// spinner is the animated "working" indicator (verb + elapsed/token/effort
	// stats) shown on the row above the input while a run is in flight.
	spinner spinner

	// subagents is the ordered set of live sub-agents dispatched by the `task`
	// tool (SPEC 4.4, US-006). A toolStartMsg with name=="task" adds a row (and
	// records its start time), subagentProgressMsg refreshes activity/tokens, and
	// the task's toolEndMsg removes it. View renders it as a multi-line panel just
	// above the spinner; it contributes zero rows when empty.
	subagents subagentPanel

	// pastes stores the full text of collapsed multi-line pastes, keyed by the id
	// shown in the "[Pasted text #N +M lines]" placeholder left in the composer.
	// submit expands the placeholders back to their content before sending, so a
	// large paste never floods the editor (mirroring Claude Code).
	pastes map[int]string
	// pasteSeq is the monotonic counter behind the paste placeholder ids. It keeps
	// climbing across submits so ids stay unique for the session.
	pasteSeq int

	// images maps the id shown in an "[Image #N]" placeholder to the temp PNG a
	// Ctrl+V / Cmd+V image paste was saved to. submit expands the placeholder to an
	// "@image:<path>" reference so BuildUserContent attaches the image as
	// multimodal content (mirroring Claude Code's image paste).
	images map[int]string
	// imageSeq is the monotonic counter behind the image placeholder ids.
	imageSeq int
}

// NewModel builds the root model from the assembled Options. It reads the
// current working directory (for the status bar's path display and git probe)
// and assembles the shared slash-command registry (#391), which reads the user
// prompt-template dirs (~/.pigo/{commands,prompts}) and the pre-loaded skills;
// missing dirs are not an error. The registry is bound here to a live config
// derived from Options; withSession rebinds it to the session's live config so a
// /model switch reaches the run loop.
func NewModel(opts Options) Model {
	cwd, err := os.Getwd()
	if err != nil {
		cwd = ""
	}
	theme := DefaultTheme()
	live := &cli.LiveConfig{
		Model:         opts.Model,
		ProviderName:  opts.ProviderName,
		Provider:      opts.Provider,
		BaseURL:       opts.BaseURL,
		Protocol:      opts.Protocol,
		ThinkingLevel: opts.ThinkingLevel,
		ContextWindow: cli.DefaultContextWindow,
	}
	return Model{
		opts:       opts,
		theme:      theme,
		transcript: newTranscript(theme),
		input:      newInput(),
		cwd:        cwd,
		statusBar:  newStatusBar(theme, opts, cwd),
		toolCards:  make(map[string]*toolCard),
		slash:      newSlashRegistry(opts, live),
		live:       live,
		menu:       newSlashMenu(theme),
		spinner:    newSpinner(theme),
		pastes:     make(map[int]string),
		images:     make(map[int]string),
	}
}

// withSession binds the assembled run session to the model: it wires the real
// run seam (startRunFn) and, for a resumed session, replays the prior history
// into the transcript so the user sees the conversation so far before entering
// interactive mode. Run calls it right after NewModel; the session-less
// constructor path (tests, pure construction) leaves startRunFn nil.
func (m Model) withSession(s *runSession, history []agentcore.Message) Model {
	m.session = s
	m.startRunFn = s.startRun
	m.interruptFn = s.interrupt
	// Rebind the registry to the session's own one (assembled against s.live, the
	// very config the run loop reads via buildConfig) so /model mutates the live
	// config, /trust reaches the session's trust manager (registered in
	// newRunSessionWithStore), and /status can list skill/plugin/user commands.
	m.live = s.live
	m.slash = s.slash
	m.transcript.addBanner(renderBanner(m.theme, m.opts, m.cwd))
	seedTranscript(&m.transcript, history)
	return m
}

// Init implements tea.Model. It kicks off the async git probe so the status bar
// can show the branch/dirty state as soon as it resolves; the alt-screen is
// requested declaratively via the AltScreen field on the View returned by View.
func (m Model) Init() tea.Cmd {
	return tea.Batch(fetchGitCmd(m.cwd), m.input.Focus(), func() tea.Msg {
		return tea.RequestBackgroundColor()
	})
}

// Update implements tea.Model. It tracks the terminal size, drives the minimal
// input line, starts runs on submit, and pumps bridged run events into the
// transcript and status bar. It quits on the standard exit keys (Ctrl+C /
// Ctrl+D).
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	switch msg := msg.(type) {
	case tea.WindowSizeMsg:
		m.width = msg.Width
		m.height = msg.Height
		m.relayout()
		return m, nil

	case gitInfoMsg:
		m.statusBar.SetGit(msg)
		return m, nil

	case tea.BackgroundColorMsg:
		// Feed the terminal's real background to the Markdown renderer so glamour
		// picks a matching light/dark palette WITHOUT issuing its own terminal
		// query (which would leak its reply into the input — see SetMarkdownDark).
		// Re-flow so any already-finalized assistant block re-renders in the right
		// palette.
		SetMarkdownDark(msg.IsDark())
		m.transcript.reflow()
		return m, nil

	case tea.MouseWheelMsg:
		// Mouse-wheel scrolling reaches the transcript viewport whether idle or
		// running, so history stays scrollable with the wheel — not just PgUp/PgDn.
		// The viewport (MouseWheelEnabled by default) turns the wheel event into a
		// scroll; enabling MouseModeCellMotion in View is what makes the terminal
		// deliver these events under the alt-screen at all.
		cmd := m.transcript.update(msg)
		m.sel = selection{}
		return m, cmd

	case tea.MouseClickMsg:
		// A left press on the scrollbar column grabs the thumb (jump + drag). A left
		// press anywhere else begins a text selection at that cell, replacing any
		// prior one; a bare click (no drag) leaves it empty so it clears the old
		// highlight without starting a copyable range.
		if msg.Button == tea.MouseLeft {
			if m.onScrollbar(msg.X, msg.Y) {
				m.draggingScrollbar = true
				m.transcript.scrollToRow(msg.Y)
				return m, nil
			}
			m.sel = selection{active: true, anchor: point{msg.X, msg.Y}, cursor: point{msg.X, msg.Y}}
			return m, nil
		}
		return m, nil

	case tea.MouseMotionMsg:
		// While the thumb is grabbed, vertical motion drags it regardless of the
		// cursor's column. Otherwise, motion after a left press extends the text
		// selection to the current cell.
		if m.draggingScrollbar {
			m.transcript.scrollToRow(msg.Y)
			return m, nil
		}
		if m.sel.active {
			m.sel.cursor = point{msg.X, msg.Y}
		}
		return m, nil

	case tea.MouseReleaseMsg:
		m.draggingScrollbar = false
		if m.sel.active {
			m.sel.cursor = point{msg.X, msg.Y}
		}
		return m, nil

	case tea.PasteMsg:
		// Bracketed paste (e.g. Cmd+V / right-click paste): the terminal delivers
		// the whole clipboard payload as one message. A multi-line paste is
		// collapsed to a compact placeholder (expanded at submit); a single-line
		// paste is inserted verbatim. See handlePaste.
		if !m.running {
			return m.handlePaste(msg.Content)
		}
		return m, nil

	case tea.ClipboardMsg:
		// OSC52 clipboard read reply (from tea.ReadClipboard on Ctrl+V / Cmd+V).
		// Route through the same collapse-or-insert path as bracketed paste.
		if !m.running {
			return m.handlePaste(msg.Content)
		}
		return m, nil

	case clipboardImageMsg:
		// Reply to a Ctrl+V / Cmd+V image-read attempt. With an image, drop an
		// "[Image #N]" placeholder (expanded to an @image reference at submit); with
		// none, fall back to a normal OSC52 text read so plain-text paste still works.
		if !m.running {
			if msg.ok {
				return m.handleImagePaste(msg.path)
			}
			return m, tea.ReadClipboard
		}
		return m, nil

	case tea.KeyPressMsg:
		return m.handleKey(msg)

	case spinnerTickMsg:
		// Advance the working animation and schedule the next frame, but only while
		// a run is in flight; once idle the tick is not re-issued so the spinner
		// stops without a lingering goroutine.
		if !m.running {
			return m, nil
		}
		m.spinner.advance()
		return m, m.tickSpinner()

	case textDeltaMsg:
		m.spinner.addTokens(msg.delta)
		m.transcript.appendDelta(msg.delta)
		m.remoteEcho(msg.delta)
		return m, m.pumpNext()

	case turnEndMsg:
		m.transcript.finalizeTurn(msg.msg)
		// Surface a failed or empty turn so a provider/API error is never silent.
		// The loop delivers request failures (e.g. a 4xx from the endpoint) as a
		// terminal assistant message with stopReason error/aborted via TurnEndEvent
		// — not as the run's result error (runEndMsg.err) — so without this check
		// the TUI would finalize an empty turn and return to the prompt with no
		// output at all. Mirrors the headless driver and the line-based REPL.
		switch msg.msg.StopReason {
		case agentcore.StopReasonError:
			reason := strings.TrimSpace(msg.msg.ErrorMessage)
			if reason == "" {
				reason = "the provider returned an error with no message"
			}
			m.transcript.addSystem("error: " + reason)
		case agentcore.StopReasonAborted:
			m.transcript.addSystem("error: aborted")
		default:
			// A turn that ends cleanly (end_turn) but produced no text, no thinking,
			// and no tool calls means the endpoint accepted the request but sent back
			// nothing usable (e.g. a 200 whose body was not in the wire format this
			// protocol expects). Note it instead of showing nothing.
			if len(msg.msg.Content) == 0 && len(msg.results) == 0 {
				m.transcript.addSystem("note: empty response from the model (no content). " +
					"Check that --model, --base-url and --protocol match the same provider.")
			}
		}
		return m, m.pumpNext()

	case toolStartMsg:
		// Create a rich tool-call card, index it by id for the later end event, and
		// append it as an ordered transcript block so it renders inline (#389).
		card := &toolCard{id: msg.id, name: msg.name, input: msg.input, state: cardRunning}
		m.toolCards[msg.id] = card
		m.lastToolCard = card
		m.transcript.addToolCard(card)
		m.remoteEcho("\n· " + msg.name + "\n")
		// A `task` tool call dispatches a sub-agent: open a status-panel row keyed by
		// the tool-call id (matching the later progress/end events) and record its
		// start so elapsed can be shown live (SPEC 4.4).
		if msg.name == "task" {
			m.subagents.add(msg.id, taskDescription(msg.input), time.Now())
			m.relayout() // the new panel row shrinks the transcript to fit
		}
		return m, m.pumpNext()

	case toolUpdateMsg:
		// A `task` sub-agent forwards its text as incremental tool-update deltas;
		// accumulate them onto the matching panel row so the expanded view can show
		// the running output. appendOutput is a no-op for non-task ids (nothing to
		// attach to), so ordinary tool updates are unaffected. Relayout only when the
		// delta lands on the currently expanded row, whose growing output changes the
		// panel height; other rows' output is buffered without touching the layout.
		m.subagents.appendOutput(msg.id, msg.partial)
		if m.subagents.expandedID() == msg.id {
			m.relayout()
		}
		return m, m.pumpNext()

	case subagentProgressMsg:
		// A running sub-agent reported structured progress: refresh its panel row's
		// activity/tokens. update adds the row if it is missing so a late/out-of-order
		// progress (arriving before the task's start) is still shown (SPEC 5.4).
		m.subagents.update(msg.id, msg.desc, msg.activity, msg.tokens, time.Now())
		m.relayout() // a first-seen id adds a row; keep the transcript sized to it
		return m, m.pumpNext()

	case toolEndMsg:
		// Flip the card's state and attach the parsed response tree. The card is
		// held by pointer in the transcript, so a reflow re-renders it in place.
		if card, ok := m.toolCards[msg.id]; ok {
			if msg.ok {
				card.state = cardSuccess
			} else {
				card.state = cardWarn
			}
			card.response = parseToolResult(msg.result)
			m.transcript.reflow()
		}
		// Retire the sub-agent's status-panel row (a no-op for non-task tools whose id
		// was never added), reclaiming its reserved height.
		if _, wasSub := m.subagents.byID[msg.id]; wasSub {
			m.subagents.remove(msg.id)
			m.relayout()
		}
		return m, m.pumpNext()

	case telemetryMsg:
		// Feed the status bar's context-usage readout, and retain the event on the
		// session's telemetry holder so /status can render the cumulative + last-run
		// telemetry report (US-002, #292). Then keep the pump running.
		m.statusBar.SetTelemetry(telemetryEventView{
			util:   msg.ev.ContextUtilization,
			window: msg.ev.ContextWindow,
			tokens: msg.ev.ContextTokens,
		})
		if m.session != nil && m.session.telemetry != nil {
			m.session.telemetry.Fold(msg.ev)
		}
		return m, m.pumpNext()

	case compactionStartMsg:
		m.spinner.pin("Compacting conversation")
		return m, m.pumpNext()

	case compactionMsg:
		m.spinner.unpin()
		if m.session != nil {
			m.session.compacted = true
		}
		m.transcript.addSystem("(context compacted)")
		return m, m.pumpNext()

	case rebuildDoneMsg:
		// A manual /rebuild finished: clear the pinned "Preparing conversation
		// context…" spinner (no run is pumping, so stop it and drop out of the
		// running state) and report the outcome. rebuild() already applied the
		// rebuilt messages and set session.compacted on success.
		m.spinner.unpin()
		m.spinner.stop()
		m.running = false
		if msg.err != nil {
			m.transcript.addSystem("rebuild failed: " + msg.err.Error() + " (context left unchanged)")
		} else {
			m.transcript.addSystem(msg.summary)
		}
		m.relayout()
		return m, nil

	case runEndMsg:
		m.running = false
		m.runCh = nil
		m.spinner.stop()
		// The run is over: any still-open sub-agent rows are stale (their tasks ended
		// with the run), so clear the panel to reclaim its height.
		m.subagents = subagentPanel{}
		m.relayout()
		if msg.err != nil {
			m.transcript.addSystem("Run ended: " + msg.err.Error())
		}
		// Persist the turn's new messages as a branch so the conversation survives
		// exit and can be resumed (FR-16). This is race-free: the pump goroutine
		// owns agentCtx.Messages during the run and only sends runEndMsg after
		// DrainStream returns (loop done), so no goroutine is still writing the
		// context when persist reads it here on the tea goroutine. A save failure
		// is surfaced but non-fatal.
		if m.session != nil {
			if err := m.session.persist(); err != nil {
				m.transcript.addSystem("Session save failed: " + err.Error())
			}
		}
		// The editor was blurred at submit; re-enable it so the next prompt can be
		// typed, and re-probe git since a run may have changed the working tree.
		focus := m.input.Focus()
		return m, tea.Batch(focus, fetchGitCmd(m.cwd))

	case remoteInputMsg:
		// A prompt arrived from the paired browser (remote-control). Always re-issue
		// the listener so successive remote prompts keep arriving. While a run is in
		// flight the prompt is refused with a note (mirroring the local single-run
		// gate); when idle it is echoed as a user block and run — as a slash command
		// if it starts with "/", else a normal prompt.
		text := strings.TrimSpace(msg.text)
		if m.running || text == "" {
			if m.running && text != "" {
				m.transcript.addSystem("(remote input ignored: a run is in progress)")
				m.relayout()
			}
			return m, m.waitRemoteInput()
		}
		var cmd tea.Cmd
		var next tea.Model = m
		if strings.HasPrefix(text, "/") {
			next, cmd = m.runSlash(text)
		} else {
			m.transcript.addUser(text)
			m.remoteEcho("\n> " + text + "\n")
			m.relayout()
			next, cmd = m.startPrompt(text)
		}
		m = next.(Model)
		return m, tea.Batch(cmd, m.waitRemoteInput())
	}
	return m, nil
}

// handleKey processes a key press. It resolves the keys the shell owns —
// two-stage interrupt/quit, prompt submit, transcript scrolling — and delegates
// everything else (character entry, in-buffer cursor movement, Shift+Enter
// newline) to the input editor while idle. Keys are matched via KeyPressMsg
// .String() so the mapping is terminal-independent.
func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
	// While idle with the autocomplete popup open, the arrow / Tab / Esc keys
	// drive the menu instead of the transcript or textarea (FR-15). Enter is left
	// to the main switch below, which routes through submit → runSlash so the
	// selected/typed command runs. These are matched via KeyPressMsg.String() so
	// the mapping is terminal-independent.
	if !m.running && m.menu.active {
		switch msg.String() {
		case "up":
			m.menu.moveUp()
			return m, nil
		case "down":
			m.menu.moveDown()
			return m, nil
		case "tab":
			m = m.completeSlash()
			m.relayout()
			return m, nil
		case "esc":
			m.menu.close()
			m.relayout()
			return m, nil
		case "enter":
			return m.submitSlashSelected()
		}
	}

	// While a sub-agent run is streaming, the composer is disabled (no typing until
	// the run ends), so ↑/↓ drive a selection cursor over the live sub-agent status
	// rows and Enter expands the selected row to show its accumulated output inline.
	// Esc is the one-key escape back to the composer: with a row selected it drops
	// the selection AND re-focuses the input box in a single press, so arrowing into
	// the panel is never a trap. With no selection Esc falls through to its
	// two-stage interrupt role below. The Value()=="" guard is a safety net for the
	// rare case where text reached the buffer (e.g. a paste): then arrows edit the
	// buffer rather than the panel.
	if m.running && m.subagents.active() > 0 && m.input.Value() == "" {
		switch msg.String() {
		case "up":
			m.subagents.selectUp()
			m.relayout()
			return m, nil
		case "down":
			m.subagents.selectDown()
			m.relayout()
			return m, nil
		case "enter":
			m.subagents.toggleExpand()
			m.relayout()
			return m, nil
		case "esc":
			if m.subagents.hasSelection() {
				m.subagents.clearSelection()
				focus := m.input.Focus()
				m.relayout()
				return m, focus
			}
		}
	}

	switch msg.String() {
	case "ctrl+c":
		// Ctrl+C copies the current mouse selection when there is one (over OSC52),
		// clearing it afterward; with no selection it keeps its interrupt-or-quit
		// role. Copying works even mid-run, so grabbing streamed output never
		// interrupts the run.
		if !m.sel.empty() {
			text := m.selectedText()
			m.sel = selection{}
			if text != "" {
				return m, tea.SetClipboard(text)
			}
			return m, nil
		}
		return m.interruptOrQuit()
	case "super+c":
		// Cmd+C on macOS is the platform-standard copy: copy the mouse selection
		// when there is one (clearing it), else the whole input buffer. Unlike
		// Ctrl+C it never interrupts/quits — Cmd+C means "copy" on macOS. Most
		// terminals intercept Cmd+C for their own native copy and never deliver it
		// here; this branch serves terminals that forward the Super modifier.
		if !m.sel.empty() {
			text := m.selectedText()
			m.sel = selection{}
			if text != "" {
				return m, tea.SetClipboard(text)
			}
			return m, nil
		}
		if !m.running {
			if v := m.input.Value(); v != "" {
				return m, tea.SetClipboard(v)
			}
		}
		return m, nil
	case "esc":
		return m.interruptOrQuit()
	case "ctrl+o":
		// Toggle the most-recent tool card between its capped preview and the full
		// response tree, then re-flow so the change shows inline (#389).
		if m.lastToolCard != nil {
			m.lastToolCard.expanded = !m.lastToolCard.expanded
			m.transcript.reflow()
		}
		return m, nil
	case "ctrl+d":
		// Ctrl+D quits only when idle; mid-run it is ignored so a run is never
		// dropped by a stray EOF key.
		if !m.running {
			m.shutdownRemote()
			m.quitting = true
			return m, tea.Quit
		}
		return m, nil
	case "enter":
		// Enter submits the composed buffer (FR-13). Shift+Enter inserts a newline
		// (rebound in newInput) so the editor is a true multi-line composer; when
		// the slash menu is open, Enter runs the highlighted command (handled
		// above), so this branch is only reached with the menu closed.
		if !m.running {
			return m.submit()
		}
		return m, nil
	case "pgup", "pgdown":
		// Page scrolling reaches the transcript viewport whether idle or running,
		// so history stays readable while a run streams. Scrolling shifts the
		// content under a screen-anchored selection, so drop the selection to avoid
		// a stale highlight. Line-oriented keys (up / down / home / end) belong to
		// the multi-line editor and are delegated below.
		m.sel = selection{}
		cmd := m.transcript.update(msg)
		return m, cmd
	case "ctrl+v":
		// Explicit paste key: first try to pull an image off the clipboard (Claude
		// Code-style image paste); the reply arrives as clipboardImageMsg and, when
		// no image is present, falls back to an OSC52 text read (tea.ClipboardMsg).
		// This is intercepted before textarea so its own Ctrl+V binding — which reads
		// via an external process and returns an unexported message the model can't
		// route — is bypassed. The common Cmd+V path does not reach here; it arrives
		// as a bracketed tea.PasteMsg handled in Update.
		if !m.running {
			return m, readClipboardImage
		}
		return m, nil
	case "super+v":
		// Cmd+V on macOS is the platform-standard paste. Most terminals turn it
		// into a bracketed paste (tea.PasteMsg, handled in Update); this branch
		// covers terminals that instead forward the Super modifier as a key. Try an
		// image read first, falling back to an OSC52 text read when none is present.
		if !m.running {
			return m, readClipboardImage
		}
		return m, nil
	case "ctrl+y":
		// Copy: the editor has no text selection, so this copies the whole buffer
		// to the system clipboard over OSC52. A no-op on an empty buffer.
		if !m.running {
			if v := m.input.Value(); v != "" {
				return m, tea.SetClipboard(v)
			}
		}
		return m, nil
	}

	// Everything else is editing input; gated on idle so keystrokes never corrupt
	// an in-flight prompt. textarea handles CJK / emoji by rune and Shift+Enter as
	// a newline. After the buffer changes, refresh the autocomplete popup so it
	// opens/filters/closes as the user types a "/name" prefix.
	if !m.running {
		// ↑/↓ walk the submitted-prompt history when the caret is at the top / bottom
		// edge of the composer; otherwise they move the caret within a multi-line
		// draft (handled by the textarea below).
		switch msg.String() {
		case "up":
			return m.historyPrev(msg)
		case "down":
			return m.historyNext(msg)
		}
		var cmd tea.Cmd
		m.input, cmd = m.input.Update(msg)
		m.menu.refresh(m.input.Value(), m.slash)
		m.relayout()
		return m, cmd
	}
	return m, nil
}

// submit starts a run for the current buffer: it appends the user block, clears
// and blurs the editor, flips to running, and — when a run starter is wired —
// returns the first pump Cmd. With no starter (pre-#392) it records the prompt
// and a system note without launching anything, and leaves the editor ready for
// the next line.
func (m Model) submit() (tea.Model, tea.Cmd) {
	raw := strings.TrimSpace(m.input.Value())
	prompt := strings.TrimSpace(m.expandImages(m.expandPastes(m.input.Value())))
	if prompt == "" {
		return m, nil
	}
	// Record the input (as typed) into the browse history, then exit browse mode.
	m.recordHistory(raw)
	// The placeholders have been expanded into the prompt, so the stored paste
	// bodies and image paths are consumed; drop them (the id counters keep climbing).
	m.pastes = make(map[int]string)
	m.images = make(map[int]string)
	// A "/name ..." line is a slash-command invocation, not a prompt: resolve it
	// against the shared registry (same as the REPL) rather than sending it to the
	// agent verbatim.
	if strings.HasPrefix(prompt, "/") {
		return m.runSlash(prompt)
	}
	m.transcript.addUser(prompt)
	m.remoteEcho("\n> " + prompt + "\n")
	m.input.Clear()
	m.menu.close()
	m.relayout()
	return m.startPrompt(prompt)
}

// completeSlash fills the buffer with the highlighted candidate's "/name " so the
// user can go on to type arguments; the trailing space ends name-completion, so
// the refresh closes the popup. It is the Tab action while the menu is open.
func (m Model) completeSlash() Model {
	if c, ok := m.menu.current(); ok {
		m.input.SetValue("/" + c.Name + " ")
		m.menu.refresh(m.input.Value(), m.slash)
	}
	return m
}

// submitSlashSelected runs the command the popup highlights (Enter while the
// menu is open). Navigating with the arrows then pressing Enter runs the
// selected command even if the typed prefix is shorter; with no selection it
// falls back to the raw buffer so a fully-typed "/name" still runs.
func (m Model) submitSlashSelected() (tea.Model, tea.Cmd) {
	line := strings.TrimSpace(m.input.Value())
	if c, ok := m.menu.current(); ok {
		line = "/" + c.Name
	}
	m.recordHistory(line)
	return m.runSlash(line)
}

// runSlash resolves a slash-command line against the shared registry and folds
// its outcome into the transcript, mirroring the REPL's dispatch: the invocation
// is echoed as a user block; an action command's status (e.g. /help, /model)
// renders as a system block; a prompt/skill command's expanded text starts a
// run; a hybrid (plugin) command shows its notifications then runs its prompt.
// An unknown command surfaces the resolver error as a system block.
func (m Model) runSlash(line string) (tea.Model, tea.Cmd) {
	// /exit and /quit terminate the TUI, mirroring the REPL loop which intercepts
	// them before slash resolution. They register only as no-op /help builtins, so
	// without this the registry would resolve them to an empty action.
	if line == "/exit" || line == "/quit" {
		m.shutdownRemote()
		m.quitting = true
		return m, tea.Quit
	}
	// /memory is intercepted before registry resolution (like /rebuild): it
	// prints the persistent-memory + infinite-context report, reading the live
	// memory store, memory root, session id, and messages that a slash Action
	// closure (string→string) cannot reach.
	if line == "/memory" || strings.HasPrefix(line, "/memory ") {
		m.transcript.addUser(line)
		m.input.Clear()
		m.menu.close()
		var buf bytes.Buffer
		var store *memory.Store
		var memoryRoot, sessionID string
		var msgs agentcore.MessageList
		window := m.live.ContextWindow
		if m.session != nil {
			store = m.session.memstore
			memoryRoot = m.session.memoryRoot
			sessionID = m.session.header.ID
			msgs = m.session.agentCtx.Messages
		}
		memstatus.RunMemory(&buf, store, memoryRoot, sessionID, msgs, window)
		m.transcript.addSystem(strings.TrimRight(buf.String(), "\n"))
		m.relayout()
		return m, nil
	}
	// /status is intercepted before registry resolution (like /memory): it prints
	// the shared runtime/context/project/credentials/telemetry report, which reads
	// the session's live collaborators (live config, trust manager, telemetry
	// holder, slash registry) that a slash Action closure (string→string) cannot
	// reach. The rendering lives in the shared status package so the TUI and the
	// REPL produce byte-identical output.
	if line == "/status" || strings.HasPrefix(line, "/status ") {
		m.transcript.addUser(line)
		m.input.Clear()
		m.menu.close()
		if m.session == nil {
			m.transcript.addSystem("(status unavailable: no active session)")
			m.relayout()
			return m, nil
		}
		var buf bytes.Buffer
		status.RunStatus(&buf, m.session)
		m.transcript.addSystem(strings.TrimRight(buf.String(), "\n"))
		m.relayout()
		return m, nil
	}
	// /session is intercepted before registry resolution (like /memory): it prints
	// the conversation summary (session id, message count, estimated tokens, model/
	// provider, created time, compaction count) from the live context — state a
	// slash Action closure cannot reach. The rendering is shared with the REPL.
	if line == "/session" {
		m.transcript.addUser(line)
		m.input.Clear()
		m.menu.close()
		if m.session == nil {
			m.transcript.addSystem("(session unavailable: no active session)")
			m.relayout()
			return m, nil
		}
		var buf bytes.Buffer
		m.session.renderSession(&buf)
		m.transcript.addSystem(strings.TrimRight(buf.String(), "\n"))
		m.relayout()
		return m, nil
	}
	// /rebuild is intercepted before registry resolution (like /exit): it
	// reconstructs the shared context from a persisted checkpoint (or falls back
	// to compaction) and replaces the message list in place — work a slash Action
	// closure cannot do. It reuses the compacting-indicator: the spinner is armed
	// and pinned to "Preparing conversation context…" while the rebuild runs off
	// the tea loop, and rebuildDoneMsg clears it and reports the result.
	if line == "/rebuild" {
		m.transcript.addUser(line)
		m.input.Clear()
		m.menu.close()
		if m.session == nil {
			m.transcript.addSystem("(rebuild unavailable: no active session)")
			m.relayout()
			return m, nil
		}
		m.spinner.begin(time.Now(), m.thinkingLabel())
		m.spinner.pin("Preparing conversation context")
		m.running = true
		m.relayout()
		return m, tea.Batch(m.session.rebuildCmd(), m.tickSpinner())
	}
	// /remote-control is intercepted before registry resolution (like /rebuild):
	// it starts/stops the LAN mirror server, which owns state (server, bridge,
	// listener Cmd) a string→string slash Action cannot hold.
	if line == "/remote-control" || strings.HasPrefix(line, "/remote-control ") {
		return m.runRemoteControl(line)
	}
	m.transcript.addUser(line)
	m.input.Clear()
	m.menu.close()
	m.relayout()
	if m.slash == nil {
		m.transcript.addSystem("Slash commands unavailable")
		return m, nil
	}
	outcome, err := m.slash.ResolveOutcome(line)
	if err != nil {
		m.transcript.addSystem(err.Error())
		return m, nil
	}
	if outcome.Message != "" {
		m.transcript.addSystem(outcome.Message)
	}
	// A live-state command (/model, /think) may have mutated m.live; sync the
	// status bar so the model/thinking segments reflect the switch immediately.
	if m.live != nil {
		m.statusBar.SetModel(m.live.Model)
		m.statusBar.SetThinking(string(m.live.ThinkingLevel))
	}
	// An action command is complete once its status is shown; a hybrid with no
	// prompt (notifications only) likewise starts no run.
	if outcome.Kind == runtime.SlashAction || outcome.Prompt == "" {
		return m, nil
	}
	return m.startPrompt(outcome.Prompt)
}

// recordHistory appends an submitted input to the browse history (skipping a
// consecutive duplicate, like a shell) and resets the browse cursor to the live
// draft, so the next ↑ starts from the most recent entry and any stashed draft is
// dropped. A blank entry is never stored.
func (m *Model) recordHistory(entry string) {
	entry = strings.TrimSpace(entry)
	if entry != "" && (len(m.history) == 0 || m.history[len(m.history)-1] != entry) {
		m.history = append(m.history, entry)
	}
	m.histIdx = len(m.history)
	m.histDraft = ""
}

// historyPrev recalls the previous submitted input into the composer, but only
// when the caret is on the first line — otherwise ↑ moves the caret within a
// multi-line draft. The first recall stashes the live draft so historyNext can
// restore it, and the cursor lands past the newest entry (len(history)) initially.
func (m Model) historyPrev(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
	if len(m.history) == 0 || m.input.Line() != 0 {
		var cmd tea.Cmd
		m.input, cmd = m.input.Update(msg)
		m.menu.refresh(m.input.Value(), m.slash)
		m.relayout()
		return m, cmd
	}
	if m.histIdx == len(m.history) {
		m.histDraft = m.input.Value()
	}
	if m.histIdx > 0 {
		m.histIdx--
	}
	m.input.SetValue(m.history[m.histIdx])
	m.menu.refresh(m.input.Value(), m.slash)
	m.relayout()
	return m, nil
}

// historyNext walks forward toward more recent inputs — restoring the stashed
// draft once it steps past the newest entry — but only while browsing and with
// the caret on the last line; otherwise ↓ moves the caret within a multi-line
// draft.
func (m Model) historyNext(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
	if m.histIdx >= len(m.history) || m.input.Line() != m.input.LineCount()-1 {
		var cmd tea.Cmd
		m.input, cmd = m.input.Update(msg)
		m.menu.refresh(m.input.Value(), m.slash)
		m.relayout()
		return m, cmd
	}
	m.histIdx++
	if m.histIdx == len(m.history) {
		m.input.SetValue(m.histDraft)
	} else {
		m.input.SetValue(m.history[m.histIdx])
	}
	m.menu.refresh(m.input.Value(), m.slash)
	m.relayout()
	return m, nil
}

// startPrompt launches an agent run for prompt, blurring the editor and flipping
// to running when a run starter is wired. With no starter (pre-session model /
// tests) it records the pre-#392 system note and stays idle. It is shared by a
// plain submit and by a slash prompt/skill command.
func (m Model) startPrompt(prompt string) (tea.Model, tea.Cmd) {
	if m.startRunFn == nil {
		m.transcript.addSystem("(run not wired up: see session assembly in #392)")
		return m, nil
	}
	m.input.Blur()
	ch, cmd := m.startRunFn(prompt)
	m.runCh = ch
	m.running = true
	m.spinner.begin(time.Now(), m.thinkingLabel())
	m.relayout()
	return m, tea.Batch(cmd, m.tickSpinner())
}

// thinkingLabel returns the current thinking-effort label for the spinner stats
// (e.g. "medium"), or "" when no thinking level is configured so the stat is
// omitted. It reads the live config the /model command mutates, falling back to
// the launch Options.
func (m Model) thinkingLabel() string {
	if m.live != nil && m.live.ThinkingLevel != "" {
		return string(m.live.ThinkingLevel)
	}
	return string(m.opts.ThinkingLevel)
}

// taskDescription pulls the human-readable "description" out of a `task` tool
// call's decoded arguments for the sub-agent panel's row label. It returns ""
// when absent or non-string (the description field is optional in the schema),
// in which case the panel row leads with the activity instead.
func taskDescription(input map[string]any) string {
	if s, ok := input["description"].(string); ok {
		return s
	}
	return ""
}

// tickSpinner schedules the next spinner animation frame. The model re-issues it
// on each spinnerTickMsg while running, so the animation self-sustains until the
// run ends (the tick is simply not re-issued once idle).
func (m Model) tickSpinner() tea.Cmd {
	return tea.Tick(spinnerInterval, func(t time.Time) tea.Msg {
		return spinnerTickMsg(t)
	})
}

// interruptOrQuit is the shared Esc / bare-Ctrl+C action: a two-stage interrupt
// (FR-14) that stops an in-flight run on the first press and stays in the
// program, or quits when idle.
func (m Model) interruptOrQuit() (tea.Model, tea.Cmd) {
	if m.running {
		if m.interruptFn != nil {
			m.interruptFn()
		}
		m.transcript.addSystem("(interrupting the current run…)")
		return m, nil
	}
	m.shutdownRemote()
	m.quitting = true
	return m, tea.Quit
}

// shutdownRemote stops the remote-control server on quit so the listener and
// WebSocket are released cleanly. A no-op when remote control is off or no
// session is bound.
func (m Model) shutdownRemote() {
	if m.session != nil {
		m.session.stopRemote()
	}
}

// feedInput forwards a message (a paste payload) to the editor, then refreshes
// the slash menu and re-lays out because inserted text can add lines (growing
// the editor) or begin a "/name". It is the shared tail of the paste handlers.
func (m Model) feedInput(msg tea.Msg) (tea.Model, tea.Cmd) {
	var cmd tea.Cmd
	m.input, cmd = m.input.Update(msg)
	m.menu.refresh(m.input.Value(), m.slash)
	m.relayout()
	return m, cmd
}

// pastePlaceholderRe matches the "[Pasted text #N +M lines]" tokens handlePaste
// leaves in the composer, capturing the id so expandPastes can swap the stored
// body back in at submit.
var pastePlaceholderRe = regexp.MustCompile(`\[Pasted text #(\d+) \+\d+ lines\]`)

// handlePaste inserts a pasted payload into the editor. A multi-line paste is
// collapsed to a compact "[Pasted text #N +M lines]" placeholder (the full body
// stashed in m.pastes for expansion at submit), so a large paste does not flood
// the composer — mirroring Claude Code. A single-line paste is inserted verbatim.
func (m Model) handlePaste(content string) (tea.Model, tea.Cmd) {
	if content == "" {
		return m, nil
	}
	if strings.Contains(content, "\n") {
		if m.pastes == nil {
			m.pastes = make(map[int]string)
		}
		m.pasteSeq++
		id := m.pasteSeq
		m.pastes[id] = content
		lines := strings.Count(content, "\n") + 1
		placeholder := fmt.Sprintf("[Pasted text #%d +%d lines]", id, lines)
		return m.feedInput(tea.PasteMsg{Content: placeholder})
	}
	return m.feedInput(tea.PasteMsg{Content: content})
}

// expandPastes replaces every paste placeholder in s with its stored body, so
// the submitted prompt carries the real pasted text rather than the compact
// token the user saw in the composer. An unknown id (e.g. the user edited the
// token) is left as-is. It returns s unchanged when no pastes are stashed.
func (m Model) expandPastes(s string) string {
	if len(m.pastes) == 0 {
		return s
	}
	return pastePlaceholderRe.ReplaceAllStringFunc(s, func(tok string) string {
		sm := pastePlaceholderRe.FindStringSubmatch(tok)
		id, err := strconv.Atoi(sm[1])
		if err != nil {
			return tok
		}
		if body, ok := m.pastes[id]; ok {
			return body
		}
		return tok
	})
}

// handleImagePaste stashes a pasted image (already saved to a temp PNG at path)
// and drops a compact "[Image #N]" placeholder into the composer, mirroring the
// text-paste placeholder. submit expands it into an "@image:<path>" reference so
// BuildUserContent attaches the image as multimodal content. An empty path falls
// back to a plain text read.
func (m Model) handleImagePaste(path string) (tea.Model, tea.Cmd) {
	if path == "" {
		return m, tea.ReadClipboard
	}
	if m.images == nil {
		m.images = make(map[int]string)
	}
	m.imageSeq++
	id := m.imageSeq
	m.images[id] = path
	placeholder := fmt.Sprintf("[Image #%d]", id)
	return m.feedInput(tea.PasteMsg{Content: placeholder})
}

// imagePlaceholderRe matches the "[Image #N]" tokens handleImagePaste leaves in
// the composer, capturing the id so expandImages can swap the stored temp path
// back in as an "@image:<path>" reference at submit.
var imagePlaceholderRe = regexp.MustCompile(`\[Image #(\d+)\]`)

// expandImages replaces every image placeholder in s with an "@image:<path>"
// reference so BuildUserContent reads and attaches the pasted image. An unknown id
// (e.g. the user edited the token) is left as-is. It returns s unchanged when no
// images are stashed.
func (m Model) expandImages(s string) string {
	if len(m.images) == 0 {
		return s
	}
	return imagePlaceholderRe.ReplaceAllStringFunc(s, func(tok string) string {
		sm := imagePlaceholderRe.FindStringSubmatch(tok)
		id, err := strconv.Atoi(sm[1])
		if err != nil {
			return tok
		}
		if p, ok := m.images[id]; ok {
			return "@image:" + p
		}
		return tok
	})
}

// pumpNext re-issues waitForEvent for the in-flight run so the next bridged msg
// is pulled. It returns nil once the run has ended (runCh cleared), stopping the
// pump.
func (m Model) pumpNext() tea.Cmd {
	if m.running && m.runCh != nil {
		return waitForEvent(m.runCh)
	}
	return nil
}

// View implements tea.Model. It renders the shell on the alt-screen: the
// scrolling transcript filling the top rows, then the autocomplete popup (when
// open) and the multi-line input editor, and finally the persistent status bar
// (#386) on the very bottom row — below the input, per the layout fix. Setting
// AltScreen on the returned View is how Bubble Tea v2 enters/leaves the alternate
// screen buffer, so the user's scrollback is restored on quit.
func (m Model) View() tea.View {
	if m.quitting {
		return tea.View{AltScreen: true}
	}

	content := m.applySelection(m.renderContent())

	// MouseModeCellMotion enables click/release/wheel events. Without it the
	// alt-screen swallows the wheel (no native scrollback), so history could only
	// be reached via PgUp/PgDn; enabling it lets the wheel scroll the transcript
	// and drives both scrollbar drag and mouse text selection.
	return tea.View{Content: content, AltScreen: true, MouseMode: tea.MouseModeCellMotion}
}

// renderContent builds the full-screen shell string (transcript, autocomplete
// popup, input editor, status bar) without any selection overlay. View wraps it
// with applySelection for display, and selectedText reuses it to extract the
// copied text from the exact rows the user sees.
func (m Model) renderContent() string {
	width := m.width
	if width <= 0 {
		width = 80
	}
	height := m.height
	if height <= 0 {
		height = 24
	}

	status := m.statusBar.Render(width)

	// The input editor renders its own prompt column and cursor across as many
	// rows as the buffer currently spans (up to maxInputRows).
	input := m.input.View()

	// Fallback transcript rows before the first size message; once sized the
	// viewport is pre-sized by relayout and pads its own content.
	rows := transcriptHeight(height)
	sized := m.width > 0 && m.height > 0

	var b strings.Builder
	if sized {
		// The viewport pads its content to exactly the rows relayout reserved.
		b.WriteString(m.transcript.view())
		b.WriteByte('\n')
	} else {
		for i := 0; i < rows; i++ {
			b.WriteByte('\n')
		}
	}
	// The working spinner sits on its own row just above the input while a run is
	// in flight (relayout reserves the row so the transcript shrinks to fit). The
	// sub-agent status panel, when any `task` sub-agents are live, renders on the
	// rows just ABOVE the spinner: one line each, elapsed refreshed every tick.
	if m.running {
		if panel := m.subagents.view(m.theme, width, time.Now()); panel != "" {
			b.WriteString(panel)
			b.WriteByte('\n')
		}
		if line := m.spinner.view(width); line != "" {
			b.WriteString(line)
			b.WriteByte('\n')
		}
	}
	// The autocomplete popup, when open, renders just above the input line as an
	// overlay (it contributes no rows while idle, so the empty-shell layout is
	// unchanged).
	if menu := m.menu.view(width); menu != "" {
		b.WriteString(menu)
		b.WriteByte('\n')
	}
	b.WriteString(input)
	b.WriteByte('\n')
	// The status bar is the final line, pinned to the very bottom of the shell
	// below the input editor.
	b.WriteString(status)
	return b.String()
}

// applySelection overlays the mouse selection highlight onto the rendered
// content, inverting the selected cells like a terminal's own selection. Only
// rows the selection intersects are rewritten (as plain text with the span
// inverted); untouched rows keep their original coloring. It is a no-op when the
// selection is empty.
func (m Model) applySelection(content string) string {
	if m.sel.empty() {
		return content
	}
	start, end := m.sel.ordered()
	hi := lipgloss.NewStyle().Reverse(true)
	rows := strings.Split(content, "\n")
	for y := start.y; y <= end.y && y < len(rows); y++ {
		if y < 0 {
			continue
		}
		c0, c1, ok := rowRange(start, end, y)
		if !ok {
			continue
		}
		rows[y], _ = selectRow(rows[y], c0, c1, hi)
	}
	return strings.Join(rows, "\n")
}

// selectedText extracts the plain text under the current selection from the rows
// the user sees, joining rows with newlines and trimming each row's trailing
// padding so copied text has no ragged whitespace tail. It returns "" when the
// selection is empty.
func (m Model) selectedText() string {
	if m.sel.empty() {
		return ""
	}
	start, end := m.sel.ordered()
	rows := strings.Split(m.renderContent(), "\n")
	var b strings.Builder
	wrote := false
	for y := start.y; y <= end.y && y < len(rows); y++ {
		if y < 0 {
			continue
		}
		c0, c1, ok := rowRange(start, end, y)
		if !ok {
			continue
		}
		_, text := selectRow(rows[y], c0, c1, lipgloss.Style{})
		if wrote {
			b.WriteByte('\n')
		}
		b.WriteString(strings.TrimRight(text, " "))
		wrote = true
	}
	return b.String()
}

// relayout re-sizes the transcript to the rows left after reserving the status
// bar (1 row), the current input editor height, and any open autocomplete popup.
// It hands the transcript the full width; the transcript itself spends one column
// on the scrollbar only while its content overflows (see transcript.reflow), so a
// short conversation uses the whole width and shows no bar, while a scrolling one
// reserves the gutter — and that decision re-runs on every streamed line, not just
// on resize. It is called on every resize and after any edit that changes the
// input height or menu row count.
func (m *Model) relayout() {
	if m.width <= 0 || m.height <= 0 {
		return
	}
	rows := m.height - 1 - m.input.Height() - m.menu.rows()
	if m.running {
		rows-- // the working spinner occupies the row just above the input
		// The sub-agent panel reserves one status row per live sub-agent, plus the
		// wrapped output lines of the expanded row (if any); an empty panel reserves
		// nothing so the single-run layout is unchanged.
		rows -= m.subagents.lineCount(m.width)
	}
	if rows < 0 {
		rows = 0
	}
	m.transcript.setSize(m.width, rows)
	m.input.SetWidth(m.width)
}

// onScrollbar reports whether the terminal cell (x, y) is the transcript's
// scrollbar: the rightmost column (relayout reserves m.width-1 for content, so
// the bar sits at column m.width-1) within the transcript's visible rows, which
// start at the top of the screen (row 0). It gates click-to-drag so presses in
// the body or on other chrome are left alone. When the content fits there is no
// bar (relayout reclaims the column), so it always returns false.
func (m Model) onScrollbar(x, y int) bool {
	if m.width <= 0 || !m.transcript.overflowing() {
		return false
	}
	h := m.transcript.viewportHeight()
	return x == m.width-1 && y >= 0 && y < h
}

// transcriptHeight returns the fallback number of rows for the transcript before
// the first size message arrives: the total minus the status bar and a single
// input row, floored at zero so tiny terminals never produce a negative extent.
func transcriptHeight(total int) int {
	h := total - 2
	if h < 0 {
		h = 0
	}
	return h
}
