import { afterEach, describe, expect, it, vi } from "vitest";
import { Writable } from "node:stream";
import {
  approvalDecision,
  abortEvent,
  assistantText,
  compactionBegin,
  compactionEnd,
  requestBegin,
  requestEnd,
  thinkingMessage,
  toolCall,
  toolCallOutput,
  tokenUsage,
  sessionMeta,
  userText,
  partialText,
  partialThinking,
  partialToolCall,
  partialToolCallOutput,
  withOrigin,
} from "@prismshadow/penguin-core";
import type { MessageOrigin } from "@prismshadow/penguin-core";
import {
  StreamRenderer,
  formatAbort,
  humanizeTokens,
  renderHistory,
  supportsColor,
} from "../src/render.js";
import { getMessages } from "../src/i18n.js";

const t = getMessages("en");

// Several tests stub FORCE_COLOR/NO_COLOR to pin the palette decision (the collector streams
// below are not TTYs); make sure no stub leaks into the next test.
afterEach(() => {
  vi.unstubAllEnvs();
});

function collector(): { stream: Writable; text: () => string } {
  let buf = "";
  const stream = new Writable({
    write(chunk, _enc, cb) {
      buf += chunk.toString();
      cb();
    },
  });
  return { stream, text: () => buf };
}

function stripAnsi(s: string): string {
  // eslint-disable-next-line no-control-regex
  return s.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "");
}

/** Overrides a message's timestamp (the constructor defaults to the current time). */
function at<M extends { timestamp: string }>(ts: string, msg: M): M {
  return { ...msg, timestamp: ts };
}

/** token_usage shorthand: request.total = req, session.total = sess (all buckets zero, sufficient for this test group). */
function usage(req: number, sess: number) {
  return tokenUsage(
    { cache_read: 0, cache_write: 0, output: 0, total: sess },
    { cache_read: 0, cache_write: 0, output: 0, total: req },
  );
}

describe("humanizeTokens", () => {
  it("abbreviates with k / M and trims .0", () => {
    expect(humanizeTokens(0)).toBe("0");
    expect(humanizeTokens(999)).toBe("999");
    expect(humanizeTokens(1000)).toBe("1k");
    expect(humanizeTokens(1234)).toBe("1.2k");
    expect(humanizeTokens(32000)).toBe("32k");
    expect(humanizeTokens(1_500_000)).toBe("1.5M");
  });
});

describe("pure formatters", () => {
  it("formatAbort includes the reason", () => {
    expect(stripAnsi(formatAbort({ type: "abort", reason: "ctrl-c" }, t))).toContain("ctrl-c");
  });

  it("renderHistory includes abort events from resumed sessions", () => {
    const { stream, text } = collector();
    renderHistory([assistantText("partial", "aborted"), abortEvent("aborted by user")], stream, t);
    expect(stripAnsi(text())).toBe("partial [aborted]\n[abort]: aborted by user\n");
  });
});

describe("StreamRenderer", () => {
  it("streams partial_text deltas and does NOT re-render the complete text", () => {
    const { stream, text } = collector();
    const r = new StreamRenderer(stream, t);
    r.handle(partialText("start", "Hel"));
    r.handle(partialText("delta", "lo "));
    r.handle(partialText("delta", "world"));
    r.handle(partialText("stop", "", "completed"));
    r.handle(assistantText("Hello world")); // complete message: must not be re-rendered
    expect(stripAnsi(text())).toBe("Hello world\n");
  });

  it("streams partial_thinking (dim) and skips the complete thinking", () => {
    const { stream, text } = collector();
    const r = new StreamRenderer(stream, t);
    r.handle(partialThinking("start", "think"));
    r.handle(partialThinking("delta", "ing"));
    r.handle(partialThinking("stop"));
    r.handle(thinkingMessage("thinking")); // must not be re-rendered
    expect(stripAnsi(text())).toBe("thinking\n");
  });

  it("does not render a complete tool_call without partials", () => {
    const { stream, text } = collector();
    const r = new StreamRenderer(stream, t);
    r.handle(toolCall({ name: "exec_command", arguments: '{"cmd":"ls"}', toolCallId: "c2" }));
    expect(text()).toBe("");
  });

  it("streams partial_tool_call with a pairing tag and skips the complete tool_call", () => {
    const { stream, text } = collector();
    const r = new StreamRenderer(stream, t);
    r.handle(partialToolCall({ eventType: "start", name: "exec_command", toolCallId: "c4" }));
    r.handle(
      partialToolCall({ eventType: "delta", name: "", arguments: '{"cmd":"l', toolCallId: "c4" }),
    );
    r.handle(partialToolCall({ eventType: "delta", name: "", arguments: 's"}', toolCallId: "c4" }));
    r.handle(partialToolCall({ eventType: "stop", name: "", toolCallId: "c4" }));
    r.handle(toolCall({ name: "exec_command", arguments: '{"cmd":"ls"}', toolCallId: "c4" }));
    // The call line carries a [tool-<last-3-chars-of-id>] pairing tag matching the output line.
    expect(stripAnsi(text())).toBe("[tool-c4] exec_command <- $ ls\n");
  });

  it("renders one call line when the description arrives after the command", () => {
    const { stream, text } = collector();
    const r = new StreamRenderer(stream, t);
    // The assembled schema carries the description argument, so the preview waits for it:
    // with payload-first emission (models don't always honour schema order) the plain form
    // must never reach the screen, or it would be stranded above the described one.
    r.useToolSchemas([
      {
        name: "exec_command",
        description: "run a command",
        parameters: { type: "object", properties: { description: {}, cmd: {} } },
      },
    ]);
    r.handle(partialToolCall({ eventType: "start", name: "exec_command", toolCallId: "c9" }));
    r.handle(
      partialToolCall({
        eventType: "delta",
        name: "",
        arguments: '{"cmd":"ls -la",',
        toolCallId: "c9",
      }),
    );
    r.handle(
      partialToolCall({
        eventType: "delta",
        name: "",
        arguments: '"description":"List files in the current directory"}',
        toolCallId: "c9",
      }),
    );
    r.handle(partialToolCall({ eventType: "stop", name: "", toolCallId: "c9" }));
    expect(stripAnsi(text())).toBe(
      "[tool-c9] exec_command <- List files in the current directory ($ ls -la)\n",
    );
  });

  it("streams the command live when the schema has no description argument", () => {
    const { stream, text } = collector();
    const r = new StreamRenderer(stream, t);
    // call_description switched off for this tool: nothing can supersede the plain form, so
    // it streams as the arguments arrive rather than waiting for them to settle.
    r.useToolSchemas([
      {
        name: "exec_command",
        description: "run a command",
        parameters: { type: "object", properties: { cmd: {} } },
      },
    ]);
    r.handle(partialToolCall({ eventType: "start", name: "exec_command", toolCallId: "c7" }));
    r.handle(
      partialToolCall({ eventType: "delta", name: "", arguments: '{"cmd":"ls', toolCallId: "c7" }),
    );
    expect(stripAnsi(text())).toBe("[tool-c7] exec_command <- $ ls");
    r.handle(
      partialToolCall({ eventType: "delta", name: "", arguments: ' -la"}', toolCallId: "c7" }),
    );
    r.handle(partialToolCall({ eventType: "stop", name: "", toolCallId: "c7" }));
    expect(stripAnsi(text())).toBe("[tool-c7] exec_command <- $ ls -la\n");
  });

  it("still renders a call line whose arguments never settled", () => {
    const { stream, text } = collector();
    const r = new StreamRenderer(stream, t);
    // Interrupted mid-arguments while awaiting a description: the call must not vanish.
    r.useToolSchemas([
      {
        name: "exec_command",
        description: "run a command",
        parameters: { type: "object", properties: { description: {}, cmd: {} } },
      },
    ]);
    r.handle(partialToolCall({ eventType: "start", name: "exec_command", toolCallId: "c8" }));
    r.handle(
      partialToolCall({ eventType: "delta", name: "", arguments: '{"cmd":"sle', toolCallId: "c8" }),
    );
    r.handle(partialToolCall({ eventType: "stop", name: "", toolCallId: "c8" }));
    expect(stripAnsi(text())).toBe("[tool-c8] exec_command <- $ sle\n");
  });

  it("prefixes streamed tool output with the tool name and skips the complete tool_call_output", () => {
    const { stream, text } = collector();
    const r = new StreamRenderer(stream, t);
    // The call precedes its output and supplies the gutter's tool name.
    r.handle(partialToolCall({ eventType: "start", name: "exec_command", toolCallId: "c3" }));
    r.handle(
      partialToolCall({
        eventType: "delta",
        name: "",
        arguments: '{"cmd":"ls"}',
        toolCallId: "c3",
      }),
    );
    r.handle(partialToolCall({ eventType: "stop", name: "", toolCallId: "c3" }));
    r.handle(partialToolCallOutput({ eventType: "start", toolCallId: "c3" }));
    r.handle(partialToolCallOutput({ eventType: "delta", output: "line1\n", toolCallId: "c3" }));
    r.handle(partialToolCallOutput({ eventType: "delta", output: "line2", toolCallId: "c3" }));
    r.handle(partialToolCallOutput({ eventType: "stop", toolCallId: "c3" }));
    r.handle(toolCallOutput({ output: "line1\nline2", toolCallId: "c3" })); // must not be re-rendered
    // Call line first, then each output line repeats the `[tool-xxx] <toolName>` prefix.
    expect(stripAnsi(text())).toBe(
      "[tool-c3] exec_command <- $ ls\n[tool-c3] exec_command -> line1\n[tool-c3] exec_command -> line2\n",
    );
  });

  it("colors edit_file diff output lines green/red and dims hunk headers", () => {
    // The collector stream is not a TTY, so color must be forced on for this test (and the
    // renderer captures the decision at construction, so the stub must precede it).
    vi.stubEnv("FORCE_COLOR", "1");
    const { stream, text } = collector();
    const r = new StreamRenderer(stream, t);
    r.handle(partialToolCall({ eventType: "start", name: "edit_file", toolCallId: "d1" }));
    r.handle(
      partialToolCall({
        eventType: "delta",
        name: "",
        arguments: '{"file_path":"x.ts","old_string":"old","new_string":"new"}',
        toolCallId: "d1",
      }),
    );
    r.handle(partialToolCall({ eventType: "stop", name: "", toolCallId: "d1" }));
    r.handle(partialToolCallOutput({ eventType: "start", toolCallId: "d1" }));
    r.handle(
      partialToolCallOutput({
        eventType: "delta",
        output: 'Replaced 1 occurrence in "x.ts".\n@@ -1,1 +1,1 @@\n-old\n+new\n',
        toolCallId: "d1",
      }),
    );
    r.handle(partialToolCallOutput({ eventType: "stop", toolCallId: "d1" }));
    const raw = text();
    // Diff lines are wrapped in green/red; the hunk header is dimmed; the summary line stays plain.
    expect(raw).toContain("\x1b[32m+new\x1b[0m");
    expect(raw).toContain("\x1b[31m-old\x1b[0m");
    expect(raw).toContain("\x1b[2m@@ -1,1 +1,1 @@\x1b[0m");
    // The stripped view still reads as labeled gutter lines.
    const plain = stripAnsi(raw);
    expect(plain).toContain("[tool-d1] edit_file -> -old");
    expect(plain).toContain("[tool-d1] edit_file -> +new");
  });

  it("does not diff-color non-file-tool output", () => {
    // Forced color, as above: with a colorless palette this assertion would pass vacuously.
    vi.stubEnv("FORCE_COLOR", "1");
    const { stream, text } = collector();
    const r = new StreamRenderer(stream, t);
    r.handle(partialToolCall({ eventType: "start", name: "exec_command", toolCallId: "d2" }));
    r.handle(
      partialToolCall({ eventType: "delta", name: "", arguments: '{"cmd":"x"}', toolCallId: "d2" }),
    );
    r.handle(partialToolCall({ eventType: "stop", name: "", toolCallId: "d2" }));
    r.handle(partialToolCallOutput({ eventType: "start", toolCallId: "d2" }));
    r.handle(partialToolCallOutput({ eventType: "delta", output: "+plus\n", toolCallId: "d2" }));
    r.handle(partialToolCallOutput({ eventType: "stop", toolCallId: "d2" }));
    expect(text()).not.toContain("\x1b[32m");
  });

  it("falls back to the pairing tag on output whose call was never seen", () => {
    const { stream, text } = collector();
    const r = new StreamRenderer(stream, t);
    r.handle(partialToolCallOutput({ eventType: "start", toolCallId: "c3" }));
    r.handle(partialToolCallOutput({ eventType: "delta", output: "line1", toolCallId: "c3" }));
    r.handle(partialToolCallOutput({ eventType: "stop", toolCallId: "c3" }));
    expect(stripAnsi(text())).toBe("[tool-c3] -> line1\n");
  });

  it("prints the retry line, with the stamped attempt, only when the retry request actually begins", () => {
    const { stream, text } = collector();
    const r = new StreamRenderer(stream, t);
    r.handle(requestBegin());
    r.handle(requestEnd("malformed", { attempt: 1 }));
    expect(stripAnsi(text())).toBe(""); // the failure itself prints nothing; only the retry's start does
    r.handle(requestBegin()); // retry #1 begins
    expect(stripAnsi(text())).toContain("retry #1");
    r.handle(requestEnd("timeout", { attempt: 2 }));
    r.handle(requestBegin()); // retry #2 begins
    expect(stripAnsi(text())).toContain("retry #2");
    // Retry #2 fails again and retries are exhausted: no next request_begin, only abort — no retry #3 appears.
    r.handle(requestEnd("malformed", { attempt: 3 }));
    r.handle(abortEvent("malformed response failed after 2 retries"));
    expect(stripAnsi(text())).not.toContain("retry #3");
    // The first request of the next run is not a retry, so it prints nothing; the next run's
    // failures are stamped from 1 again.
    r.handle(requestBegin());
    r.handle(requestEnd("timeout", { attempt: 1 }));
    r.handle(requestBegin());
    const lines = stripAnsi(text());
    expect(lines.match(/retry #1/g)).toHaveLength(2);
    expect(lines).not.toContain("retry #3");
  });

  it("prints the retry line for a failed request too, straight from the stamped ordinal", () => {
    // The engine reconnects on `failed` as well, so the CLI has to say so — otherwise the
    // session goes quiet for the whole ladder. The number is the event's own attempt, so a
    // mixed timeout → failed → timeout run keeps counting without any client-side state.
    const { stream, text } = collector();
    const r = new StreamRenderer(stream, t);
    r.handle(requestBegin());
    r.handle(requestEnd("failed", { errorMessage: "Upstream HTTP/2 stream failed", attempt: 1 }));
    r.handle(requestBegin()); // retry #1 begins
    let lines = stripAnsi(text());
    expect(lines).toContain("the model provider returned an error");
    expect(lines).toContain("retry #1");
    r.handle(requestEnd("timeout", { attempt: 2 }));
    r.handle(requestBegin()); // retry #2 begins — the count does not restart
    lines = stripAnsi(text());
    expect(lines).toContain("connection timed out");
    expect(lines).toContain("retry #2");
    // `auth` is terminal: the engine never retries it, so the next request_begin (a new run)
    // must not be announced as a retry.
    r.handle(requestEnd("auth", { errorMessage: "401 invalid x-api-key", attempt: 3 }));
    r.handle(requestBegin());
    expect(stripAnsi(text())).not.toContain("retry #3");
  });

  it("locks the screen to one streaming tool output; other messages queue until its stop", () => {
    const { stream, text } = collector();
    const r = new StreamRenderer(stream, t);
    r.handle(partialToolCallOutput({ eventType: "start", toolCallId: "tA" }));
    r.handle(partialToolCallOutput({ eventType: "delta", output: "a1\n", toolCallId: "tA" }));
    // The screen is locked by tA: other streaming messages queue up.
    r.handle(partialText("start", ""));
    r.handle(partialText("delta", "hello"));
    r.handle(partialToolCallOutput({ eventType: "delta", output: "a2\n", toolCallId: "tA" }));
    // No call preceded tA in this stream: the gutter falls back to the pairing tag.
    expect(stripAnsi(text())).toBe("[tool-tA] -> a1\n[tool-tA] -> a2\n"); // hello is still queued
    r.handle(partialToolCallOutput({ eventType: "stop", toolCallId: "tA" }));
    r.handle(partialText("stop", "", "completed"));
    expect(stripAnsi(text())).toBe("[tool-tA] -> a1\n[tool-tA] -> a2\nhello\n");
  });

  it("queues everything while a user prompt is active and flushes after it ends", () => {
    const { stream, text } = collector();
    const r = new StreamRenderer(stream, t);
    r.beginUserPrompt();
    r.handle(partialText("start", ""));
    r.handle(partialText("delta", "after prompt"));
    r.handle(partialText("stop", "", "completed"));
    expect(text()).toBe(""); // the screen is locked while waiting for user input
    r.endUserPrompt();
    expect(stripAnsi(text())).toBe("after prompt\n");
  });

  it("does not print token_usage per turn; endTask prints [stats] line with per-task deltas", () => {
    const { stream, text } = collector();
    const r = new StreamRenderer(stream, t);
    r.handle(
      sessionMeta({
        session_id: "s",
        provider: "custom",
        model_id: "m",
        model_context_window: 1,
        system_prompt: "sp",
        tools: [{ name: "exec_command", description: "test tool" }],
        agent_state: "/a",
        workspace: "/w",
      }),
    );
    // Two turns: request total 1500, 4000. Per-task token delta = 5500; session cumulative = 12000;
    // context = the latest request's input+output (= total) = 4000.
    r.handle(
      tokenUsage(
        { cache_read: 0, cache_write: 0, output: 0, total: 8000 },
        { cache_read: 0, cache_write: 0, output: 200, total: 1500 },
      ),
    );
    r.handle(
      tokenUsage(
        { cache_read: 0, cache_write: 0, output: 0, total: 12000 },
        { cache_read: 0, cache_write: 0, output: 300, total: 4000 },
      ),
    );
    expect(stripAnsi(text())).toBe(""); // no stats line is printed mid-turn
    r.endTask(2345);
    // Exact full-line assertion: context 4k (the latest request's total) and its delta, cumulative tokens 12k,
    // per-task delta 5.5k (1500 + 4000), elapsed 2.3s (first task: session equals the delta);
    // this also implies session_meta is not rendered (no /w or similar field appears in the output).
    expect(stripAnsi(text())).toBe(
      "[stats] context 4k (+4k) · tokens 12k (+5.5k) · 2.3s (+2.3s)\n",
    );
  });

  it("accumulates session elapsed across tasks; context delta is vs previous task", () => {
    const { stream, text } = collector();
    const r = new StreamRenderer(stream, t);
    // task 1: context 4000, elapsed 2000ms.
    r.handle(
      tokenUsage(
        { cache_read: 0, cache_write: 0, output: 0, total: 4000 },
        { cache_read: 0, cache_write: 0, output: 0, total: 4000 },
      ),
    );
    r.endTask(2000);
    // task 2: context 7000 (+3000 vs. the previous task), session elapsed cumulative 5000ms (this task +3000ms).
    r.handle(
      tokenUsage(
        { cache_read: 0, cache_write: 0, output: 0, total: 11000 },
        { cache_read: 0, cache_write: 0, output: 0, total: 7000 },
      ),
    );
    r.endTask(3000);
    const lines = stripAnsi(text()).trim().split("\n");
    const last = lines[lines.length - 1]!;
    // Exact full-line assertion: context 7k (delta = 7000 - 4000), cumulative session tokens 11k,
    // per-task token delta 7k, total session elapsed 5s (this task +3s).
    expect(last).toBe("[stats] context 7k (+3k) · tokens 11k (+7k) · 5s (+3s)");
  });

  it("an elapsed remainder that rounds to 60s carries into the minute", () => {
    const { stream, text } = collector();
    const r = new StreamRenderer(stream, t);
    r.handle(
      tokenUsage(
        { cache_read: 0, cache_write: 0, output: 0, total: 4000 },
        { cache_read: 0, cache_write: 0, output: 0, total: 4000 },
      ),
    );
    // 119.7s: rounding the remainder against floored minutes would read 1m60s.
    r.endTask(119_700);
    const lines = stripAnsi(text()).trim().split("\n");
    expect(lines[lines.length - 1]).toBe(
      "[stats] context 4k (+4k) · tokens 4k (+4k) · 2m0s (+2m0s)",
    );
  });

  it("context delta goes negative after compaction shrinks the context (no clamping)", () => {
    const { stream, text } = collector();
    const r = new StreamRenderer(stream, t);
    // task 1: context 7000.
    r.handle(
      tokenUsage(
        { cache_read: 0, cache_write: 0, output: 0, total: 7000 },
        { cache_read: 0, cache_write: 0, output: 0, total: 7000 },
      ),
    );
    r.endTask(1000);
    // task 2: context drops to 2000 after compaction -> delta is negative (2000 - 7000 = -5k), not clamped to non-negative.
    r.handle(
      tokenUsage(
        { cache_read: 0, cache_write: 0, output: 0, total: 9000 },
        { cache_read: 0, cache_write: 0, output: 0, total: 2000 },
      ),
    );
    r.endTask(1000);
    const lines = stripAnsi(text()).trim().split("\n");
    expect(lines[lines.length - 1]).toBe("[stats] context 2k (-5k) · tokens 9k (+2k) · 2s (+1s)");
  });

  it("renders mode-specific compaction messages (summarize vs discard)", () => {
    const { stream, text } = collector();
    const r = new StreamRenderer(stream, t);
    r.handle(compactionBegin({ reason: "context", mode: "summarize", context: 150, turns: 3 }));
    r.handle(compactionEnd({ reason: "context", mode: "summarize", status: "completed" }));
    r.handle(compactionBegin({ reason: "manual", mode: "discard", context: 10, turns: 1 }));
    r.handle(compactionEnd({ reason: "manual", mode: "discard", status: "completed" }));
    r.handle(compactionEnd({ reason: "context", mode: "summarize", status: "failed" }));
    expect(stripAnsi(text())).toBe(
      [
        "[compaction] summarizing context (context)…",
        "[compaction] done; continuing with the summarized context",
        "[compaction] discarding context (manual)…",
        "[compaction] done; old context discarded",
        "[compaction] failed; keeping the current context",
        "",
      ].join("\n"),
    );
  });

  it("compaction after the turn ends: the completion line shows its own cost, excluded from the turn stats delta; context not updated", () => {
    const { stream, text } = collector();
    const r = new StreamRenderer(stream, t);
    // Ordinary request: context 5000.
    r.handle(
      tokenUsage(
        { cache_read: 0, cache_write: 0, output: 0, total: 8000 },
        { cache_read: 0, cache_write: 0, output: 0, total: 5000 },
      ),
    );
    // The compaction request's usage sits between the paired compaction events: no ordinary request_end
    // follows it in this turn -> compaction after the turn has ended.
    r.handle(compactionBegin({ reason: "context", mode: "summarize", context: 5000, turns: 1 }));
    r.handle(
      tokenUsage(
        { cache_read: 0, cache_write: 0, output: 0, total: 14000 },
        { cache_read: 0, cache_write: 0, output: 0, total: 6000 },
      ),
    );
    r.handle(compactionEnd({ reason: "context", mode: "summarize", status: "completed" }));
    r.endTask(1000);
    const s = stripAnsi(text());
    // The compaction-done line still shows this call's usage: session cumulative 14k + this compaction's 6k.
    expect(s).toContain(
      "[compaction] done; continuing with the summarized context · tokens 14k (+6k)",
    );
    // Stats line: context stays at the ordinary-request figure of 5k; cumulative tokens 14k (includes
    // compaction, following the provider), but this turn's **delta** is only the ordinary request's 5k —
    // compaction after the turn ends is not attributed to this turn.
    expect(s).toContain("context 5k");
    expect(s).toContain("tokens 14k (+5k)");
  });

  it("mid-turn compaction (a normal request_end follows): elapsed time includes the compaction span, Token delta includes compaction", () => {
    const { stream, text } = collector();
    const r = new StreamRenderer(stream, t);
    // own1: ordinary request, request 5000, 00:00 -> 00:02.
    r.handle(at("2026-07-05T00:00:00.000Z", requestBegin()));
    r.handle(at("2026-07-05T00:00:01.000Z", usage(5000, 5000)));
    r.handle(at("2026-07-05T00:00:02.000Z", requestEnd("completed")));
    // Mid-turn compaction: 00:03 -> 00:13, request 6000 (the compaction's own summarization request).
    r.handle(
      at(
        "2026-07-05T00:00:03.000Z",
        compactionBegin({ reason: "context", mode: "summarize", context: 5000, turns: 1 }),
      ),
    );
    r.handle(at("2026-07-05T00:00:04.000Z", requestBegin()));
    r.handle(at("2026-07-05T00:00:10.000Z", usage(6000, 14000)));
    r.handle(at("2026-07-05T00:00:12.000Z", requestEnd("completed")));
    r.handle(
      at(
        "2026-07-05T00:00:13.000Z",
        compactionEnd({ reason: "context", mode: "summarize", status: "completed" }),
      ),
    );
    // The turn continues after compaction (carry-over): own2 request 2000, final request_end at 00:16 -> settles the compaction usage.
    r.handle(at("2026-07-05T00:00:14.000Z", requestBegin()));
    r.handle(at("2026-07-05T00:00:15.000Z", usage(2000, 16000)));
    r.handle(at("2026-07-05T00:00:16.000Z", requestEnd("completed")));
    r.endTask(999); // the passed-in wall clock is ignored: with a request_end present, elapsed comes from the timestamp span
    const s = stripAnsi(text());
    // Elapsed = first event 00:00 -> the last non-compaction request_end 00:16 = 16s (includes the 10s of
    // compaction in the middle, which occupied this turn's wall clock).
    // Token delta = own1 5000 + own2 2000 + compaction 6000 = 13k; context uses the ordinary-request figure after compaction, 2k.
    expect(s).toContain("context 2k");
    expect(s).toContain("tokens 16k (+13k)");
    expect(s).toContain("16s (+16s)");
  });

  it("compaction after the turn ends (with request events): elapsed time stops at the last request_end before compaction", () => {
    const { stream, text } = collector();
    const r = new StreamRenderer(stream, t);
    // own1: 00:00 -> 00:03.
    r.handle(at("2026-07-05T00:00:00.000Z", requestBegin()));
    r.handle(at("2026-07-05T00:00:01.000Z", usage(5000, 5000)));
    r.handle(at("2026-07-05T00:00:03.000Z", requestEnd("completed")));
    // Trailing compaction: 00:04 -> 00:24, a full 20s, with no ordinary request_end for this turn after it.
    r.handle(
      at(
        "2026-07-05T00:00:04.000Z",
        compactionBegin({ reason: "context", mode: "summarize", context: 5000, turns: 1 }),
      ),
    );
    r.handle(at("2026-07-05T00:00:05.000Z", requestBegin()));
    r.handle(at("2026-07-05T00:00:20.000Z", usage(6000, 14000)));
    r.handle(at("2026-07-05T00:00:23.000Z", requestEnd("completed")));
    r.handle(
      at(
        "2026-07-05T00:00:24.000Z",
        compactionEnd({ reason: "context", mode: "summarize", status: "completed" }),
      ),
    );
    r.endTask(999);
    const s = stripAnsi(text());
    // Elapsed = 00:00 -> the last non-compaction request_end before compaction, 00:03 = 3s (the whole 20s
    // compaction span comes after it and does not count).
    // Token delta is only own1's 5k; compaction's 6k is not attributed to this turn.
    expect(s).toContain("tokens 14k (+5k)");
    expect(s).toContain("3s (+3s)");
  });

  it("renders approval_decision events (approved / denied)", () => {
    const { stream, text } = collector();
    const r = new StreamRenderer(stream, t);
    r.handle(approvalDecision("allow", "c1"));
    r.handle(approvalDecision("deny", "c2"));
    const s = stripAnsi(text());
    expect(s).toContain("[approved]");
    expect(s).toContain("[denied]");
  });

  it("keeps call → decision contiguous at prompt time and dedupes the late approval_decision event", () => {
    const { stream, text } = collector();
    const r = new StreamRenderer(stream, t);
    const tc = toolCall({ name: "exec_command", arguments: '{"cmd":"pwd"}', toolCallId: "p8" });
    // Interactive approval: while locked, renders "call line -> (prompt, written directly by readline) -> result" as three contiguous lines.
    r.beginUserPrompt(tc);
    r.noteApprovalDecision(tc, "allow");
    r.endUserPrompt();
    expect(stripAnsi(text())).toBe("[tool-p8] exec_command <- $ pwd\n✓ [approved]\n");
    // A late approval_decision event is deduped by key and not re-rendered.
    r.handle(approvalDecision("allow", "p8"));
    expect(stripAnsi(text())).toBe("[tool-p8] exec_command <- $ pwd\n✓ [approved]\n");
  });

  it("prints the decoded file-tool payload before the approval prompt, without duplicating the call line", () => {
    const { stream, text } = collector();
    const r = new StreamRenderer(stream, t);
    const tc = toolCall({
      name: "edit_file",
      arguments: JSON.stringify({ file_path: "src/x.ts", old_string: "a", new_string: "b" }),
      toolCallId: "fp1",
    });
    r.beginUserPrompt(tc);
    r.noteApprovalDecision(tc, "allow");
    r.endUserPrompt();
    // Call line, payload lines (what the user is approving), then the result — with no
    // duplicated call line after the payload.
    expect(stripAnsi(text())).toBe(
      "[tool-fp1] edit_file src/x.ts\n" +
        "file_path: src/x.ts\nold_string: a\nnew_string: b\n" +
        "✓ [approved]\n",
    );
  });

  it("re-renders a half-streamed call line at approval and suppresses its late tail deltas", () => {
    const { stream, text } = collector();
    const r = new StreamRenderer(stream, t);
    const tc = toolCall({
      name: "exec_command",
      arguments: '{"cmd":"git status"}',
      toolCallId: "h7",
    });
    // The call line is still mid-stream (only half its arguments rendered) when approval begins.
    r.handle(partialToolCall({ eventType: "start", name: "exec_command", toolCallId: "h7" }));
    r.handle(
      partialToolCall({
        eventType: "delta",
        name: "",
        arguments: '{"cmd":"git st',
        toolCallId: "h7",
      }),
    );
    r.beginUserPrompt(tc);
    // The trailing delta / stop arrive queued while the screen is locked.
    r.handle(
      partialToolCall({ eventType: "delta", name: "", arguments: 'atus"}', toolCallId: "h7" }),
    );
    r.handle(partialToolCall({ eventType: "stop", name: "", toolCallId: "h7" }));
    r.noteApprovalDecision(tc, "allow");
    r.endUserPrompt();
    const s = stripAnsi(text());
    // At approval time, the full call line is re-rendered in place from the complete message, right next to
    // the result; after unlocking, the late tail is deduped and must not start a duplicate call line after
    // the result line.
    expect(s).toContain("[tool-h7] exec_command <- $ git status\n✓ [approved]\n");
    expect(s.slice(s.indexOf("[approved]"))).not.toContain("[tool-h7]");
  });

  it("defers another call's auto-approval rendering while an interactive prompt is active", () => {
    const { stream, text } = collector();
    const r = new StreamRenderer(stream, t);
    const parent = toolCall({
      name: "exec_command",
      arguments: '{"cmd":"pwd"}',
      toolCallId: "pa1",
    });
    const child = withOrigin(
      toolCall({ name: "exec_command", arguments: '{"cmd":"ls"}', toolCallId: "ch2" }),
      "sess_kid",
    );
    r.beginUserPrompt(parent); // parent call's interactive prompt: locks the screen
    r.noteApprovalDecision(child, "allow"); // concurrent subagent auto-approval: deferred, not inserted mid-prompt
    expect(stripAnsi(text())).not.toContain("ch2");
    r.noteApprovalDecision(parent, "allow"); // the prompt owner's result renders in place as usual
    r.endUserPrompt();
    const s = stripAnsi(text());
    // Order: parent call line -> parent result -> child call line -> child result.
    const iParentOk = s.indexOf("[approved]");
    const iChildCall = s.indexOf("[agent-kid-tool-ch2]");
    expect(s.indexOf("[tool-pa1]")).toBeGreaterThanOrEqual(0);
    expect(iChildCall).toBeGreaterThan(iParentOk);
    expect(s.indexOf("[approved]", iChildCall)).toBeGreaterThan(iChildCall);
  });

  it("endCompact settles manual /compact usage so the next task's delta excludes it", () => {
    const { stream, text } = collector();
    const r = new StreamRenderer(stream, t);
    r.handle(
      tokenUsage(
        { cache_read: 0, cache_write: 0, output: 0, total: 8000 },
        { cache_read: 0, cache_write: 0, output: 0, total: 5000 },
      ),
    );
    r.endTask(1000);
    // Manual /compact: the compaction request consumes 6000 (already shown on the compaction-done line), endCompact settles it.
    r.handle(compactionBegin({ reason: "manual", mode: "summarize", context: 5000, turns: 1 }));
    r.handle(
      tokenUsage(
        { cache_read: 0, cache_write: 0, output: 0, total: 14000 },
        { cache_read: 0, cache_write: 0, output: 0, total: 6000 },
      ),
    );
    r.handle(compactionEnd({ reason: "manual", mode: "summarize", status: "completed" }));
    r.endCompact(500);
    // The next task consumes only 1000: its delta must not include compaction's 6000.
    r.handle(
      tokenUsage(
        { cache_read: 0, cache_write: 0, output: 0, total: 15000 },
        { cache_read: 0, cache_write: 0, output: 0, total: 1000 },
      ),
    );
    r.endTask(1000);
    const lines = stripAnsi(text()).trim().split("\n");
    expect(lines[lines.length - 1]).toContain("tokens 15k (+1k)");
  });

  it("re-renders the call line next to the decision when other output separated them (auto-approve)", () => {
    const { stream, text } = collector();
    const r = new StreamRenderer(stream, t);
    // The call line is first rendered while streaming, then separated from the decision by other output.
    r.handle(partialToolCall({ eventType: "start", name: "exec_command", toolCallId: "c5" }));
    r.handle(
      partialToolCall({
        eventType: "delta",
        name: "",
        arguments: '{"cmd":"ls"}',
        toolCallId: "c5",
      }),
    );
    r.handle(partialToolCall({ eventType: "stop", name: "", toolCallId: "c5" }));
    r.handle(partialText("start", ""));
    r.handle(partialText("delta", "hi"));
    r.handle(partialText("stop", "", "completed"));
    // Auto-approval: the call line is no longer adjacent -> it is re-rendered in place, with the result immediately following it as a pair.
    r.noteApprovalDecision(
      toolCall({ name: "exec_command", arguments: '{"cmd":"ls"}', toolCallId: "c5" }),
      "allow",
    );
    expect(stripAnsi(text())).toBe(
      "[tool-c5] exec_command <- $ ls\nhi\n[tool-c5] exec_command <- $ ls\n✓ [approved]\n",
    );
  });

  it("does not re-render the call line when it is already adjacent to the decision", () => {
    const { stream, text } = collector();
    const r = new StreamRenderer(stream, t);
    r.handle(partialToolCall({ eventType: "start", name: "exec_command", toolCallId: "c6" }));
    r.handle(
      partialToolCall({
        eventType: "delta",
        name: "",
        arguments: '{"cmd":"ls"}',
        toolCallId: "c6",
      }),
    );
    r.handle(partialToolCall({ eventType: "stop", name: "", toolCallId: "c6" }));
    r.noteApprovalDecision(
      toolCall({ name: "exec_command", arguments: '{"cmd":"ls"}', toolCallId: "c6" }),
      "deny",
    );
    expect(stripAnsi(text())).toBe("[tool-c6] exec_command <- $ ls\n× [denied]\n");
  });
});

describe("StreamRenderer — nested (origin-tagged) subagent messages", () => {
  const hop: MessageOrigin = "sess_child";

  it("renders nested tool calls with an agent-tool tag; skips nested text/thinking partials", () => {
    const { stream, text } = collector();
    const r = new StreamRenderer(stream, t);
    // Nested text/thinking is not rendered (the child's reply is shown via the parent tool's output gutter).
    r.handle(withOrigin(partialText("delta", "child text"), hop));
    r.handle(withOrigin(partialThinking("delta", "child think"), hop));
    // A nested complete tool_call renders one line (so the user can see what tool the subagent is calling
    // before approval); the tag is agent-<last-3-chars-of-child-session>-tool-<last-3-chars-of-id>; the
    // approval line carries no tag.
    r.handle(
      withOrigin(
        toolCall({ name: "exec_command", arguments: '{"cmd":"ls"}', toolCallId: "cc1" }),
        hop,
      ),
    );
    r.handle(withOrigin(approvalDecision("allow", "cc1"), hop));
    expect(stripAnsi(text())).toBe("[agent-ild-tool-cc1] exec_command <- $ ls\n✓ [approved]\n");
  });

  it("renders the pending nested tool call at approval time when its stream copy has not arrived; dedupes the late copy", () => {
    const { stream, text } = collector();
    const r = new StreamRenderer(stream, t);
    const tc = withOrigin(
      toolCall({ name: "exec_command", arguments: '{"cmd":"ls"}', toolCallId: "cc9" }),
      hop,
    );
    // The approval callback arrives before the forwarded message: beginUserPrompt renders the call line directly from the complete message.
    r.beginUserPrompt(tc);
    expect(stripAnsi(text())).toBe("[agent-ild-tool-cc9] exec_command <- $ ls\n");
    r.endUserPrompt();
    // The late forwarded copy is deduped by key and not re-rendered.
    r.handle(tc);
    expect(stripAnsi(text())).toBe("[agent-ild-tool-cc9] exec_command <- $ ls\n");
  });

  it("renders the pending parent tool call at approval time and suppresses its late partial stream", () => {
    const { stream, text } = collector();
    const r = new StreamRenderer(stream, t);
    r.beginUserPrompt(
      toolCall({ name: "exec_command", arguments: '{"cmd":"pwd"}', toolCallId: "p7" }),
    );
    r.endUserPrompt();
    // The whole late streaming copy is deduped and skipped.
    r.handle(partialToolCall({ eventType: "start", name: "exec_command", toolCallId: "p7" }));
    r.handle(
      partialToolCall({
        eventType: "delta",
        name: "",
        arguments: '{"cmd":"pwd"}',
        toolCallId: "p7",
      }),
    );
    r.handle(partialToolCall({ eventType: "stop", name: "", toolCallId: "p7" }));
    expect(stripAnsi(text())).toBe("[tool-p7] exec_command <- $ pwd\n");
  });

  it("adds nested token_usage request totals to the task delta and the session total", () => {
    const { stream, text } = collector();
    const r = new StreamRenderer(stream, t);
    // One parent-session request: 1500; one child-session request: 2000 -> per-task delta 3.5k;
    // session cumulative = parent 8000 + child 2000 = 10k (delta and cumulative use the same basis: parent + child).
    r.handle(
      tokenUsage(
        { cache_read: 0, cache_write: 0, output: 0, total: 8000 },
        { cache_read: 0, cache_write: 0, output: 200, total: 1500 },
      ),
    );
    r.handle(
      withOrigin(
        tokenUsage(
          { cache_read: 0, cache_write: 0, output: 0, total: 2000 },
          { cache_read: 0, cache_write: 0, output: 100, total: 2000 },
        ),
        hop,
      ),
    );
    r.endTask(1000);
    const s1 = stripAnsi(text());
    expect(s1).toContain("3.5k"); // the per-task delta includes child-session usage
    expect(s1).toContain("10k"); // the session cumulative includes child-session usage
    // The child session's cumulative persists across tasks: the next task consumes only from the parent session, cumulative = 9000 + 2000 = 11k (+1k).
    r.handle(
      tokenUsage(
        { cache_read: 0, cache_write: 0, output: 0, total: 9000 },
        { cache_read: 0, cache_write: 0, output: 100, total: 1000 },
      ),
    );
    r.endTask(1000);
    const lines = stripAnsi(text()).trim().split("\n");
    const last = lines[lines.length - 1]!;
    expect(last).toContain("11k");
    expect(last).toContain("+1k");
  });

  it("prints stats when a task only has nested (subagent) token usage", () => {
    const { stream, text } = collector();
    const r = new StreamRenderer(stream, t);
    r.handle(
      withOrigin(
        tokenUsage(
          { cache_read: 0, cache_write: 0, output: 0, total: 2000 },
          { cache_read: 0, cache_write: 0, output: 100, total: 2000 },
        ),
        hop,
      ),
    );
    r.endTask(1000);
    const s = stripAnsi(text());
    expect(s).toContain("[stats]");
    expect(s).toContain("2k (+2k)");
  });
});

describe("renderHistory (resume)", () => {
  it("renders complete messages statically with interruption markers", async () => {
    const { renderHistory } = await import("../src/render.js");
    const { userText } = await import("@prismshadow/penguin-core");
    const { stream, text } = collector();
    renderHistory(
      [
        userText("hello"),
        thinkingMessage("pondering"),
        assistantText("hi there"),
        toolCall({ name: "exec_command", arguments: '{"cmd":"ls"}', toolCallId: "call_653" }),
        toolCallOutput({ output: "a.txt\nb.txt", toolCallId: "call_653" }),
        assistantText("half answer", "aborted"),
      ],
      stream,
    );
    const s = stripAnsi(text());
    expect(s).toContain("> hello");
    expect(s).toContain("pondering");
    expect(s).toContain("hi there");
    expect(s).toContain("[tool-653] exec_command <- $ ls");
    expect(s).toContain("[tool-653] exec_command -> a.txt");
    expect(s).toContain("[tool-653] exec_command -> b.txt");
    // An interrupted message carries a marker (rendering includes the interrupted turn).
    expect(s).toContain("half answer [aborted]");
  });

  it("skips events and renders nothing for empty history", async () => {
    const { renderHistory } = await import("../src/render.js");
    const { stream, text } = collector();
    renderHistory(
      [
        tokenUsage(
          { cache_read: 0, cache_write: 0, output: 0, total: 1 },
          { cache_read: 0, cache_write: 0, output: 0, total: 1 },
        ),
      ],
      stream,
    );
    expect(text()).toBe("");
  });
});

describe("mid-run steering rendering ([user_steering] user messages)", () => {
  it("streaming: a complete [user_steering] user text renders as prefixed steering lines (other complete texts stay unrendered)", () => {
    const { stream, text } = collector();
    const r = new StreamRenderer(stream, t);
    r.handle(userText("[user_steering]\nfocus on tests\nand docs\n[/user_steering]"));
    r.handle(userText("a plain prompt")); // normal prompts are local echoes: never re-rendered
    r.handle(assistantText("complete assistant text")); // complete assistant text: already streamed
    const plain = stripAnsi(text());
    expect(plain).toContain("↪ user: focus on tests");
    expect(plain).toContain("↪ user: and docs");
    expect(plain).not.toContain("[user_steering]");
    expect(plain).not.toContain("a plain prompt");
    expect(plain).not.toContain("complete assistant text");
  });

  it("renderHistory: steering user texts render with the steering prefix, not as a prompt line", () => {
    const { stream, text } = collector();
    renderHistory(
      [userText("run the tests"), userText("[user_steering]\nswitch branch\n[/user_steering]")],
      stream,
      t,
    );
    const plain = stripAnsi(text());
    expect(plain).toContain("> run the tests");
    expect(plain).toContain("↪ user: switch branch");
    expect(plain).not.toContain("> [user_steering]");
  });

  it("setInputHold: rendering is held while the user composes a line and flushes on release; printLine lands before the flush", () => {
    const { stream, text } = collector();
    const r = new StreamRenderer(stream, t);
    r.setInputHold(true);
    r.handle(partialText("start", ""));
    r.handle(partialText("delta", "streamed while typing"));
    expect(stripAnsi(text())).not.toContain("streamed while typing");
    // The steering ack prints immediately (through the renderer, ahead of held output).
    r.printLine("» steering queued: do it");
    expect(stripAnsi(text())).toContain("» steering queued: do it");
    expect(stripAnsi(text())).not.toContain("streamed while typing");
    r.setInputHold(false);
    expect(stripAnsi(text())).toContain("streamed while typing");
    expect(stripAnsi(text()).indexOf("» steering queued")).toBeLessThan(
      stripAnsi(text()).indexOf("streamed while typing"),
    );
  });

  it("endTask force-releases the input hold (safety net)", () => {
    const { stream, text } = collector();
    const r = new StreamRenderer(stream, t);
    r.setInputHold(true);
    r.handle(partialText("start", ""));
    r.handle(partialText("delta", "tail output"));
    r.handle(partialText("stop"));
    r.endTask(10);
    expect(stripAnsi(text())).toContain("tail output");
  });
});

describe("supportsColor (color gating, issue #102)", () => {
  it("requires a TTY when no color variables are set", () => {
    expect(supportsColor({ isTTY: true }, {})).toBe(true);
    expect(supportsColor({ isTTY: false }, {})).toBe(false);
    expect(supportsColor({}, {})).toBe(false);
  });

  it("NO_COLOR (non-empty) and TERM=dumb turn color off even on a TTY", () => {
    expect(supportsColor({ isTTY: true }, { NO_COLOR: "1" })).toBe(false);
    expect(supportsColor({ isTTY: true }, { TERM: "dumb" })).toBe(false);
    // An empty NO_COLOR counts as unset (no-color.org).
    expect(supportsColor({ isTTY: true }, { NO_COLOR: "" })).toBe(true);
  });

  it("FORCE_COLOR overrides everything, NO_COLOR and pipes included (Node semantics)", () => {
    expect(supportsColor({ isTTY: false }, { FORCE_COLOR: "1" })).toBe(true);
    // The exact environment #102 observed in the nested CLI: FORCE_COLOR=3 + NO_COLOR=1 + TERM=dumb.
    expect(supportsColor({ isTTY: false }, { FORCE_COLOR: "3", NO_COLOR: "1", TERM: "dumb" })).toBe(
      true,
    );
    expect(supportsColor({ isTTY: true }, { FORCE_COLOR: "0" })).toBe(false);
    // An empty FORCE_COLOR is not an override; the normal gate applies.
    expect(supportsColor({ isTTY: false }, { FORCE_COLOR: "" })).toBe(false);
  });
});

describe("StreamRenderer color wiring (issue #102)", () => {
  it("a piped (non-TTY) stream gets no ANSI escapes at all", () => {
    // Neutralize any ambient override; the collector stream is not a TTY, so the palette is plain.
    vi.stubEnv("FORCE_COLOR", "");
    const { stream, text } = collector();
    const r = new StreamRenderer(stream, t);
    r.handle(partialToolCall({ eventType: "start", name: "exec_command", toolCallId: "p1" }));
    r.handle(
      partialToolCall({
        eventType: "delta",
        name: "",
        arguments: '{"cmd":"cat todo.md"}',
        toolCallId: "p1",
      }),
    );
    r.handle(partialToolCall({ eventType: "stop", name: "", toolCallId: "p1" }));
    r.handle(partialToolCallOutput({ eventType: "start", toolCallId: "p1" }));
    r.handle(partialToolCallOutput({ eventType: "delta", output: "hello\n", toolCallId: "p1" }));
    r.handle(partialToolCallOutput({ eventType: "stop", toolCallId: "p1" }));
    r.handle(thinkingMessage("hmm"));
    r.handle(partialThinking("start", ""));
    r.handle(partialThinking("delta", "pondering"));
    r.handle(partialThinking("stop"));
    const raw = text();
    expect(raw).not.toContain("\x1b");
    expect(raw).toContain("[tool-p1] exec_command <- $ cat todo.md");
    expect(raw).toContain("[tool-p1] exec_command -> hello");
  });

  it("renderHistory on a piped stream is escape-free too", () => {
    vi.stubEnv("FORCE_COLOR", "");
    const { stream, text } = collector();
    renderHistory(
      [
        userText("hi"),
        thinkingMessage("t"),
        toolCall({ name: "exec_command", arguments: '{"cmd":"ls"}', toolCallId: "h1" }),
        toolCallOutput({ output: "a.txt\n", toolCallId: "h1" }),
      ],
      stream,
      t,
    );
    const raw = text();
    expect(raw).not.toContain("\x1b");
    expect(raw).toContain("[tool-h1] exec_command <- $ ls");
  });

  it("FORCE_COLOR=1 re-enables color on a pipe and defeats NO_COLOR", () => {
    vi.stubEnv("FORCE_COLOR", "1");
    vi.stubEnv("NO_COLOR", "1");
    const { stream, text } = collector();
    const r = new StreamRenderer(stream, t);
    r.handle(partialToolCall({ eventType: "start", name: "exec_command", toolCallId: "f1" }));
    r.handle(
      partialToolCall({
        eventType: "delta",
        name: "",
        arguments: '{"cmd":"ls"}',
        toolCallId: "f1",
      }),
    );
    r.handle(partialToolCall({ eventType: "stop", name: "", toolCallId: "f1" }));
    expect(text()).toContain("\x1b[36m");
  });
});
