/**
 * @file RunConsole.tsx
 * @description The run console: everything that renders one run's live
 * conversation and drives its next turn. Moved verbatim out of `pages/Run.tsx`
 * (where it was `RunSession`) so the Run page and the Workspace page can both
 * mount the same console.
 *
 * Three pieces live here:
 *   - the envelope stream — user turns, assistant markdown, thinking, tool
 *     uses and tool results, plus the result footer;
 *   - the token / context-window meter rolled up from the envelope log;
 *   - the prompt editor with its `/` slash-command and `@` file autocomplete.
 *
 * Props only: no API call except the `@`-file lookup the editor already owned,
 * and no stream subscription — `envelopes` arrives as a prop, so the page keeps
 * `useRunStream` and both pages share one subscription per run.
 *
 * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
 */

import { useEffect, useMemo, useRef, useState } from "react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
  Play,
  Square,
  Send,
  RefreshCw,
  Sparkles,
  Terminal,
  CheckCircle2,
  XCircle,
  Clock,
  ExternalLink,
  Plus,
  AtSign,
  Slash as SlashIcon,
  FileCode,
} from "lucide-react";
import { api } from "../../lib/api";
import type { RunHandle, RunMode } from "../../lib/api";
import { MarkdownContent } from "../conversation/MarkdownContent";
import type {
  AssistantMessage,
  ContentBlock,
  Envelope,
  ResultEnvelope,
  SystemInit,
  UserMessage,
} from "../../hooks/useRunStream";

// ── Token / context-window meter ──────────────────────────────────────

interface TokenStats {
  inputTokens: number;
  outputTokens: number;
  cacheReadTokens: number;
  cacheCreationTokens: number;
  costUsd: number | null;
  contextWindow: number | null;
}

const DEFAULT_CONTEXT_WINDOW = 200_000;

/**
 * Roll up token usage from the in-memory envelope log. Pulls the latest
 * `usage` block from `stream_event/message_delta` events (live numbers
 * during streaming) and the canonical `result.usage` envelope when the run
 * finishes. The 1M-context Opus variants emit `contextWindow` in
 * `result.modelUsage`; we surface that to size the meter correctly.
 */
function computeTokens(envelopes: Envelope[]): TokenStats {
  // Per-turn rolling counters (overwritten as each new turn's message_start
  // arrives). The latest message_start's input + cache numbers reflect the
  // current turn's prompt size, which is the right thing to show in the
  // "Context" gauge.
  let inputTokens = 0;
  let cacheReadTokens = 0;
  let cacheCreationTokens = 0;
  // Output is summed across all completed turns plus the running current
  // turn - claude reports output_tokens as a per-turn (per-message) number,
  // not cumulative. Without summing, the meter resets every time a new
  // `message_start` arrives.
  let completedOutputTokens = 0;
  let currentTurnOutput = 0;
  let costUsd: number | null = null;
  let contextWindow: number | null = null;
  let sawMessageStart = false;
  // While we don't have an authoritative output count from message_delta /
  // result, estimate from the char count in the streaming assistant block
  // so the meter ticks live as text appears (claude doesn't emit usage on
  // every text_delta).
  let outputAuthoritativeForCurrent = false;
  let streamingChars = 0;

  const commitTurn = () => {
    completedOutputTokens += currentTurnOutput;
    currentTurnOutput = 0;
    outputAuthoritativeForCurrent = false;
    streamingChars = 0;
  };

  for (const env of envelopes) {
    const e = env as { type?: string };
    if (e.type === "stream_event") {
      const ev = (
        env as {
          event?: {
            type?: string;
            usage?: Record<string, number>;
            message?: { usage?: Record<string, number> };
          };
        }
      ).event;
      if (!ev) continue;
      if (ev.type === "message_start") {
        // Roll the previous turn's running output into the cumulative total
        // before resetting for this new turn.
        if (sawMessageStart) commitTurn();
        sawMessageStart = true;
        const u = ev.message?.usage;
        if (u) {
          inputTokens = u.input_tokens ?? 0;
          cacheReadTokens = u.cache_read_input_tokens ?? 0;
          cacheCreationTokens = u.cache_creation_input_tokens ?? 0;
          currentTurnOutput = u.output_tokens ?? 0;
        }
      } else if (ev.type === "message_delta") {
        const u = ev.usage;
        if (u && typeof u.output_tokens === "number") {
          // Authoritative running output for the current turn.
          currentTurnOutput = u.output_tokens;
          outputAuthoritativeForCurrent = true;
        }
      }
    } else if (e.type === "result") {
      const r = env as ResultEnvelope & {
        modelUsage?: Record<
          string,
          {
            contextWindow?: number;
            inputTokens?: number;
            outputTokens?: number;
            cacheReadInputTokens?: number;
            cacheCreationInputTokens?: number;
          }
        >;
      };
      // Result is end-of-run: commit any in-flight current turn first.
      if (currentTurnOutput > 0) {
        completedOutputTokens += currentTurnOutput;
        currentTurnOutput = 0;
        outputAuthoritativeForCurrent = false;
      }
      if (typeof r.total_cost_usd === "number") costUsd = r.total_cost_usd;
      if (r.modelUsage && typeof r.modelUsage === "object") {
        for (const m of Object.values(r.modelUsage)) {
          if (!m || typeof m !== "object") continue;
          if (typeof m.contextWindow === "number") contextWindow = m.contextWindow;
          // Prefer modelUsage's per-model totals when available - these are
          // the canonical per-run numbers.
          if (typeof m.inputTokens === "number") inputTokens = m.inputTokens;
          if (typeof m.cacheReadInputTokens === "number") cacheReadTokens = m.cacheReadInputTokens;
          if (typeof m.cacheCreationInputTokens === "number")
            cacheCreationTokens = m.cacheCreationInputTokens;
          if (typeof m.outputTokens === "number") {
            // modelUsage.outputTokens is the run total for this model - use
            // it as the canonical cumulative output, replacing our running
            // sum.
            completedOutputTokens = m.outputTokens;
          }
        }
      }
    } else if (e.type === "system" && (env as SystemInit).model) {
      // Heuristic: 1M Opus has [1m] in the model id
      const model = (env as SystemInit).model || "";
      if (/\[1m\]/i.test(model)) contextWindow = 1_000_000;
    } else if (e.type === "assistant") {
      const msg = (
        env as {
          message?: {
            _streaming?: boolean;
            content?: ContentBlock[];
            usage?: {
              input_tokens?: number;
              output_tokens?: number;
              cache_read_input_tokens?: number;
              cache_creation_input_tokens?: number;
            };
          };
        }
      ).message;
      if (msg?._streaming) {
        streamingChars = 0;
        const blocks = msg.content || [];
        for (const b of blocks) {
          if (b.type === "text") {
            streamingChars += ((b as { text?: string }).text || "").length;
          } else if (b.type === "thinking") {
            streamingChars += ((b as { thinking?: string }).thinking || "").length;
          }
        }
      } else if (msg?.usage) {
        // Transcript-derived seed envelopes carry usage but have no
        // `message.id` (transcriptToEnvelopes doesn't set one). Live-stream
        // canonical envelopes always have an id assigned by message_start,
        // and their tokens are already counted via stream_event / commitTurn
        // - folding them here would double-count. Use id-presence as the
        // discriminator: no id → transcript-seeded → fold; id → live → skip.
        const hasId = !!(msg as { id?: string }).id;
        if (!hasId) {
          const u = msg.usage;
          if (typeof u.input_tokens === "number") inputTokens = u.input_tokens;
          if (typeof u.cache_read_input_tokens === "number") {
            cacheReadTokens = u.cache_read_input_tokens;
          }
          if (typeof u.cache_creation_input_tokens === "number") {
            cacheCreationTokens = u.cache_creation_input_tokens;
          }
          if (typeof u.output_tokens === "number") {
            completedOutputTokens += u.output_tokens;
          }
        }
      }
    }
  }

  // While we don't have an authoritative output count for the current turn,
  // surface the char-based estimate so the meter ticks live during streaming.
  if (!outputAuthoritativeForCurrent && streamingChars > 0) {
    const estimate = Math.ceil(streamingChars / 4);
    if (estimate > currentTurnOutput) currentTurnOutput = estimate;
  }

  return {
    inputTokens,
    outputTokens: completedOutputTokens + currentTurnOutput,
    cacheReadTokens,
    cacheCreationTokens,
    costUsd,
    contextWindow,
  };
}

function formatNum(n: number): string {
  if (n < 1000) return String(n);
  if (n < 100_000) return (n / 1000).toFixed(1) + "k";
  if (n < 1_000_000) return Math.round(n / 1000) + "k";
  return (n / 1_000_000).toFixed(2) + "M";
}

function TokenMeter({ stats }: { stats: TokenStats }) {
  const { t } = useTranslation("run");
  const total = stats.inputTokens + stats.cacheReadTokens + stats.cacheCreationTokens;
  const cap = stats.contextWindow ?? DEFAULT_CONTEXT_WINDOW;
  const pct = Math.min(100, Math.round((total / cap) * 100));
  // Colour is the whole warning mechanism here - the meter is one status line,
  // so there is no room for a bar plus five labelled figures.
  const tone =
    pct >= 95 ? "text-status-danger" : pct >= 80 ? "text-status-warning" : "text-fg-secondary";
  return (
    <div className="flex flex-wrap items-center gap-x-3 gap-y-0.5 border-t border-border px-3 py-1.5 font-mono text-[11.5px] text-fg-muted">
      <span className="select-none opacity-60" aria-hidden>
        ──
      </span>
      <span className={tone}>{`${formatNum(total)} / ${formatNum(cap)} (${pct}%)`}</span>
      <span title={t("tokens.output")}>↑{formatNum(stats.outputTokens)}</span>
      {stats.cacheReadTokens > 0 && (
        <span className="text-status-success/70" title={t("tokens.cacheRead")}>
          ⚡{formatNum(stats.cacheReadTokens)}
        </span>
      )}
      {stats.costUsd != null && (
        <span className="text-fg-secondary">${stats.costUsd.toFixed(4)}</span>
      )}
    </div>
  );
}

// ── Slash commands (built-in list + user/project/plugin from API) ─────

export interface SlashCommand {
  name: string;
  description?: string;
  source: "builtin" | "user" | "project" | "plugin";
  filePath?: string;
}

// Built-in commands the CLI handles itself. We surface them in autocomplete
// with a "CLI only" tag so users know they won't actually execute when
// sent over stream-json stdin.
export const BUILTIN_SLASH_COMMANDS: SlashCommand[] = [
  { name: "help", description: "List available commands", source: "builtin" },
  { name: "clear", description: "Clear the conversation", source: "builtin" },
  { name: "config", description: "Open the interactive config menu", source: "builtin" },
  { name: "model", description: "Change model mid-session", source: "builtin" },
  { name: "compact", description: "Compact the conversation context", source: "builtin" },
  { name: "memory", description: "Edit CLAUDE.md", source: "builtin" },
  { name: "hooks", description: "Manage hooks", source: "builtin" },
  { name: "cost", description: "Show session cost", source: "builtin" },
  { name: "agents", description: "List subagents", source: "builtin" },
  { name: "review", description: "Review current changes", source: "builtin" },
  { name: "release-notes", description: "Show CC release notes", source: "builtin" },
  { name: "permissions", description: "Edit permission rules", source: "builtin" },
  { name: "status", description: "Show session status", source: "builtin" },
  { name: "init", description: "Initialise CLAUDE.md from codebase", source: "builtin" },
  { name: "login", description: "Sign in to Claude", source: "builtin" },
  { name: "logout", description: "Sign out", source: "builtin" },
  { name: "exit", description: "Exit the session", source: "builtin" },
  { name: "mcp", description: "Manage MCP servers", source: "builtin" },
  { name: "plugin", description: "Manage plugins", source: "builtin" },
  { name: "output-style", description: "Change output style", source: "builtin" },
];

function commandSourceLabel(s: SlashCommand["source"]): string {
  return s === "builtin"
    ? "CLI only"
    : s === "user"
      ? "user"
      : s === "project"
        ? "project"
        : "plugin";
}

function commandSourceTone(s: SlashCommand["source"]): string {
  return s === "builtin"
    ? "bg-surface-4/10 text-fg-secondary border-border-light/30"
    : s === "user"
      ? "bg-sky-500/10 text-sky-300 border-sky-500/30"
      : s === "project"
        ? "bg-status-success/10 text-status-success border-status-success/30"
        : "bg-violet-500/10 text-violet-300 border-violet-500/30";
}

// ── Autocomplete dropdown for slash + @-files ─────────────────────────

interface AutocompleteState {
  kind: "slash" | "file";
  query: string;
  // The position in the textarea where the trigger character starts (so we
  // can replace from there to the cursor on selection).
  triggerStart: number;
  cursor: number;
}

/**
 * Tiered slash-command match scoring. Higher = more relevant. Returns 0 for
 * "doesn't match, hide it." Tiers in descending priority:
 *   1. Exact name match
 *   2. Name starts with query
 *   3. Word boundary (after `-` / `_` / `.`) starts with query
 *   4. Name contains query (earlier index ranks higher)
 *   5. Subsequence match across the name
 *   6. Description contains query - only when query is at least 3 chars,
 *      so a single keystroke can't drag in tangential descriptions.
 */
function scoreSlashMatch(name: string, description: string | undefined, q: string): number {
  if (!q) return 1;
  const n = name.toLowerCase();
  if (n === q) return 1000;
  if (n.startsWith(q)) return 800 - Math.min(n.length, 100);
  const parts = n.split(/[-_.\s]/);
  if (parts.some((p) => p.startsWith(q))) {
    return 600 - Math.min(n.length, 100);
  }
  const idx = n.indexOf(q);
  if (idx >= 0) return 400 - Math.min(idx, 100);
  if (subsequenceMatch(n, q)) return 200;
  if (q.length >= 3) {
    const d = (description || "").toLowerCase();
    if (d.includes(q)) return 100;
  }
  return 0;
}

function subsequenceMatch(s: string, q: string): boolean {
  let i = 0;
  for (let k = 0; k < s.length && i < q.length; k++) {
    if (s[k] === q[i]) i++;
  }
  return i === q.length;
}

function detectAutocomplete(value: string, cursor: number): AutocompleteState | null {
  // Look back from the cursor to find the active "token". A token starts at
  // the beginning of the line / after whitespace and continues until cursor.
  let start = cursor;
  while (start > 0) {
    const ch = value[start - 1];
    if (!ch || /\s/.test(ch)) break;
    start--;
  }
  const tok = value.slice(start, cursor);
  if (tok.startsWith("/") && tok.length >= 1) {
    // Only trigger for slash if it's at line start OR right after whitespace.
    // The detection above already enforces that.
    return { kind: "slash", query: tok.slice(1), triggerStart: start, cursor };
  }
  if (tok.startsWith("@") && tok.length >= 1) {
    return { kind: "file", query: tok.slice(1), triggerStart: start, cursor };
  }
  return null;
}

interface PromptEditorProps {
  value: string;
  onChange: (s: string) => void;
  onSubmit?: () => void;
  placeholder?: string;
  rows?: number;
  slashCommands: SlashCommand[];
  fileCwd: string;
  autoFocus?: boolean;
}

export function PromptEditor({
  value,
  onChange,
  onSubmit,
  placeholder,
  rows = 4,
  slashCommands,
  fileCwd,
  autoFocus,
}: PromptEditorProps) {
  const { t } = useTranslation("run");
  const taRef = useRef<HTMLTextAreaElement | null>(null);
  const [state, setState] = useState<AutocompleteState | null>(null);
  const [active, setActive] = useState(0);
  const [fileSuggestions, setFileSuggestions] = useState<string[]>([]);
  const fileFetchRef = useRef<{ q: string; t: number } | null>(null);

  // Slash filter - tiered scoring so prefix matches outrank arbitrary
  // substring hits, name matches outrank description matches, and shorter
  // names break ties when scores are equal.
  const slashItems = useMemo(() => {
    if (!state || state.kind !== "slash") return [] as SlashCommand[];
    const q = state.query.toLowerCase();
    const sourceOrder = { project: 0, user: 1, plugin: 2, builtin: 3 } as const;
    if (!q) {
      return [...slashCommands].sort(
        (a, b) => sourceOrder[a.source] - sourceOrder[b.source] || a.name.localeCompare(b.name)
      );
    }
    type Scored = { cmd: SlashCommand; score: number };
    const scored: Scored[] = [];
    for (const cmd of slashCommands) {
      const score = scoreSlashMatch(cmd.name, cmd.description, q);
      if (score > 0) scored.push({ cmd, score });
    }
    return scored
      .sort(
        (a, b) =>
          b.score - a.score ||
          sourceOrder[a.cmd.source] - sourceOrder[b.cmd.source] ||
          a.cmd.name.length - b.cmd.name.length ||
          a.cmd.name.localeCompare(b.cmd.name)
      )
      .map((s) => s.cmd);
  }, [state, slashCommands]);

  // File fetch (debounced)
  useEffect(() => {
    if (!state || state.kind !== "file") return;
    const ts = Date.now();
    fileFetchRef.current = { q: state.query, t: ts };
    const tid = setTimeout(() => {
      if (fileFetchRef.current?.t !== ts) return;
      api.run
        .files(fileCwd, state.query)
        .then((r) => setFileSuggestions(r.items))
        .catch(() => setFileSuggestions([]));
    }, 120);
    return () => clearTimeout(tid);
  }, [state, fileCwd]);

  const items = state?.kind === "file" ? fileSuggestions : slashItems;

  useEffect(() => {
    if (active >= items.length) setActive(Math.max(0, items.length - 1));
  }, [items.length, active]);

  const insertChoice = (choice: SlashCommand | string) => {
    if (!state || !taRef.current) return;
    const ta = taRef.current;
    const before = value.slice(0, state.triggerStart);
    const after = value.slice(state.cursor);
    let inserted: string;
    if (state.kind === "slash") {
      const c = choice as SlashCommand;
      inserted = `/${c.name}`;
    } else {
      inserted = `@${choice as string}`;
    }
    const next = before + inserted + (after.startsWith(" ") || after === "" ? "" : " ") + after;
    onChange(next);
    setState(null);
    setActive(0);
    // Re-position cursor after the inserted token + a trailing space
    requestAnimationFrame(() => {
      const pos = before.length + inserted.length + 1;
      ta.focus();
      ta.setSelectionRange(pos, pos);
    });
  };

  const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
    if (state && items.length > 0) {
      if (e.key === "ArrowDown") {
        e.preventDefault();
        setActive((a) => Math.min(items.length - 1, a + 1));
        return;
      }
      if (e.key === "ArrowUp") {
        e.preventDefault();
        setActive((a) => Math.max(0, a - 1));
        return;
      }
      if (e.key === "Enter" && !e.metaKey && !e.ctrlKey) {
        e.preventDefault();
        const choice = items[active];
        if (choice) insertChoice(choice);
        return;
      }
      if (e.key === "Tab") {
        e.preventDefault();
        const choice = items[active];
        if (choice) insertChoice(choice);
        return;
      }
      if (e.key === "Escape") {
        e.preventDefault();
        setState(null);
        return;
      }
    }
    if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
      e.preventDefault();
      onSubmit?.();
    }
  };

  const onTextareaInput = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
    onChange(e.target.value);
    const ta = e.target;
    const next = detectAutocomplete(ta.value, ta.selectionStart || 0);
    setState(next);
    if (!next) setActive(0);
  };

  const onSelect = (e: React.SyntheticEvent<HTMLTextAreaElement>) => {
    const ta = e.currentTarget;
    const next = detectAutocomplete(ta.value, ta.selectionStart || 0);
    setState(next);
  };

  return (
    <div className="relative">
      <textarea
        ref={taRef}
        autoFocus={autoFocus}
        value={value}
        onChange={onTextareaInput}
        onKeyDown={onKeyDown}
        onSelect={onSelect}
        placeholder={placeholder}
        rows={rows}
        spellCheck={false}
        className="w-full bg-surface-2 border border-border rounded-md px-3 py-2 text-sm text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50 resize-y font-sans leading-relaxed"
      />
      {state && (
        <div className="absolute z-30 left-0 right-0 bottom-full mb-1 rounded-md border border-border bg-surface-1 shadow-lg shadow-black/40 max-h-72 overflow-auto py-1">
          <div className="px-3 py-1.5 border-b border-border text-[10px] font-semibold uppercase tracking-wider text-fg-muted inline-flex items-center gap-1.5">
            {state.kind === "slash" ? (
              <>
                <SlashIcon className="w-3 h-3" />
                {t("autocomplete.slashHint")}
              </>
            ) : (
              <>
                <AtSign className="w-3 h-3" />
                {t("autocomplete.fileHint")}
              </>
            )}
          </div>
          {items.length === 0 ? (
            <div className="px-3 py-2 text-[11px] text-fg-muted">{t("autocomplete.noMatches")}</div>
          ) : state.kind === "slash" ? (
            (items as SlashCommand[]).map((c, idx) => (
              <button
                key={`${c.source}:${c.name}`}
                type="button"
                onMouseDown={(e) => e.preventDefault()}
                onClick={() => insertChoice(c)}
                onMouseEnter={() => setActive(idx)}
                className={`w-full text-left px-3 py-1.5 transition-colors ${
                  idx === active ? "bg-accent/15" : "hover:bg-surface-3"
                }`}
              >
                <div className="flex items-center gap-2">
                  <span className="font-mono text-[12px] text-fg-primary">/{c.name}</span>
                  <span
                    className={`text-[9px] font-mono px-1.5 py-0.5 rounded border ${commandSourceTone(c.source)}`}
                  >
                    {commandSourceLabel(c.source)}
                  </span>
                </div>
                {c.description && (
                  <div className="text-[10.5px] text-fg-muted truncate mt-0.5">{c.description}</div>
                )}
              </button>
            ))
          ) : (
            (items as string[]).map((p, idx) => (
              <button
                key={p}
                type="button"
                onMouseDown={(e) => e.preventDefault()}
                onClick={() => insertChoice(p)}
                onMouseEnter={() => setActive(idx)}
                className={`w-full text-left px-3 py-1.5 transition-colors flex items-center gap-2 ${
                  idx === active ? "bg-accent/15" : "hover:bg-surface-3"
                }`}
              >
                <FileCode className="w-3 h-3 text-fg-muted flex-shrink-0" />
                <span className="font-mono text-[11px] text-fg-secondary truncate">{p}</span>
              </button>
            ))
          )}
        </div>
      )}
    </div>
  );
}

// ── Live run session ─────────────────────────────────────────────────

interface RunConsoleProps {
  handle: RunHandle;
  envelopes: Envelope[];
  mode: RunMode;
  isLive: boolean;
  hasFinished: boolean;
  followUp: string;
  onFollowUpChange: (s: string) => void;
  busy: "start" | "send" | "stop" | "attach" | null;
  onSend: () => void;
  onStop: () => void;
  onNewRun: () => void;
  slashCommands: SlashCommand[];
}

export function RunConsole(props: RunConsoleProps) {
  const { t } = useTranslation("run");
  const scrollRef = useRef<HTMLDivElement | null>(null);
  const [pinnedToBottom, setPinnedToBottom] = useState(true);

  // Track whether the user has scrolled away - if so, don't yank them back.
  useEffect(() => {
    const el = scrollRef.current;
    if (!el) return;
    const onScroll = () => {
      const distance = el.scrollHeight - (el.scrollTop + el.clientHeight);
      setPinnedToBottom(distance < 80);
    };
    el.addEventListener("scroll", onScroll, { passive: true });
    return () => el.removeEventListener("scroll", onScroll);
  }, []);

  // Auto-scroll on new envelopes if the user is pinned to bottom.
  useEffect(() => {
    if (!pinnedToBottom) return;
    const el = scrollRef.current;
    if (!el) return;
    el.scrollTop = el.scrollHeight;
  }, [props.envelopes.length, pinnedToBottom]);

  const result = useMemo(
    () => props.envelopes.find((e) => e.type === "result") as ResultEnvelope | undefined,
    [props.envelopes]
  );
  const init = useMemo(
    () => props.envelopes.find((e) => e.type === "system") as SystemInit | undefined,
    [props.envelopes]
  );
  const tokenStats = useMemo(() => computeTokens(props.envelopes), [props.envelopes]);

  return (
    // flex-1 + min-h-0 lets us fill the viewport-locked parent, while the
    // inner stream area's overflow-auto keeps long chats scrollable inside
    // the panel - never the page.
    <div className="flex flex-col flex-1 min-h-0 rounded-lg border border-border bg-black/40">
      {/* Toolbar - one dense strip, mono so it reads as a terminal chrome */}
      <div className="flex flex-wrap items-center gap-2 border-b border-border px-3 py-1.5 font-mono text-[11.5px]">
        <StatusPill status={props.handle.status} />
        <ModeBadge mode={props.mode} />
        {init?.model && <span className="text-fg-muted">{init.model}</span>}
        {props.handle.sessionId && (
          <span className="truncate text-fg-muted">{props.handle.sessionId.slice(0, 8)}</span>
        )}
        <div className="flex-1" />
        {props.isLive && (
          <button
            onClick={props.onStop}
            disabled={props.busy === "stop"}
            className="inline-flex items-center gap-1 text-status-danger hover:text-status-danger disabled:opacity-60 transition-colors"
          >
            <Square className="w-3 h-3" />
            {props.busy === "stop" ? t("actions.stopping") : t("actions.stop")}
          </button>
        )}
        {props.handle.sessionId && (
          <Link
            to={`/sessions/${encodeURIComponent(props.handle.sessionId)}`}
            className="inline-flex items-center gap-1 text-fg-secondary hover:text-fg-primary transition-colors"
          >
            <ExternalLink className="w-3 h-3" />
            {t("actions.viewSession")}
          </Link>
        )}
        {/* Always available - lets the user leave a running run in the
            background and start another one. The original is still in the
            Active Runs dropdown for re-attach. */}
        <button
          onClick={props.onNewRun}
          className="inline-flex items-center gap-1 text-accent hover:brightness-125 transition-colors"
        >
          <Plus className="w-3 h-3" />
          {t("actions.newRun")}
        </button>
      </div>

      {/* Stream area */}
      <div ref={scrollRef} className="min-h-0 flex-1 space-y-1.5 overflow-auto px-3 py-2">
        {props.envelopes.length === 0 && <EmptyStream isLive={props.isLive} />}
        {props.envelopes.map((env, i) => (
          <EnvelopeRow key={i} envelope={env} />
        ))}
      </div>

      {/* Live token / context-window meter */}
      <TokenMeter stats={tokenStats} />

      {/* Footer banner once finished */}
      {props.hasFinished && result && <ResultFooter result={result} />}

      {/* Follow-up input - only for conversation mode while live */}
      {props.mode === "conversation" && props.isLive && (
        <div className="border-t border-border px-3 py-2">
          <PromptEditor
            value={props.followUp}
            onChange={props.onFollowUpChange}
            onSubmit={props.onSend}
            placeholder={t("fields.promptPlaceholder")}
            rows={2}
            slashCommands={props.slashCommands}
            fileCwd={props.handle.cwd}
          />
          <div className="mt-2 flex items-center justify-between">
            <div className="text-[10px] text-fg-muted">{t("hint.shortcut")} · / · @</div>
            <button
              onClick={props.onSend}
              disabled={!props.followUp.trim() || props.busy === "send"}
              className="inline-flex items-center gap-1.5 rounded-md border border-accent/40 bg-accent/15 hover:bg-accent/25 text-accent px-3 py-1.5 text-xs font-medium transition-colors disabled:opacity-50"
            >
              <Send className="w-3 h-3" />
              {props.busy === "send" ? t("actions.sending") : t("actions.send")}
            </button>
          </div>
        </div>
      )}
    </div>
  );
}
function EmptyStream({ isLive }: { isLive: boolean }) {
  const { t } = useTranslation("run");
  if (isLive) {
    return (
      <div className="text-center py-12 text-fg-muted flex flex-col items-center gap-2">
        <RefreshCw className="w-5 h-5 animate-spin" />
        <span className="text-xs">{t("status.spawning")}</span>
      </div>
    );
  }
  return (
    <div className="text-center py-12 flex flex-col items-center gap-2">
      <Sparkles className="w-6 h-6 text-fg-muted" />
      <div className="text-sm font-medium text-fg-secondary">{t("empty.title")}</div>
      <div className="text-xs text-fg-muted max-w-md">{t("empty.body")}</div>
    </div>
  );
}

export function StatusPill({ status }: { status: string }) {
  const { t } = useTranslation("run");
  const idle = {
    color: "bg-surface-3 text-fg-secondary border-border",
    icon: Clock as typeof Play,
  };
  const config: Record<string, { color: string; icon: typeof Play }> = {
    spawning: {
      color: "bg-status-warning/15 text-status-warning border-status-warning/30",
      icon: RefreshCw,
    },
    running: {
      color: "bg-status-success/15 text-status-success border-status-success/30",
      icon: Sparkles,
    },
    completed: {
      color: "bg-status-success/15 text-status-success border-status-success/30",
      icon: CheckCircle2,
    },
    error: {
      color: "bg-status-danger/15 text-status-danger border-status-danger/30",
      icon: XCircle,
    },
    killed: { color: "bg-surface-4/15 text-fg-secondary border-border-light/30", icon: Square },
    abandoned: {
      color: "bg-orange-500/10 text-orange-300 border-orange-500/30",
      icon: Square,
    },
    idle,
  };
  const c = config[status] ?? idle;
  const Icon = c.icon;
  const animate = status === "spawning" || status === "running";
  return (
    <span
      className={`inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[11px] font-medium border ${c.color}`}
    >
      <Icon
        className={`w-3 h-3 ${animate ? (status === "spawning" ? "animate-spin" : "animate-pulse") : ""}`}
      />
      {t(`status.${status}`)}
    </span>
  );
}

export function ModeBadge({ mode }: { mode: RunMode }) {
  const { t } = useTranslation("run");
  return (
    <span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-surface-3 text-fg-secondary border border-border inline-flex items-center gap-1">
      {mode === "conversation" ? (
        <Terminal className="w-3 h-3" />
      ) : (
        <Sparkles className="w-3 h-3" />
      )}
      {t(`mode.${mode}`)}
    </span>
  );
}

// ── Envelope rendering ───────────────────────────────────────────────

function EnvelopeRow({ envelope }: { envelope: Envelope }) {
  if (!envelope || typeof envelope !== "object") return null;
  switch (envelope.type) {
    case "user":
      return <UserTurn env={envelope as UserMessage} />;
    case "assistant":
      return <AssistantTurn env={envelope as AssistantMessage} />;
    case "system":
      return null; // init metadata is shown in the toolbar
    case "result":
      return null; // shown in the footer
    case "stream_event":
      return null; // kept in state for token accounting only - never rendered
    default:
      // Unknown envelope: render compact JSON for transparency
      return <UnknownTurn env={envelope} />;
  }
}

function extractText(content: ContentBlock[] | string | undefined): string {
  if (!content) return "";
  if (typeof content === "string") return content;
  return content
    .filter((b): b is ContentBlock & { type: "text" } => b.type === "text")
    .map((b) => b.text)
    .join("\n");
}

function UserTurn({ env }: { env: UserMessage }) {
  const content = env.message?.content;

  // Tool results live inside user.message.content as { type: "tool_result", ... }.
  const toolResults = Array.isArray(content)
    ? (content.filter((b) => b.type === "tool_result") as Extract<
        ContentBlock,
        { type: "tool_result" }
      >[])
    : [];
  const text = extractText(content);

  if (toolResults.length > 0 && !text) {
    return (
      <div className="space-y-2">
        {toolResults.map((tr, i) => (
          <ToolResultBlock key={i} result={tr} />
        ))}
      </div>
    );
  }

  return (
    <div className="flex gap-2 font-mono text-[12.5px] leading-relaxed">
      <span className="select-none text-indigo-400" aria-hidden>
        &gt;
      </span>
      <span className="min-w-0 flex-1 whitespace-pre-wrap break-words text-fg-secondary">
        {text || "-"}
      </span>
    </div>
  );
}

function AssistantTurn({ env }: { env: AssistantMessage }) {
  const content = env.message?.content;
  const blocks = Array.isArray(content)
    ? content
    : content
      ? [{ type: "text", text: content } as ContentBlock]
      : [];
  const text = blocks
    .filter((b): b is ContentBlock & { type: "text" } => b.type === "text")
    .map((b) => b.text)
    .join("\n");
  const toolUses = blocks.filter(
    (b): b is ContentBlock & { type: "tool_use" } => b.type === "tool_use"
  );
  const thinking = blocks.filter(
    (b): b is ContentBlock & { type: "thinking" } => b.type === "thinking"
  );

  return (
    <div className="space-y-1">
      {thinking.map((th, i) => (
        <ThinkingBlock key={`th-${i}`} text={th.thinking || ""} />
      ))}
      {text && (
        // The gutter glyph is the only speaker marker - no avatar, no label
        // row. Prose keeps its markdown rendering; only the chrome is gone.
        <div className="flex gap-2">
          <span
            className="select-none font-mono text-[12.5px] leading-relaxed text-accent"
            aria-hidden
          >
            ›
          </span>
          <div className="min-w-0 flex-1 text-[13px] leading-relaxed text-fg-secondary prose-claude">
            <MarkdownContent text={text} />
          </div>
        </div>
      )}
      {toolUses.map((tu) => (
        <ToolUseBlock key={tu.id} toolUse={tu} />
      ))}
    </div>
  );
}

function ThinkingBlock({ text }: { text: string }) {
  const { t } = useTranslation("run");
  const [open, setOpen] = useState(false);
  if (!text) return null;
  return (
    <div className="font-mono text-[12px] leading-relaxed">
      <button
        onClick={() => setOpen((v) => !v)}
        className="flex items-center gap-2 text-violet-400/70 hover:text-violet-300 transition-colors"
      >
        <span className="select-none" aria-hidden>
          {open ? "\u25be" : "\u25b8"}
        </span>
        <span className="italic">{t("events.thinking")}</span>
      </button>
      {open && (
        <pre className="mt-0.5 border-l border-violet-500/25 pl-3 ml-1.5 whitespace-pre-wrap break-words text-violet-200/60">
          {text}
        </pre>
      )}
    </div>
  );
}

function ToolUseBlock({ toolUse }: { toolUse: Extract<ContentBlock, { type: "tool_use" }> }) {
  const { t } = useTranslation("run");
  const [open, setOpen] = useState(false);
  const summary = describeToolInput(toolUse.input);
  return (
    <div className="font-mono text-[12px] leading-relaxed">
      <button
        onClick={() => setOpen((v) => !v)}
        className="flex w-full items-baseline gap-2 text-left hover:bg-white/[0.03] transition-colors"
        title={t("events.tool")}
      >
        <span className="select-none text-status-warning" aria-hidden>
          ●
        </span>
        <span className="font-medium text-status-warning">{toolUse.name}</span>
        {summary && <span className="truncate text-fg-muted">{summary}</span>}
      </button>
      {open && (
        <pre className="mt-0.5 ml-1.5 max-h-72 overflow-auto border-l border-status-warning/25 pl-3 whitespace-pre-wrap break-words text-fg-secondary">
          {JSON.stringify(toolUse.input, null, 2)}
        </pre>
      )}
    </div>
  );
}

function ToolResultBlock({ result }: { result: Extract<ContentBlock, { type: "tool_result" }> }) {
  const { t } = useTranslation("run");
  const [open, setOpen] = useState(false);
  const text =
    typeof result.content === "string"
      ? result.content
      : Array.isArray(result.content)
        ? result.content
            .map((c) => {
              if (c == null) return "";
              if (typeof c === "string") return c;
              const obj = c as { text?: string };
              return obj.text || JSON.stringify(c);
            })
            .join("\n")
        : JSON.stringify(result.content);
  const lines = text.split("\n").length;
  const tone = result.is_error ? "text-status-danger" : "text-status-success/90";
  // First line is the useful one nine times out of ten, so it doubles as the
  // collapsed summary instead of a generic "tool result" label.
  const firstLine = text.split("\n").find((l) => l.trim()) || "";
  return (
    <div className={`font-mono text-[12px] leading-relaxed ${tone}`}>
      <button
        onClick={() => setOpen((v) => !v)}
        className="flex w-full items-baseline gap-2 text-left hover:bg-white/[0.03] transition-colors"
      >
        <span className="select-none opacity-70" aria-hidden>
          {open ? "\u2514" : "\u2514"}
        </span>
        <span className="truncate opacity-90">{firstLine || t("events.toolResult")}</span>
        {lines > 1 && <span className="ml-auto shrink-0 text-[11px] opacity-50">{lines}L</span>}
      </button>
      {open && (
        <pre className="mt-0.5 ml-1.5 max-h-72 overflow-auto border-l border-current/25 pl-3 whitespace-pre-wrap break-words opacity-80">
          {text}
        </pre>
      )}
    </div>
  );
}

function UnknownTurn({ env }: { env: Envelope }) {
  return (
    <details className="font-mono text-[11.5px] leading-relaxed text-fg-muted">
      <summary className="cursor-pointer">? {(env.type as string) || "unknown"}</summary>
      <pre className="mt-0.5 ml-1.5 max-h-48 overflow-auto border-l border-border pl-3 whitespace-pre-wrap break-words text-fg-muted">
        {JSON.stringify(env, null, 2)}
      </pre>
    </details>
  );
}

function describeToolInput(input: unknown): string {
  if (!input || typeof input !== "object") return "";
  const obj = input as Record<string, unknown>;
  // Common Claude Code tool inputs: file_path, path, command, pattern…
  for (const k of ["file_path", "path", "command", "pattern", "url", "name"]) {
    const v = obj[k];
    if (typeof v === "string" && v) return v.length > 80 ? v.slice(0, 80) + "…" : v;
  }
  return "";
}

function ResultFooter({ result }: { result: ResultEnvelope }) {
  const { t } = useTranslation("run");
  const isError = result.is_error;
  const parts: string[] = [];
  if (typeof result.duration_ms === "number")
    parts.push(`${(result.duration_ms / 1000).toFixed(1)}s`);
  if (typeof result.total_cost_usd === "number") parts.push(`$${result.total_cost_usd.toFixed(4)}`);
  if (typeof result.num_turns === "number") parts.push(t("footer.turns") + " " + result.num_turns);
  return (
    <div
      className={`border-t border-border px-3 py-1.5 font-mono text-[11.5px] ${
        isError ? "text-status-danger" : "text-status-success/90"
      }`}
    >
      <span className="select-none opacity-60" aria-hidden>
        ──{" "}
      </span>
      {isError ? t("status.error") : t("status.completed")}
      {parts.length > 0 && <span className="text-fg-muted"> · {parts.join(" · ")}</span>}
    </div>
  );
}
