import SwiftUI
import NucleicCore

/// Transcript display preferences.
enum TranscriptDisplay {
    /// When `false` (default), debug-level rows are hidden for a clean transcript;
    /// when `true`, advanced diagnostic detail is shown inline.
    static let showDebugKey = "nucleic.showDebugLines"
    /// When `true` (default), the merged tool-call lines are rephrased by the on-device
    /// model where eligible; when `false`, they stay the deterministic heuristic merge.
    /// (Consecutive calls coalesce into one block and merge by tool either way.)
    static let summarizeToolCallsKey = "nucleic.summarizeToolCalls"
    /// When `true` (default), file-lock lifecycle notes (waiting/acquired/released) show in
    /// the transcript; when `false`, they're hidden to keep the chat focused on the agent's work.
    static let showLockEventsKey = "nucleic.showLockEvents"

    /// Which tool-call families the AI rewrites when summarization is on (Bash only, or
    /// every non-Read family). Read is always a plain deterministic merge.
    static let toolSummaryScopeKey = "nucleic.toolSummaryScope"

    /// The persisted "summarize tool calls" preference, defaulting to `true` when
    /// unset (so the store mirrors `@AppStorage`'s default before Settings is opened).
    static var storedSummarizeToolCalls: Bool {
        UserDefaults.standard.object(forKey: summarizeToolCallsKey) as? Bool ?? true
    }

    /// The persisted tool-summary scope, defaulting to `.bashOnly` when unset (mirrors
    /// the `@AppStorage` default so the store agrees before Settings is opened). Bash-only
    /// is the safe default: the "all tool calls" scope drives the on-device model much
    /// harder and only suits strong chips (see `DeviceCapability`).
    static var storedToolSummaryScope: ToolSummaryScope {
        UserDefaults.standard.string(forKey: toolSummaryScopeKey)
            .flatMap(ToolSummaryScope.init(rawValue:)) ?? .bashOnly
    }
}

private struct TranscriptInSubagentKey: EnvironmentKey {
    static let defaultValue = false
}

private struct LockLinesKey: EnvironmentKey {
    static let defaultValue: [String: [NoteLock]] = [:]
}

extension EnvironmentValues {
    /// True while rendering rows *inside* a subagent's activity stream. Inner tool rows then
    /// show their literal "Read <path>" / "Bash <command>" lines rather than the AFM-rephrased
    /// summaries, which are only computed for the top-level conversation — so a nested Bash
    /// call reads straight away instead of stalling on a loading placeholder that never lands.
    var transcriptInSubagent: Bool {
        get { self[TranscriptInSubagentKey.self] }
        set { self[TranscriptInSubagentKey.self] = newValue }
    }

    /// File-lock lifecycle lines folded onto each tool card, keyed by tool-call id
    /// (`TranscriptProjection.items(_:worktreeRoot:)`). A card renders these as a 🔒 line so the
    /// lock reads as bracketing its edit rather than as a free-floating row after the call.
    var lockLinesByToolCall: [String: [NoteLock]] {
        get { self[LockLinesKey.self] }
        set { self[LockLinesKey.self] = newValue }
    }
}

/// The leading glyph and muted one-liner for a lock line folded onto a tool card.
enum LockLineStyle {
    static func icon(_ state: NoteLock.State) -> String {
        switch state {
        case .waiting: "hourglass"
        case .acquired: "lock.fill"
        case .released: "lock.open"
        }
    }

    static func text(_ lock: NoteLock) -> String {
        let paths = pathList(lock.paths)
        switch lock.state {
        case .waiting: return "Waiting for lock · \(paths)"
        case .acquired: return "Locked · \(paths)"
        case .released: return "Lock released · \(paths)"
        }
    }

    /// Up to two repo-relative paths (basename kept readable), then a "+N more" tail.
    private static func pathList(_ paths: [String]) -> String {
        guard !paths.isEmpty else { return "no files" }
        let shown = paths.prefix(2).joined(separator: ", ")
        let extra = paths.count - min(paths.count, 2)
        return extra > 0 ? "\(shown) +\(extra) more" : shown
    }
}

/// One folded lock line on a tool card: a small muted glyph + status, matching the card's
/// secondary detail styling. Hidden by the caller when the "Show lock events" setting is off.
private struct LockLineRow: View {
    let lock: NoteLock

    var body: some View {
        HStack(alignment: .firstTextBaseline, spacing: 6) {
            Image(systemName: LockLineStyle.icon(lock.state))
                .font(.caption2)
                .foregroundStyle(.tertiary)
                .frame(width: 15)
            Text(LockLineStyle.text(lock))
                .font(.caption)
                .foregroundStyle(.secondary)
                .lineLimit(1)
                .truncationMode(.middle)
            Spacer(minLength: 0)
        }
    }
}

/// One transcript item rendered. Collapses tool calls/results to a compact line;
/// assistant text reads as prose; thinking shows muted/italic (UX_MACOS "Session detail").
struct TranscriptRow: View {
    @Environment(\.appPalette) private var palette
    @Environment(AppStore.self) private var store
    let item: TranscriptItem
    /// Invoked with a user message's canonical `seq` when its "revert to here" arrow is
    /// tapped. `nil` hides the arrow (e.g. while a turn is running). Only user bubbles use it.
    var onRevert: ((UInt64) -> Void)? = nil
    /// Invoked when the "Log in" button on an authentication error is tapped. `nil` hides
    /// the button (falling back to a plain error line). Only the auth-error row uses it.
    var onLogin: (() -> Void)? = nil

    var body: some View {
        switch item {
        case .assistant(_, let text, _):
            // Actual agent prose renders tight, with no extra padding — it's the
            // baseline the non-response rows are set apart from.
            MarkdownText(markdown: text).textSelection(.enabled)
        case .thinking(_, let text, _):
            thinkingRow(text).modifier(NonResponsePadding())
        case .tool(_, let call, let result, let finished, let children):
            toolRow(call: call, result: result, finished: finished, children: children)
                .modifier(NonResponsePadding())
        case .toolGroup(_, let signature, let calls):
            // A run made entirely of subagent spawns is a multi-agent fan-out — render it
            // as the gold orchestration card (parallel subagents with live status) rather
            // than the generic tool block.
            if calls.allSatisfy({ Self.isSubagentCall($0.call) }) {
                OrchestrationCard(calls: calls).modifier(NonResponsePadding())
            } else {
                // The block supplies the single rounded container + padding; its inner
                // rows render chrome-less so they read as one contiguous card. A
                // non-subagent group never carries children, so they're dropped here.
                ToolGroupRow(
                    signature: signature,
                    calls: calls.map { (call: $0.call, result: $0.result, finished: $0.finished) })
                    .modifier(NonResponsePadding())
            }
        case .thinkingTokens(_, let total, let delta, let elapsed):
            thinkingTokensRow(total: total, delta: delta, elapsed: elapsed)
                .modifier(NonResponsePadding())
        case .event(let event):
            rawEventBody(event).modifier(NonResponsePadding())
        }
    }

    // MARK: - Tool row

    /// A completed `AskUserQuestion` renders as a pretty Q&A card (the question plus the
    /// option the user picked) rather than the opaque "AskUserQuestion {…}" tool card;
    /// every other tool — and an as-yet-unanswered question — stays the normal collapsible
    /// `ToolCallRow`.
    @ViewBuilder
    private func toolRow(call: ToolCall, result: ToolResult?, finished: Bool,
                         children: [TranscriptItem]) -> some View {
        if let card = answeredQuestionCard(call: call, result: result) {
            card
        } else if let card = gitCommitCard(call: call, result: result) {
            card
        } else if let card = hostExecCard(call: call, result: result, finished: finished) {
            card
        } else if Self.isSubagentCall(call) {
            // A lone subagent spawn renders as its own gold subagent card (type, task, and
            // running/done/failed status), making delegated work read distinctly from the
            // agent's own tool calls. Its inner Read/Bash/Edit stream nests inside, on expand.
            SubagentCard(call: call, result: result, finished: finished, children: children)
        } else {
            ToolCallRow(call: call, result: result, finished: finished,
                        worktreeRoot: store.openSession?.worktreePath)
        }
    }

    /// Whether `call` spawns a subagent — Claude Code's `Task` tool (and the `Agent` alias).
    /// These get the dedicated subagent / orchestration cards instead of a generic tool row.
    static func isSubagentCall(_ call: ToolCall) -> Bool {
        call.name == "Task" || call.name == "Agent"
    }

    /// A git commit pipeline renders as the same structured card the approval shows — subject,
    /// body, and steps — rather than the raw "Bash <command>" row. The literal command stays
    /// reachable under the card's "Show command", and a trailing `git log` result (the new
    /// hash) shows beneath it. Nil for any non-commit Bash call, which keeps the normal row.
    private func gitCommitCard(call: ToolCall, result: ToolResult?) -> GitBlockCard? {
        guard call.name == "Bash",
              let command = call.input["command"]?.stringValue,
              let block = GitCommandSummary.block(for: command), block.isCommit
        else { return nil }
        let output = result.map { Self.resultText($0.content) }.flatMap { $0.isEmpty ? nil : $0 }
        return GitBlockCard(block: block, rawCommand: command, output: output)
    }

    /// A `mcp__nucleic__host_exec` call renders as a structured host-execution card — a
    /// host-machine glyph, the command pretty-printed with a `$` prompt, and its output revealed
    /// on tap — instead of the generic "mcp__nucleic__host_exec {"command": …}" JSON row the
    /// catch-all tool card would show. Nil for any other tool (which keeps the normal row), and
    /// for a host call with no command (a malformed input falls back to the generic row).
    private func hostExecCard(call: ToolCall, result: ToolResult?, finished: Bool) -> HostExecToolCard? {
        guard call.name == MCPApprovalServer.qualifiedHostExecToolName,
              let command = call.input["command"]?.stringValue, !command.isEmpty
        else { return nil }
        let output = result.map { Self.resultText($0.content) }.flatMap { $0.isEmpty ? nil : $0 }
        return HostExecToolCard(
            command: command, output: output, finished: finished, isError: result?.isError == true)
    }

    /// The pretty card for a *completed* AskUserQuestion whose result records the user's
    /// choices, or nil when this isn't that (wrong tool, still pending, errored, or an
    /// unexpected result shape) — in which case the row falls back to a normal tool card.
    private func answeredQuestionCard(call: ToolCall, result: ToolResult?) -> AskUserQuestionResultCard? {
        guard call.name == AskUserQuestion.toolName,
              let result, !result.isError,
              let questions = AskUserQuestion.questions(from: call.input)
        else { return nil }
        let answers = AskUserQuestion.answers(in: Self.resultText(result.content), for: questions)
        guard !answers.isEmpty else { return nil }
        return AskUserQuestionResultCard(questions: questions, answers: answers)
    }

    // MARK: - Thinking

    @ViewBuilder
    private func thinkingRow(_ text: String) -> some View {
        let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
        if !trimmed.isEmpty {
            HStack(alignment: .top, spacing: 8) {
                Rectangle().fill(.quaternary).frame(width: 2).cornerRadius(1)
                HStack(alignment: .firstTextBaseline, spacing: 6) {
                    Image(systemName: "sparkles").font(.caption2).foregroundStyle(.tertiary)
                    Text(trimmed)
                        .font(.callout).italic().foregroundStyle(.secondary)
                        .lineSpacing(4)
                        .textSelection(.enabled)
                }
            }
            .padding(.vertical, 1)
        }
    }

    // MARK: - Thinking tokens (advanced detail)

    /// The live "thinking tokens" meter: the running token estimate of the model's current
    /// reasoning, the latest per-ping change, and the elapsed time — coalesced by the
    /// projection into one row that updates as it thinks. A debug-level row, so it shows only
    /// when "advanced detail" is on, alongside the other diagnostic tallies.
    private func thinkingTokensRow(total: Int, delta: Int, elapsed: TimeInterval) -> some View {
        label("brain", Self.thinkingTokensText(total: total, delta: delta, elapsed: elapsed), .secondary)
    }

    /// "Thinking · 1.4k tokens (+50) · 12.3s" — the cumulative estimate (compacted past 999
    /// into `n.nk`), the latest delta when it's a real increase, and the elapsed time.
    static func thinkingTokensText(total: Int, delta: Int, elapsed: TimeInterval) -> String {
        var text = "Thinking · \(TokenFormatting.short(total)) tokens"
        if delta > 0 { text += " (+\(TokenFormatting.short(delta)))" }
        return text + " · \(TokenFormatting.elapsed(elapsed))"
    }

    // MARK: - Tool call / result

    private func toolResultRow(_ result: ToolResult) -> some View {
        HStack(alignment: .top, spacing: 8) {
            Image(systemName: result.isError ? "exclamationmark.triangle.fill" : "arrow.turn.down.right")
                .font(.caption2)
                .foregroundStyle(result.isError ? AnyShapeStyle(palette.danger) : AnyShapeStyle(.tertiary))
                .frame(width: 15)
            Text(Self.resultText(result.content))
                .font(.system(.caption, design: .monospaced))
                .foregroundStyle(result.isError ? AnyShapeStyle(palette.danger) : AnyShapeStyle(.secondary))
                .lineLimit(4)
                .textSelection(.enabled)
            Spacer(minLength: 0)
        }
        .padding(.leading, 6)
    }

    /// Renders a one-line tool summary as inline Markdown, so a model-written line can carry
    /// `code` spans — a command, file, or symbol set monospaced — the way assistant prose does.
    /// Only inline constructs apply on a single line (emphasis, links, `code`); a parse failure
    /// falls back to the plain string. Same `.inlineOnlyPreservingWhitespace` path `MarkdownText`
    /// uses, so a backtick span reads identically in a summary line and in chat.
    static func inlineMarkdown(_ string: String) -> AttributedString {
        let options = AttributedString.MarkdownParsingOptions(
            interpretedSyntax: .inlineOnlyPreservingWhitespace)
        if let attributed = try? AttributedString(markdown: string, options: options) {
            return attributed
        }
        return AttributedString(string)
    }

    static func toolIcon(_ name: String) -> String {
        switch name {
        case "Bash": "terminal"
        case "Read": "doc.text"
        case "Edit", "MultiEdit", "Write", "NotebookEdit": "pencil"
        case "Grep", "Glob": "magnifyingglass"
        case "Task", "Agent": "person.2"
        case "WebFetch", "WebSearch": "globe"
        case "TodoWrite", "TaskCreate", "TaskUpdate": "checklist"
        // A host_exec run on the macOS host, outside the sandbox — the desktop glyph the
        // approval card and the in-chat `HostExecToolCard` use, so it reads alike everywhere.
        case MCPApprovalServer.qualifiedHostExecToolName: "desktopcomputer"
        default: "wrench.and.screwdriver"
        }
    }

    /// The single most informative argument of a tool call, for the compact row. File
    /// paths render relative to `root` (the project working directory) when given.
    static func toolDetail(_ call: ToolCall, relativeTo root: String? = nil) -> String? {
        func arg(_ key: String) -> String? { call.input[key]?.stringValue }
        let detail: String? = switch call.name {
        case "Bash": arg("command")
        case "Read", "Edit", "MultiEdit", "Write": arg("file_path").map { HeuristicSummary.displayPath($0, relativeTo: root) }
        case "NotebookEdit": arg("notebook_path").map { HeuristicSummary.displayPath($0, relativeTo: root) }
        case "Grep", "Glob": arg("pattern")
        case "WebFetch": arg("url")
        case "WebSearch": arg("query")
        case "Task", "Agent": arg("description")
        // The host command itself, so a per-call host_exec row (e.g. inside an expanded group)
        // shows "$ command" rather than the raw `{"command": …}` JSON the fallback would print.
        case MCPApprovalServer.qualifiedHostExecToolName: arg("command")
        default: nil
        }
        // Trim any worktree path mentioned anywhere in the detail (commands, patterns,
        // the JSON fallback), not just the dedicated file-path argument.
        if let detail { return HeuristicSummary.relativizePaths(detail, relativeTo: root) }
        let compacted = compact(call.input)
        return compacted.isEmpty ? nil : HeuristicSummary.relativizePaths(compacted, relativeTo: root)
    }

    /// Human-readable tool-result text: a plain string, the joined text of an MCP
    /// content array, or the canonical JSON as a last resort.
    static func resultText(_ value: JSONValue) -> String {
        if let string = value.stringValue { return string }
        if let array = value.arrayValue {
            let texts = array.compactMap { $0["text"]?.stringValue }
            if !texts.isEmpty { return texts.joined(separator: "\n") }
        }
        let canonical = value.canonicalString()
        return canonical == "null" ? "" : canonical
    }

    @ViewBuilder
    private func rawEventBody(_ event: AgentEvent) -> some View {
        switch event.kind {
        case .sessionStarted(let s):
            label("play.circle", "Session started · \(s.model)", .secondary)
        case .userText(let chunk):
            // Set the user's message apart from the surrounding turn with extra
            // breathing room above and below the bubble. (NonResponsePadding adds
            // the rest, since the user turn is also non-response text.)
            UserMessageBubble(
                text: chunk.text,
                onRevert: onRevert.map { revert in { revert(event.seq) } })
                .padding(.vertical, 4)
        case .assistantText(let chunk) where !chunk.isPartial:
            // Only reached for replayed transcripts not run through the projection.
            MarkdownText(markdown: chunk.text).textSelection(.enabled)
        case .assistantText, .thinking, .toolCallStarted, .toolCallInputDelta, .toolCallCompleted:
            EmptyView()  // handled by the projection
        case .toolResult(let result):
            toolResultRow(result)
        case .fileChange(let change):
            label("doc.badge.gearshape", "\(change.kind.rawValue) \(change.path)", palette.paused)
        case .approvalRequested(let request):
            label("pause.circle", "Approval requested: \(request.title)", palette.attention)
        case .approvalResolved(let resolved):
            label("arrow.right.circle", "Resolved by \(resolved.decidedBy)", .secondary)
        case .usage(let usage):
            label("chart.bar", usageText(usage), .secondary)
        case .rateLimit(let rl):
            label("hourglass", "Rate limit: \(rl.rateLimitType ?? "?") \(rl.status ?? "")", .secondary)
        case .turnCompleted:
            label("circle.dotted", "Turn completed", .secondary)
        case .runFinished(let finished):
            switch finished.outcome {
            case .interrupted:
                // A deliberate manual stop, not a failure: stop-sign octagon in amber.
                label("octagon", "Run stopped", palette.attention)
            case .errored:
                label("exclamationmark.triangle", "Run errored", palette.danger)
            case .completed:
                label("flag.checkered", "Run completed", palette.success)
            case .maxTurns:
                label("flag.checkered", "Run maxTurns", palette.success)
            }
        case .error(let error):
            // An authentication failure (an expired/missing Claude Code login, or a bad
            // API key) reads as a friendly "log in" prompt with a one-click button rather
            // than the raw "claude exited… 401…" line.
            if Self.isAuthError(error.message) {
                AuthErrorRow(rawMessage: error.message, onLogin: onLogin)
            } else {
                label("exclamationmark.triangle", error.message, palette.danger)
            }
        case .note(let note):
            label(note.icon ?? "arrow.triangle.branch", note.text, .secondary)
        case .raw(let raw):
            label("questionmark.circle", "[\(event.nativeType ?? "?")] \(Self.compact(raw.native))", .secondary)
        }
    }

    private func label(_ icon: String, _ text: String, _ color: Color) -> some View {
        HStack(alignment: .firstTextBaseline, spacing: 6) {
            Image(systemName: icon).font(.caption).foregroundStyle(color).frame(width: 16)
            Text(text).font(.callout).foregroundStyle(color).lineLimit(3).textSelection(.enabled)
        }
    }

    static func compact(_ value: JSONValue) -> String {
        let string = value.canonicalString()
        if string == "{}" || string == "null" { return "" }
        return String(string.prefix(160))
    }

    /// Whether an error message looks like an authentication failure — the family of 401s
    /// the user fixes by logging in again (an expired/missing Claude Code OAuth login, or a
    /// bad API key). Matched on the message text since it arrives synthesized from CLI
    /// stderr or an `api_retry` event, not as a structured status code.
    static func isAuthError(_ message: String) -> Bool {
        let m = message.lowercased()
        return m.contains("401")
            || m.contains("authentication_error")
            || m.contains("invalid authentication")
            || m.contains("invalid x-api-key")
            || m.contains("invalid bearer token")
            || m.contains("oauth token has expired")
            || m.contains("unauthorized")
            || m.contains("not logged in")
            || m.contains("please run /login")
    }

    private func usageText(_ usage: Usage) -> String {
        var parts: [String] = []
        if let i = usage.inputTokens { parts.append("in \(i)") }
        if let r = usage.cachedInputTokens, r > 0 { parts.append("cache-read \(r)") }
        if let w = usage.cacheCreationInputTokens, w > 0 { parts.append("cache-write \(w)") }
        if let o = usage.outputTokens { parts.append("out \(o)") }
        if let cost = usage.costUSD { parts.append(String(format: "$%.4f", cost)) }
        return parts.joined(separator: " · ")
    }
}

/// The in-chat rendering for an authentication failure (a 401 from an expired/missing
/// Claude Code login or a bad API key). Replaces the raw "claude exited… 401…" line with
/// a friendly prompt and a one-click "Log in" button that opens the built-in terminal and
/// runs `claude /login`, so the user never has to leave Nucleic for a separate terminal.
/// The underlying error text stays reachable under "Details" for anyone debugging.
private struct AuthErrorRow: View {
    @Environment(\.appPalette) private var palette
    let rawMessage: String
    let onLogin: (() -> Void)?
    @State private var showingDetails = false

    var body: some View {
        VStack(alignment: .leading, spacing: 6) {
            HStack(alignment: .firstTextBaseline, spacing: 6) {
                Image(systemName: "lock.fill")
                    .font(.caption).foregroundStyle(palette.attention).frame(width: 16)
                Text("Unauthenticated — log in to continue.")
                    .font(.callout).foregroundStyle(palette.attention)
                    .textSelection(.enabled)
            }
            HStack(spacing: 12) {
                if let onLogin {
                    Button(action: onLogin) {
                        Label("Log in", systemImage: "key.fill")
                            .font(.caption.weight(.semibold))
                    }
                    .buttonStyle(.borderedProminent)
                    .controlSize(.small)
                    .tint(palette.accent)
                }
                Button {
                    withAnimation(.easeInOut(duration: 0.15)) { showingDetails.toggle() }
                } label: {
                    Text(showingDetails ? "Hide details" : "Details")
                        .font(.caption).foregroundStyle(.secondary)
                }
                .buttonStyle(.plain)
            }
            .padding(.leading, 22)
            if showingDetails {
                Text(rawMessage)
                    .font(.system(.caption, design: .monospaced))
                    .foregroundStyle(.secondary)
                    .lineLimit(4)
                    .textSelection(.enabled)
                    .padding(.leading, 22)
            }
        }
    }
}

/// A sent (user) message bubble, right-aligned, with a "revert to here" arrow tucked
/// beneath it that reveals on hover. Tapping the arrow asks to wipe this message and
/// everything after it and drop the text back into the composer to edit (see
/// `AppStore.revertOpenSession`). The arrow is absent (`onRevert == nil`) while a turn runs.
private struct UserMessageBubble: View {
    @Environment(\.appPalette) private var palette
    let text: String
    let onRevert: (() -> Void)?
    @State private var hovering = false
    @State private var expanded = false

    /// Collapsed bubbles show at most this many lines before a "Show more". A message
    /// only counts as "long" (and gets the toggle) once it would overflow this.
    private static let collapsedLineLimit = 12
    /// A cheap pre-check so most messages never pay for the measurement probe: only text
    /// past this many characters or newlines can *plausibly* exceed `collapsedLineLimit`
    /// lines. This is necessary but not sufficient — a 700-char message can still wrap to
    /// fewer than 12 lines, so the probe below confirms before showing the toggle.
    private var isLong: Bool {
        text.count > 700 || text.filter(\.isNewline).count >= Self.collapsedLineLimit
    }
    /// Measured heights of the message clipped to `collapsedLineLimit` vs. unbounded. The
    /// limit only hides something — and the toggle is only meaningful — when the unbounded
    /// text is actually taller. Without this the button could appear yet toggle nothing.
    @State private var collapsedHeight: CGFloat = 0
    @State private var fullHeight: CGFloat = 0
    private var isTruncated: Bool { fullHeight > collapsedHeight + 0.5 }

    var body: some View {
        VStack(alignment: .trailing, spacing: 3) {
            HStack {
                Spacer(minLength: 40)
                Text(text)
                    .lineSpacing(4)
                    .foregroundStyle(AppTheme.primaryText)
                    .textSelection(.enabled)
                    .lineLimit(isLong && !expanded ? Self.collapsedLineLimit : nil)
                    .padding(.horizontal, 10).padding(.vertical, 6)
                    .background(palette.accent.opacity(0.22), in: .rect(cornerRadius: 10))
                    .background { if isLong { truncationProbe } }
                    .frame(alignment: .trailing)
            }
            if isTruncated {
                Button {
                    withAnimation(.easeInOut(duration: 0.15)) { expanded.toggle() }
                } label: {
                    Text(expanded ? "Show less" : "Show more")
                        .font(.caption.weight(.medium))
                        .foregroundStyle(palette.accent)
                }
                .buttonStyle(.plain)
                .padding(.trailing, 2)
            }
            if let onRevert {
                Button(action: onRevert) {
                    Image(systemName: "arrow.uturn.backward")
                        .font(.caption2.weight(.semibold))
                        .foregroundStyle(.secondary)
                        .padding(.trailing, 2)
                }
                .buttonStyle(.plain)
                .help("Revert to here: delete this and all later messages and put this message "
                    + "back in the composer to edit. The agent forgets the reverted turns; your files are left as-is.")
                .opacity(hovering ? 1 : 0)
            }
        }
        .onHover { hovering = $0 }
        .animation(.easeInOut(duration: 0.12), value: hovering)
    }

    /// Hidden copies of the message — one clipped to `collapsedLineLimit`, one unbounded —
    /// laid out as a background so they share the bubble's text width and wrap identically.
    /// Their measured heights tell us whether collapsing actually hides any lines.
    private var truncationProbe: some View {
        ZStack {
            probeText(limit: Self.collapsedLineLimit)
                .background(GeometryReader { g in
                    Color.clear.preference(key: CollapsedHeightKey.self, value: g.size.height)
                })
            probeText(limit: nil)
                .background(GeometryReader { g in
                    Color.clear.preference(key: FullHeightKey.self, value: g.size.height)
                })
        }
        .hidden()
        .onPreferenceChange(CollapsedHeightKey.self) { collapsedHeight = $0 }
        .onPreferenceChange(FullHeightKey.self) { fullHeight = $0 }
    }

    /// A measurement-only copy of the bubble text, matching the visible text's spacing and
    /// padding so its wrapped height matches what the user sees.
    private func probeText(limit: Int?) -> some View {
        Text(text)
            .lineSpacing(4)
            .lineLimit(limit)
            .padding(.horizontal, 10).padding(.vertical, 6)
            .frame(maxWidth: .infinity, alignment: .topLeading)
    }
}

private struct CollapsedHeightKey: PreferenceKey {
    static let defaultValue: CGFloat = 0
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = max(value, nextValue()) }
}

private struct FullHeightKey: PreferenceKey {
    static let defaultValue: CGFloat = 0
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = max(value, nextValue()) }
}

/// A tool call rendered as a self-contained rounded "card". The header (icon,
/// tool name, and the most informative argument) is always visible; the tool's
/// output is tucked inside and revealed on tap. Collapsed by default to keep the
/// transcript uncluttered — the output is opt-in detail, not baseline reading.
private struct ToolCallRow: View {
    @Environment(\.appPalette) private var palette
    @Environment(AppStore.self) private var store
    @Environment(\.transcriptInSubagent) private var inSubagent
    @Environment(\.lockLinesByToolCall) private var lockLinesByToolCall
    @AppStorage(TranscriptDisplay.summarizeToolCallsKey) private var summarizeToolCalls = true
    @AppStorage(TranscriptDisplay.showLockEventsKey) private var showLockEvents = true
    @AppStorage(TranscriptDisplay.toolSummaryScopeKey)
    private var toolSummaryScopeRaw = ToolSummaryScope.bashOnly.rawValue
    let call: ToolCall
    let result: ToolResult?
    let finished: Bool
    /// The project working directory, so the header's file path renders relative to it.
    var worktreeRoot: String? = nil
    /// When true, the row renders flush (no card background/border) because an
    /// enclosing `ToolGroupRow` supplies the single contiguous container.
    var inGroup: Bool = false
    @State private var expanded = false

    private var scope: ToolSummaryScope { ToolSummaryScope(rawValue: toolSummaryScopeRaw) ?? .bashOnly }

    /// For a lone, AI-eligible call (Bash always; others only under the "all" scope, and
    /// then only with more than one call to merge — so in practice Bash) the model's rewrite
    /// once it lands, or a loading placeholder until then — exactly as a one-family group
    /// card shows. A lone call summarizes identically to a one-call group; it renders in its
    /// own card only for display. Nil for grouped per-call rows (they keep their literal
    /// detail), and when summarization is off or the family isn't eligible — those keep the
    /// existing "ToolName detail" / `searchSummary` look.
    private var aiSummaryLine: SummaryLine? {
        // Nested subagent rows keep their literal detail — their AFM lines are never computed
        // (only the top-level conversation is summarized), so a placeholder would never resolve.
        guard !inGroup, !inSubagent, summarizeToolCalls,
              let family = HeuristicSummary.families([call]).first,
              family.aiEligible(scope: scope) else { return nil }
        if family.isBash {
            // The block line is keyed by the block's stable id — for a lone call, its own id.
            return store.bashBlockLine(call.toolCallID).map(SummaryLine.ready) ?? .pending(verb: family.verb)
        }
        let signature = TranscriptProjection.toolGroupSignature([call])
        if let line = store.toolFamilyLines(signature)?.first { return .ready(line) }
        return .pending(verb: family.verb)
    }

    /// For a lone `Grep` rendered on its own (not inside a group card), the same
    /// "Searched for <pattern>" line the group headline uses — so a standalone search reads
    /// in plain language, with a regex alternation naturalized into "a, b, or c", rather
    /// than the raw "Grep <pattern>" tool label. Nil for other tools and for the raw per-call
    /// rows under an expanded group, which intentionally keep their literal detail.
    private var searchSummary: String? {
        guard !inGroup, call.name == "Grep" else { return nil }
        return HeuristicSummary.toolGroupLines([call], relativeTo: worktreeRoot)
            .first.map(HeuristicSummary.terminated)
    }

    /// The output text to reveal, or `nil` when there's nothing worth showing
    /// (no result yet, or an empty non-error result) — in which case the row
    /// isn't expandable and shows no disclosure chevron.
    private var outputText: String? {
        guard let result else { return nil }
        let text = TranscriptRow.resultText(result.content)
        if text.isEmpty && !result.isError { return nil }
        return text
    }

    /// The lock-lifecycle lines folded onto this call (LOCKING §4), newest last, or empty when the
    /// "Show lock events" setting is off — so the lock reads as part of the edit instead of as a
    /// separate row that sorts after the call.
    private var lockLines: [NoteLock] {
        showLockEvents ? (lockLinesByToolCall[call.toolCallID] ?? []) : []
    }

    var body: some View {
        let output = outputText
        let locks = lockLines
        let content = VStack(alignment: .leading, spacing: 0) {
            header(hasOutput: output != nil)
                .contentShape(Rectangle())
                .onTapGesture {
                    guard output != nil else { return }
                    withAnimation(.easeInOut(duration: 0.15)) { expanded.toggle() }
                }
            if !locks.isEmpty {
                VStack(alignment: .leading, spacing: 2) {
                    ForEach(Array(locks.enumerated()), id: \.offset) { _, lock in
                        LockLineRow(lock: lock)
                    }
                }
                .padding(.horizontal, 10)
                .padding(.bottom, 6)
            }
            if expanded, let output {
                Divider().overlay(palette.accent.opacity(0.14))
                resultBody(output)
            }
        }
        if inGroup {
            content  // the block draws the card; rows stay flush so they read as one
        } else {
            content
                .background(palette.accent.opacity(0.06))
                .overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(palette.accent.opacity(0.14), lineWidth: 1))
                .clipShape(RoundedRectangle(cornerRadius: 8))
        }
    }

    private func header(hasOutput: Bool) -> some View {
        HStack(spacing: 8) {
            Image(systemName: TranscriptRow.toolIcon(call.name))
                .font(.caption).foregroundStyle(palette.accent).frame(width: 15)
            if let aiSummaryLine {
                // An AI-eligible lone call (Bash) reads as its model rewrite once it lands,
                // matching the group card — with a loading placeholder until then so the
                // literal "Bash <command>" never flashes in for a line about to be rewritten.
                switch aiSummaryLine {
                case .ready(let text):
                    // A model rewrite may carry `code` spans (a command or symbol); render them
                    // as Markdown, matching the group card's summary lines.
                    Text(TranscriptRow.inlineMarkdown(text))
                        .font(.callout)
                        .foregroundStyle(AppTheme.primaryText)
                        .lineLimit(1).truncationMode(.middle)
                case .pending:
                    pendingPlaceholder
                }
            } else if let searchSummary {
                // A standalone search reads as the plain "Searched for …" line rather than
                // the "Grep <pattern>" tool label, matching the group card's phrasing.
                Text(searchSummary)
                    .font(.callout)
                    .foregroundStyle(AppTheme.primaryText)
                    .lineLimit(1).truncationMode(.middle)
            } else {
                Text(call.name).font(.callout.weight(.medium)).foregroundStyle(AppTheme.primaryText)
                if let detail = TranscriptRow.toolDetail(call, relativeTo: worktreeRoot) {
                    Text(detail)
                        .font(.system(.caption, design: .monospaced))
                        .foregroundStyle(.secondary)
                        .lineLimit(1).truncationMode(.middle)
                }
            }
            Spacer(minLength: 0)
            if !finished {
                ProgressView().controlSize(.small).scaleEffect(0.7)
            }
            if hasOutput {
                Image(systemName: "chevron.right")
                    .font(.caption2.weight(.semibold))
                    .foregroundStyle(.tertiary)
                    .rotationEffect(.degrees(expanded ? 90 : 0))
            }
        }
        .padding(.horizontal, 10).padding(.vertical, 6)
        // Cross-fade the headline when the heuristic placeholder is replaced by the AI line.
        .animation(.easeInOut(duration: 0.2), value: aiSummaryLine)
    }

    /// The loading stand-in for a lone summarized line whose AI text is still being
    /// generated: a muted bar (where the prose will land) plus a small spinner — the same
    /// look the group card uses, so a lone Bash call reads identically while it summarizes.
    private var pendingPlaceholder: some View {
        HStack(spacing: 7) {
            RoundedRectangle(cornerRadius: 4)
                .fill(.tertiary)
                .frame(width: 150, height: 10)
                .opacity(0.45)
            ProgressView().controlSize(.small).scaleEffect(0.6)
        }
    }

    @ViewBuilder
    private func resultBody(_ output: String) -> some View {
        let isError = result?.isError == true
        HStack(alignment: .top, spacing: 8) {
            Image(systemName: isError ? "exclamationmark.triangle.fill" : "arrow.turn.down.right")
                .font(.caption2)
                .foregroundStyle(isError ? AnyShapeStyle(palette.danger) : AnyShapeStyle(.tertiary))
                .frame(width: 15)
            if !isError, output.contains("```") {
                // Tool output that comes back as Markdown (fenced code blocks, tables)
                // renders formatted rather than as one flat monospaced blob.
                MarkdownText(markdown: output, bodySize: 12)
            } else {
                Text(output)
                    .font(.system(.caption, design: .monospaced))
                    .foregroundStyle(isError ? AnyShapeStyle(palette.danger) : AnyShapeStyle(.secondary))
                    .textSelection(.enabled)
            }
            Spacer(minLength: 0)
        }
        .padding(.horizontal, 10).padding(.vertical, 6)
    }
}

/// Pretty rendering of a *completed* `AskUserQuestion` tool call. Once the user has
/// answered, the raw "AskUserQuestion {…}" card reads as opaque JSON; instead we show each
/// question with the option the user chose (and that option's description when the answer
/// matches a known label — free-text "Other" answers won't, so they show none). The answers
/// are recovered from the persisted tool result (`AskUserQuestion.answers(in:for:)`), so the
/// card survives transcript replay across app restarts. Mirrors `AskUserQuestionBar`'s look —
/// the question prompt set quiet, the chosen answer emphasized — so the answered card reads as
/// the settled form of the picker the user filled in.
private struct AskUserQuestionResultCard: View {
    @Environment(\.appPalette) private var palette
    let questions: [AskUserQuestion.Question]
    /// Chosen option labels keyed by question text (more than one for a multi-select).
    let answers: [String: [String]]

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            HStack(spacing: 8) {
                Image(systemName: "questionmark.bubble")
                    .font(.caption).foregroundStyle(palette.accent).frame(width: 15)
                Text("You answered")
                    .font(.callout.weight(.medium)).foregroundStyle(AppTheme.primaryText)
                Spacer(minLength: 0)
            }
            ForEach(Array(questions.enumerated()), id: \.offset) { index, question in
                if index > 0 { Divider().overlay(palette.accent.opacity(0.12)) }
                questionRow(question)
            }
        }
        .padding(.horizontal, 12).padding(.vertical, 10)
        .frame(maxWidth: .infinity, alignment: .leading)
        .background(palette.accent.opacity(0.06))
        .overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(palette.accent.opacity(0.14), lineWidth: 1))
        .clipShape(RoundedRectangle(cornerRadius: 8))
    }

    @ViewBuilder
    private func questionRow(_ question: AskUserQuestion.Question) -> some View {
        VStack(alignment: .leading, spacing: 5) {
            if let header = question.header, !header.isEmpty {
                Text(header.uppercased())
                    .font(.caption2.weight(.semibold)).foregroundStyle(.secondary)
            }
            Text(question.question)
                .font(.callout).foregroundStyle(.secondary)
                .fixedSize(horizontal: false, vertical: true)
            if let chosen = answers[question.question], !chosen.isEmpty {
                ForEach(Array(chosen.enumerated()), id: \.offset) { _, label in
                    answerRow(label, description: description(of: label, in: question))
                }
            } else {
                // An answered card only shows for questions that parsed an answer; this is the
                // honest fallback if one question in a multi-question ask somehow didn't.
                Text("No answer recorded")
                    .font(.callout).italic().foregroundStyle(.tertiary)
            }
        }
    }

    private func answerRow(_ label: String, description: String?) -> some View {
        HStack(alignment: .top, spacing: 7) {
            Image(systemName: "checkmark.circle.fill")
                .font(.caption).foregroundStyle(palette.accent)
            VStack(alignment: .leading, spacing: 2) {
                Text(label)
                    .font(.callout.weight(.semibold)).foregroundStyle(AppTheme.primaryText)
                    .fixedSize(horizontal: false, vertical: true)
                    .textSelection(.enabled)
                if let description, !description.isEmpty {
                    Text(description)
                        .font(.caption).foregroundStyle(.secondary)
                        .fixedSize(horizontal: false, vertical: true)
                }
            }
            Spacer(minLength: 0)
        }
    }

    /// The chosen option's description, when the answer matches a known option label.
    private func description(of label: String, in question: AskUserQuestion.Question) -> String? {
        question.options.first { $0.label == label }?.description
    }
}

/// One line in a tool-call block's collapsed headline: a finished line — the deterministic
/// merge for a non-summarized family, or the model's rewrite once it lands — or a placeholder
/// shown while a summarized family's AI line is still being generated, so the heuristic never
/// flashes in for a call that's about to be rewritten.
private enum SummaryLine: Equatable {
    case ready(String)
    case pending(verb: String)
}

/// A run of consecutive tool calls rendered as ONE contiguous rounded card. Same-tool
/// calls are always folded into one line ("Read a, b from dir/"), whatever the AI toggle;
/// with "Summarize tool calls with AI" on, eligible lines are rephrased by the on-device
/// model once they land. Each line carries a glyph for its tool and sits on its own row
/// so the block reads cleanly. A long block shows the first five lines with a "See more";
/// tapping the block (or "See more") reveals every individual call, each with its output.
private struct ToolGroupRow: View {
    @Environment(\.appPalette) private var palette
    @Environment(AppStore.self) private var store
    @Environment(\.transcriptInSubagent) private var inSubagent
    @Environment(\.lockLinesByToolCall) private var lockLinesByToolCall
    @AppStorage(TranscriptDisplay.summarizeToolCallsKey) private var summarizeToolCalls = true
    @AppStorage(TranscriptDisplay.showLockEventsKey) private var showLockEvents = true
    @AppStorage(TranscriptDisplay.toolSummaryScopeKey)
    private var toolSummaryScopeRaw = ToolSummaryScope.bashOnly.rawValue
    let signature: Int
    let calls: [(call: ToolCall, result: ToolResult?, finished: Bool)]
    @State private var blockExpanded = false

    /// Which families the AI rewrites — mirrors the store's scope so the view knows which
    /// lines are summarized (and thus shown as a loading placeholder until their AI line
    /// lands) versus a plain deterministic merge shown straight away.
    private var scope: ToolSummaryScope { ToolSummaryScope(rawValue: toolSummaryScopeRaw) ?? .bashOnly }

    /// A collapsed block shows at most this many summary lines before a "See more".
    private static let collapsedLineLimit = 5

    private var allFinished: Bool { calls.allSatisfy(\.finished) }
    private var worktreeRoot: String? { store.openSession?.worktreePath }

    /// The block's headline lines. With AI off, the deterministic heuristic merge of the
    /// whole run (read-then-edit folds included). With AI on, one entry per tool family in
    /// run order: a non-summarized family (Read, or anything outside the scope) shows its
    /// plain merge straight away, while a summarized family (Bash always; others under the
    /// "all" scope) shows its model line once it lands — and a loading placeholder until
    /// then, so the heuristic never flashes in for a call that's about to be rewritten.
    private var displayLines: [SummaryLine] {
        let rawCalls = calls.map(\.call)
        // Off, or nested inside a subagent (whose inner calls are never AFM-summarized): show
        // the deterministic heuristic merge straight away, with no pending placeholders.
        guard summarizeToolCalls, !inSubagent else {
            return HeuristicSummary.toolGroupLines(rawCalls, relativeTo: worktreeRoot).map { .ready($0) }
        }
        var out: [SummaryLine] = []
        for family in HeuristicSummary.families(rawCalls) {
            if !family.aiEligible(scope: scope) {
                out.append(.ready(HeuristicSummary.line(for: family, relativeTo: worktreeRoot)))
            } else if family.isBash {
                // Bash's live line is keyed by the block's stable id (its first call's id).
                if let first = family.calls.first, let line = store.bashBlockLine(first.toolCallID) {
                    out.append(.ready(line))
                } else {
                    out.append(.pending(verb: family.verb))
                }
            } else {
                let familySignature = TranscriptProjection.toolGroupSignature(family.calls)
                if let lines = store.toolFamilyLines(familySignature), !lines.isEmpty {
                    out.append(contentsOf: lines.map { SummaryLine.ready($0) })
                } else {
                    out.append(.pending(verb: family.verb))
                }
            }
        }
        return out
    }

    /// When the block *leads* with a sub-agent spawn (`Task`/`Agent`), the short
    /// `description` the parent gave it. Surfaced as an emphasized headline so a
    /// collapsed agent block says what the subagent was sent to do rather than a
    /// generic "Ran a subagent".
    private var agentPurpose: String? {
        guard let first = calls.first?.call, first.name == "Task" || first.name == "Agent"
        else { return nil }
        let purpose = (first.input["description"]?.stringValue ?? "")
            .trimmingCharacters(in: .whitespacesAndNewlines)
        return purpose.isEmpty ? nil : purpose
    }

    /// Summary lines beneath the headline. When an agent-purpose headline is shown it
    /// replaces the generic sub-agent line, so drop that from the body to avoid a
    /// redundant "Ran a subagent" right under the purpose.
    private var bodyLines: [SummaryLine] {
        guard agentPurpose != nil else { return displayLines }
        return displayLines.filter { line in
            if case .ready(let text) = line, text == HeuristicSummary.subagentVerb { return false }
            return true
        }
    }

    /// Lines actually shown: all of them when expanded, else capped to the collapsed
    /// limit, with the remainder reached via "See more".
    private var displayedLines: [SummaryLine] {
        let lines = bodyLines
        guard !blockExpanded, lines.count > Self.collapsedLineLimit else { return lines }
        return Array(lines.prefix(Self.collapsedLineLimit))
    }

    private var hiddenLineCount: Int { max(0, bodyLines.count - Self.collapsedLineLimit) }

    var body: some View {
        VStack(alignment: .leading, spacing: 0) {
            header
                .contentShape(Rectangle())
                .onTapGesture {
                    withAnimation(.easeInOut(duration: 0.15)) { blockExpanded.toggle() }
                }
            if blockExpanded {
                ForEach(Array(calls.enumerated()), id: \.element.call.toolCallID) { _, entry in
                    Divider().overlay(palette.accent.opacity(0.14))
                    ToolCallRow(call: entry.call, result: entry.result,
                                finished: entry.finished, worktreeRoot: worktreeRoot, inGroup: true)
                }
                // A footer collapse control so a long, scrolled-through block can be folded
                // back without hunting for the header at the top.
                Divider().overlay(palette.accent.opacity(0.14))
                collapseFooter
            }
        }
        .background(palette.accent.opacity(0.06))
        .overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(palette.accent.opacity(0.14), lineWidth: 1))
        .clipShape(RoundedRectangle(cornerRadius: 8))
    }

    private var header: some View {
        VStack(alignment: .leading, spacing: 7) {
            // A block led by a sub-agent reads as "Agent: <purpose>", emphasized and a
            // touch larger than the activity lines, even collapsed.
            if let agentPurpose {
                HStack(alignment: .firstTextBaseline, spacing: 10) {
                    Image(systemName: TranscriptRow.toolIcon(calls[0].call.name))
                        .font(.body)
                        .foregroundStyle(palette.accent)
                        .frame(width: 18)
                    Text("Agent: \(agentPurpose)")
                        .font(.body.weight(.semibold))
                        .foregroundStyle(AppTheme.primaryText)
                        .lineLimit(2)
                        .truncationMode(.tail)
                        .fixedSize(horizontal: false, vertical: true)
                    Spacer(minLength: 6)
                    trailingControls
                }
            }
            ForEach(Array(displayedLines.enumerated()), id: \.offset) { index, line in
                summaryLineRow(line, index: index)
            }
            if !blockExpanded, hiddenLineCount > 0 { seeMore }
        }
        .padding(.horizontal, 12).padding(.vertical, 10)
        // Cross-fade the headline when the heuristic lines are replaced by the AFM summary.
        .animation(.easeInOut(duration: 0.2), value: displayedLines)
    }

    /// One headline row: a finished line (glyph + prose) or, for a summarized family whose
    /// AI line hasn't landed yet, a subtle loading placeholder (glyph + shimmer bar + spinner)
    /// so the heuristic never flashes in first. The count + chevron pin to the first row.
    @ViewBuilder
    private func summaryLineRow(_ line: SummaryLine, index: Int) -> some View {
        HStack(alignment: .firstTextBaseline, spacing: 9) {
            switch line {
            case .ready(let text):
                Image(systemName: Self.icon(for: text))
                    .font(.caption)
                    .foregroundStyle(palette.accent)
                    .frame(width: 16)
                Text(Self.styledLine(text))
                    .font(.callout)
                    .foregroundStyle(AppTheme.primaryText)
                    .lineLimit(2)
                    .truncationMode(.middle)
                    .fixedSize(horizontal: false, vertical: true)
            case .pending(let verb):
                Image(systemName: Self.icon(for: verb))
                    .font(.caption)
                    .foregroundStyle(palette.accent)
                    .frame(width: 16)
                pendingPlaceholder
            }
            Spacer(minLength: 6)
            // Pin the count + chevron to the headline: the purpose row carries them
            // when present, otherwise the first summary line does.
            if agentPurpose == nil, index == 0 { trailingControls }
        }
    }

    /// The loading stand-in for a summarized line whose AI text is still being generated:
    /// a muted bar (where the prose will land) plus a small spinner.
    private var pendingPlaceholder: some View {
        HStack(spacing: 7) {
            RoundedRectangle(cornerRadius: 4)
                .fill(.tertiary)
                .frame(width: 150, height: 10)
                .opacity(0.45)
            ProgressView().controlSize(.small).scaleEffect(0.6)
        }
    }

    /// The lock state of any call in the block that is currently waiting/held (newest wins), so a
    /// collapsed block can flag at a glance that it holds a file lock. `nil` when nothing is locked,
    /// every lock has been released, or the "Show lock events" setting is off — expand to see which
    /// calls and paths (each per-call row carries its own lock line).
    private var blockLockState: NoteLock.State? {
        guard showLockEvents else { return nil }
        for entry in calls.reversed() {
            if let state = lockLinesByToolCall[entry.call.toolCallID]?.last?.state, state != .released {
                return state
            }
        }
        return nil
    }

    private var trailingControls: some View {
        HStack(spacing: 6) {
            // A held/waiting lock anywhere in the block shows as a small glyph, collapsed.
            if let lockState = blockLockState {
                Image(systemName: LockLineStyle.icon(lockState))
                    .font(.caption2)
                    .foregroundStyle(.tertiary)
            }
            // The call count makes the block's collapsed scope obvious at a glance.
            Text("\(calls.count)")
                .font(.caption2.weight(.semibold).monospacedDigit())
                .foregroundStyle(.secondary)
            if !allFinished {
                ProgressView().controlSize(.small).scaleEffect(0.7)
            }
            Image(systemName: "chevron.right")
                .font(.caption2.weight(.semibold))
                .foregroundStyle(.tertiary)
                .rotationEffect(.degrees(blockExpanded ? 90 : 0))
        }
    }

    private var collapseFooter: some View {
        Button {
            withAnimation(.easeInOut(duration: 0.15)) { blockExpanded = false }
        } label: {
            HStack(spacing: 6) {
                Image(systemName: "chevron.up")
                    .font(.caption2.weight(.semibold))
                Text("Collapse")
                    .font(.caption.weight(.medium))
                Spacer(minLength: 0)
            }
            .foregroundStyle(palette.accent)
            .contentShape(Rectangle())
        }
        .buttonStyle(.plain)
        .padding(.horizontal, 12).padding(.vertical, 4)
    }

    private var seeMore: some View {
        Button {
            withAnimation(.easeInOut(duration: 0.15)) { blockExpanded = true }
        } label: {
            Text("See \(hiddenLineCount) more…")
                .font(.caption.weight(.medium))
                .foregroundStyle(palette.accent)
        }
        .buttonStyle(.plain)
        .padding(.leading, 25)  // align with the text column, past the icon gutter
    }

    /// A merged headline as styled text. For a file-family line (Read/Edited/Wrote …) the
    /// path portion is set in the same monospaced, secondary style the per-call rows give
    /// "Read <path>", so the path stands apart from the verb at a glance. Lines without a
    /// file path — Ran/Searched/Fetched and any AI-rewritten prose — render plain.
    ///
    /// Only the filenames, paths, and directories themselves take the monospaced run; the
    /// connective punctuation the merge inserts between them — the commas, the "; " between
    /// directory groups, the " from " before a shared directory, and the terminating period
    /// — stays in the row's regular prose font so it doesn't read as part of a filename.
    private static func styledLine(_ line: String) -> AttributedString {
        guard let (verb, path) = HeuristicSummary.filePathBody(of: line) else {
            // Ran/Searched/Fetched and any model-rewritten prose: no path to set apart, but the
            // line may carry `code` spans (a command, flag, or symbol) — render them as Markdown.
            return TranscriptRow.inlineMarkdown(HeuristicSummary.terminated(line))
        }
        var result = AttributedString(verb + " ")
        result.append(styledPathBody(path))
        // The terminating period rides outside the monospaced run, as plain punctuation.
        if let last = path.last, !".!?…".contains(last) {
            result.append(AttributedString("."))
        }
        return result
    }

    /// Styles a merged file-path body so only the paths render monospaced/secondary while
    /// the connective tissue the merge inserts stays in the regular prose font: groups are
    /// joined by "; ", filenames within a group by ", ", a shared directory is introduced by
    /// " from ", and an overflow count trails as " +N more". See `mergeFilePaths`.
    private static func styledPathBody(_ body: String) -> AttributedString {
        // Peel off a trailing "+N more" overflow tag — it's a count, not a path.
        var core = body
        var overflow = ""
        if let range = core.range(of: #" \+\d+ more$"#, options: .regularExpression) {
            overflow = String(core[range])
            core = String(core[..<range.lowerBound])
        }
        var result = AttributedString()
        for (groupIndex, group) in core.components(separatedBy: "; ").enumerated() {
            if groupIndex > 0 { result.append(AttributedString("; ")) }
            for (itemIndex, item) in group.components(separatedBy: ", ").enumerated() {
                if itemIndex > 0 { result.append(AttributedString(", ")) }
                // The last filename of a multi-file group may carry a " from dir/" tail;
                // monospace the filename and the directory, but not the " from " between.
                if let from = item.range(of: " from ") {
                    result.append(monospacedPath(String(item[..<from.lowerBound])))
                    result.append(AttributedString(" from "))
                    result.append(monospacedPath(String(item[from.upperBound...])))
                } else {
                    result.append(monospacedPath(item))
                }
            }
        }
        if !overflow.isEmpty { result.append(AttributedString(overflow)) }
        return result
    }

    /// A path token in the monospaced, secondary style the per-call rows use for "Read <path>".
    private static func monospacedPath(_ path: String) -> AttributedString {
        var run = AttributedString(path)
        run.font = .system(.caption, design: .monospaced)
        run.foregroundColor = .secondary
        return run
    }

    /// A per-line glyph from the line's leading verb (Read / Edited / Ran / Searched …),
    /// so each merged line is identifiable at a glance. Works for heuristic and AI lines
    /// alike — the model is told to lead with the same verbs.
    private static func icon(for line: String) -> String {
        // A combined "Read, Edited …" / "Read, Wrote …" line is a modification at heart —
        // show the edit glyph rather than the leading Read's.
        if line.hasPrefix("Read, ") { return "pencil" }
        let verb = line.split(separator: " ").first.map { $0.lowercased() } ?? ""
        switch verb {
        case "read": return "doc.text"
        case "edited", "wrote", "created": return "pencil"
        case "ran": return "terminal"
        case "searched", "listed", "grepped", "found": return "magnifyingglass"
        case "fetched": return "globe"
        default: return "square.stack.3d.up"
        }
    }
}

/// Extra vertical breathing room around non-response rows (tool calls, thinking,
/// and pass-through events). It sets these apart from the agent's actual prose,
/// which renders tight, so real responses are visually obvious in the transcript.
private struct NonResponsePadding: ViewModifier {
    func body(content: Content) -> some View {
        content.padding(.vertical, 8)
    }
}

// MARK: - Subagent & multi-agent cards
//
// Claude Code spawns subagents through the `Task` tool. Nucleic surfaces that delegated
// work in the orchestra gold so it reads distinctly from the agent's own tool calls: a
// lone `Task` becomes a `SubagentCard`, and a parallel wave of them becomes an
// `OrchestrationCard`. Both build entirely from the existing tool-call/result events
// (`ToolCall.parentToolCallID` already links a subagent's inner calls to its parent), so
// no protocol or projection changes are needed.

/// The lifecycle state of a spawned subagent, read from its `Task` tool result: no result
/// yet → still working; an error result → failed; otherwise → done.
private enum SubagentRunState {
    case running, done, failed

    init(result: ToolResult?) {
        guard let result else { self = .running; return }
        self = result.isError ? .failed : .done
    }

    var label: String {
        switch self {
        case .running: "Working"
        case .done: "Done"
        case .failed: "Failed"
        }
    }

    /// Status tint: done/failed reuse the shared palette so they match the rest of the app;
    /// a still-running subagent glows in the orchestra gold.
    func color(_ palette: AppPalette) -> Color {
        switch self {
        case .running: AppTheme.orchestra
        case .done: palette.success
        case .failed: palette.danger
        }
    }
}

/// Pulls the human-facing bits out of a `Task` tool call so the subagent cards agree on what
/// to show.
private enum Subagent {
    /// The subagent flavor (`subagent_type`: "Explore", "general-purpose", …), or nil.
    static func type(_ call: ToolCall) -> String? {
        let raw = call.input["subagent_type"]?.stringValue?
            .trimmingCharacters(in: .whitespacesAndNewlines)
        return (raw?.isEmpty == false) ? raw : nil
    }

    /// The one-line task the parent handed the subagent (`description`), falling back to the
    /// full `prompt`, then a generic label.
    static func taskLabel(_ call: ToolCall) -> String {
        if let description = call.input["description"]?.stringValue?
            .trimmingCharacters(in: .whitespacesAndNewlines), !description.isEmpty {
            return description
        }
        if let prompt = call.input["prompt"]?.stringValue?
            .trimmingCharacters(in: .whitespacesAndNewlines), !prompt.isEmpty {
            return prompt
        }
        return "Subagent task"
    }

    /// "Explore — find the composer", joining the flavor and task for a compact one-liner.
    static func headline(_ call: ToolCall) -> String {
        let label = taskLabel(call)
        if let type = type(call) { return "\(type) — \(label)" }
        return label
    }

    /// The subagent's returned report, or nil while it's still working / reported nothing.
    static func report(_ result: ToolResult?) -> String? {
        guard let result else { return nil }
        let text = TranscriptRow.resultText(result.content)
        return text.isEmpty ? nil : text
    }
}

/// A small leading status glyph for a subagent — a gold spinner while it works, a green
/// check or red cross once it settles.
private struct SubagentStatusGlyph: View {
    @Environment(\.appPalette) private var palette
    let state: SubagentRunState

    var body: some View {
        Group {
            switch state {
            case .running: ProgressView().controlSize(.mini).tint(AppTheme.orchestra)
            case .done: Image(systemName: "checkmark.circle.fill").foregroundStyle(palette.success)
            case .failed: Image(systemName: "xmark.octagon.fill").foregroundStyle(palette.danger)
            }
        }
        .font(.caption)
        .frame(width: 16)
    }
}

/// A status pill (spinner / check / cross + word) for the header of a subagent card.
private struct SubagentStatusChip: View {
    @Environment(\.appPalette) private var palette
    let state: SubagentRunState

    var body: some View {
        HStack(spacing: 4) {
            SubagentStatusGlyph(state: state).frame(width: 14)
            Text(state.label)
        }
        .font(.caption2.weight(.medium))
        .foregroundStyle(state.color(palette))
        .padding(.horizontal, 7).padding(.vertical, 3)
        .background(state.color(palette).opacity(0.14), in: Capsule())
    }
}

/// One delegated subagent (`Task`), as a gold card: its type and the task it was given, a
/// live Working / Done / Failed status, and — once it reports back — its findings, collapsed
/// by default. Mirrors the other transcript cards (GitBlockCard et al.) in the orchestra hue.
private struct SubagentCard: View {
    @Environment(\.appPalette) private var palette
    let call: ToolCall
    let result: ToolResult?
    let finished: Bool
    /// The subagent's own activity — its inner Read/Bash/Edit/thinking/prose — nested here so
    /// the card can show what it actually did, on expand.
    let children: [TranscriptItem]
    @State private var expanded = false

    private let accent = AppTheme.orchestra
    private var state: SubagentRunState { SubagentRunState(result: result) }
    private var report: String? { Subagent.report(result) }
    /// Expandable once there's something to reveal: a captured activity stream, or (for a
    /// replayed/legacy transcript with none) the subagent's returned report.
    private var hasDetail: Bool { !children.isEmpty || report != nil }

    var body: some View {
        VStack(alignment: .leading, spacing: 0) {
            header
                .contentShape(Rectangle())
                .onTapGesture {
                    guard hasDetail else { return }
                    withAnimation(.easeInOut(duration: 0.15)) { expanded.toggle() }
                }
            if expanded, hasDetail {
                Divider().overlay(accent.opacity(0.2))
                SubagentActivityStream(children: children, fallbackReport: report)
            }
        }
        .background(accent.opacity(0.08))
        .overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(accent.opacity(0.22), lineWidth: 1))
        .clipShape(RoundedRectangle(cornerRadius: 8))
    }

    private var header: some View {
        VStack(alignment: .leading, spacing: 6) {
            HStack(alignment: .firstTextBaseline, spacing: 10) {
                Image(systemName: "person.2.fill")
                    .font(.body)
                    .foregroundStyle(accent)
                    .frame(width: 18)
                (Text("Subagent")
                    + (Subagent.type(call).map { Text(" · \($0)").foregroundColor(.secondary) } ?? Text("")))
                    .font(.body.weight(.semibold))
                    .foregroundStyle(AppTheme.primaryText)
                Spacer(minLength: 6)
                SubagentStatusChip(state: state)
                if hasDetail {
                    Image(systemName: expanded ? "chevron.up" : "chevron.down")
                        .font(.caption2).foregroundStyle(.secondary)
                }
            }
            Text(Subagent.taskLabel(call))
                .font(.callout)
                .foregroundStyle(AppTheme.primaryText)
                .lineLimit(expanded ? nil : 2)
                .truncationMode(.tail)
                .fixedSize(horizontal: false, vertical: true)
        }
        .padding(.horizontal, 12).padding(.vertical, 10)
    }
}

/// A subagent's nested activity — the calls, thinking, and prose it produced — rendered with
/// the same row views the main transcript uses, but flagged `transcriptInSubagent` so inner
/// tool lines stay literal (the AFM summaries are only computed for the top-level conversation,
/// so a nested Bash line shows its command straight away rather than a spinner that never
/// resolves). Nested subagents recurse into their own cards.
private struct SubagentActivityStream: View {
    let children: [TranscriptItem]
    /// Shown when there's no captured inner activity (a replayed/legacy transcript, or an agent
    /// that hasn't streamed anything yet) but the subagent returned a final report.
    var fallbackReport: String? = nil

    var body: some View {
        if !children.isEmpty {
            VStack(alignment: .leading, spacing: 2) {
                ForEach(children) { child in
                    TranscriptRow(item: child)
                }
            }
            .environment(\.transcriptInSubagent, true)
            .padding(.horizontal, 12).padding(.vertical, 6)
            .frame(maxWidth: .infinity, alignment: .leading)
        } else if let fallbackReport {
            Text(fallbackReport)
                .font(.callout)
                .foregroundStyle(AppTheme.primaryText)
                .textSelection(.enabled)
                .frame(maxWidth: .infinity, alignment: .leading)
                .padding(.horizontal, 12).padding(.vertical, 10)
        }
    }
}

/// A multi-agent fan-out: a wave of two or more subagents (`Task` calls) spawned together,
/// the visual centerpiece of orchestra's orchestration. The header sums up the wave (count +
/// overall status); each subagent is its own row — a one-line status that expands
/// independently to reveal that agent's live activity stream and report. Gold throughout to
/// mark it as orchestration work.
private struct OrchestrationCard: View {
    @Environment(\.appPalette) private var palette
    let calls: [ToolEntry]

    private let accent = AppTheme.orchestra

    /// Overall wave status: failed if any subagent failed, else running if any is still
    /// working, else done.
    private var overall: SubagentRunState {
        let states = calls.map { SubagentRunState(result: $0.result) }
        if states.contains(where: { if case .failed = $0 { true } else { false } }) { return .failed }
        if states.contains(where: { if case .running = $0 { true } else { false } }) { return .running }
        return .done
    }

    private var doneCount: Int { calls.filter { $0.result != nil }.count }

    var body: some View {
        VStack(alignment: .leading, spacing: 0) {
            header
            ForEach(Array(calls.enumerated()), id: \.element.call.toolCallID) { _, entry in
                Divider().overlay(accent.opacity(0.18))
                OrchestrationAgentRow(entry: entry)
            }
        }
        .background(accent.opacity(0.08))
        .overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(accent.opacity(0.22), lineWidth: 1))
        .clipShape(RoundedRectangle(cornerRadius: 8))
    }

    private var header: some View {
        HStack(alignment: .firstTextBaseline, spacing: 10) {
            Image(systemName: "rectangle.3.group.fill")
                .font(.body)
                .foregroundStyle(accent)
                .frame(width: 18)
            VStack(alignment: .leading, spacing: 1) {
                Text("Multiple Subagents")
                    .font(.body.weight(.semibold))
                    .foregroundStyle(AppTheme.primaryText)
                Text("\(calls.count) subagents · \(doneCount) done")
                    .font(.caption)
                    .foregroundStyle(.secondary)
            }
            Spacer(minLength: 6)
            SubagentStatusChip(state: overall)
        }
        .padding(.horizontal, 12).padding(.vertical, 10)
    }
}

/// One subagent within a fan-out: a one-line status header (glyph + "Type — task") that
/// expands on tap to reveal that agent's own activity stream — the Read/Bash/Edit/thinking it
/// produced — and, failing that, its returned report. Each row tracks its own expansion so
/// agents open independently.
private struct OrchestrationAgentRow: View {
    let entry: ToolEntry
    @State private var expanded = false

    private let accent = AppTheme.orchestra
    private var state: SubagentRunState { SubagentRunState(result: entry.result) }
    private var report: String? { Subagent.report(entry.result) }
    private var hasDetail: Bool { !entry.children.isEmpty || report != nil }

    var body: some View {
        VStack(alignment: .leading, spacing: 0) {
            header
                .contentShape(Rectangle())
                .onTapGesture {
                    guard hasDetail else { return }
                    withAnimation(.easeInOut(duration: 0.15)) { expanded.toggle() }
                }
            if expanded, hasDetail {
                SubagentActivityStream(children: entry.children, fallbackReport: report)
            }
        }
    }

    private var header: some View {
        HStack(alignment: .firstTextBaseline, spacing: 9) {
            SubagentStatusGlyph(state: state)
            Text(Subagent.headline(entry.call))
                .font(.callout)
                .foregroundStyle(AppTheme.primaryText)
                .lineLimit(1)
                .truncationMode(.tail)
            Spacer(minLength: 6)
            if hasDetail {
                Image(systemName: expanded ? "chevron.up" : "chevron.down")
                    .font(.caption2).foregroundStyle(.secondary)
            }
        }
        .padding(.horizontal, 12).padding(.vertical, 8)
    }
}
