@fnclaude/renderer 0.0.1 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +75 -0
  3. package/package.json +47 -3
  4. package/src/App.test.tsx +130 -0
  5. package/src/App.tsx +321 -0
  6. package/src/__fixtures__/events.ts +66 -0
  7. package/src/__fixtures__/multi-turn.ndjson +11 -0
  8. package/src/__fixtures__/slash-command-turn.ndjson +3 -0
  9. package/src/__fixtures__/text-turn.ndjson +4 -0
  10. package/src/__fixtures__/tool-use-turn.ndjson +6 -0
  11. package/src/__fixtures__/unknown-slash-command-turn.ndjson +2 -0
  12. package/src/claude-process.test.ts +196 -0
  13. package/src/claude-process.ts +138 -0
  14. package/src/event-parser.test.ts +271 -0
  15. package/src/event-parser.ts +89 -0
  16. package/src/filter-state.test.ts +189 -0
  17. package/src/filter-state.ts +110 -0
  18. package/src/index.tsx +11 -0
  19. package/src/keybinds.test.ts +148 -0
  20. package/src/keybinds.ts +75 -0
  21. package/src/renderers/BashInput.test.tsx +61 -0
  22. package/src/renderers/BashInput.tsx +44 -0
  23. package/src/renderers/BashOutput.test.tsx +44 -0
  24. package/src/renderers/BashOutput.tsx +36 -0
  25. package/src/renderers/EditDiff.test.tsx +65 -0
  26. package/src/renderers/EditDiff.tsx +73 -0
  27. package/src/renderers/ErrorRenderer.test.tsx +25 -0
  28. package/src/renderers/ErrorRenderer.tsx +22 -0
  29. package/src/renderers/ReadContent.test.tsx +46 -0
  30. package/src/renderers/ReadContent.tsx +37 -0
  31. package/src/renderers/ReadInput.test.tsx +18 -0
  32. package/src/renderers/ReadInput.tsx +19 -0
  33. package/src/renderers/ResultRenderer.test.tsx +52 -0
  34. package/src/renderers/ResultRenderer.tsx +26 -0
  35. package/src/renderers/SystemInit.test.tsx +33 -0
  36. package/src/renderers/SystemInit.tsx +22 -0
  37. package/src/renderers/TaskNested.test.tsx +58 -0
  38. package/src/renderers/TaskNested.tsx +49 -0
  39. package/src/renderers/TextRenderer.test.tsx +37 -0
  40. package/src/renderers/TextRenderer.tsx +80 -0
  41. package/src/renderers/ThinkingRenderer.test.tsx +45 -0
  42. package/src/renderers/ThinkingRenderer.tsx +52 -0
  43. package/src/renderers/ToolResultRenderer.test.tsx +105 -0
  44. package/src/renderers/ToolResultRenderer.tsx +66 -0
  45. package/src/renderers/ToolUseRenderer.test.tsx +99 -0
  46. package/src/renderers/ToolUseRenderer.tsx +112 -0
  47. package/src/renderers/WriteContent.test.tsx +53 -0
  48. package/src/renderers/WriteContent.tsx +47 -0
  49. package/src/renderers/index.ts +50 -0
  50. package/src/renderers/summarize.ts +27 -0
  51. package/src/types/events.test.ts +43 -0
  52. package/src/types/events.ts +145 -0
@@ -0,0 +1,18 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { render } from "ink-testing-library";
3
+ import { ReadInput } from "./ReadInput.tsx";
4
+
5
+ describe("ReadInput", () => {
6
+ test("renders path being read", () => {
7
+ const { lastFrame } = render(<ReadInput filePath="/etc/hosts" />);
8
+ expect(lastFrame() ?? "").toContain("/etc/hosts");
9
+ });
10
+
11
+ test("includes line range when offset/limit present", () => {
12
+ const { lastFrame } = render(<ReadInput filePath="/etc/hosts" offset={10} limit={20} />);
13
+ const frame = lastFrame() ?? "";
14
+ expect(frame).toContain("/etc/hosts");
15
+ expect(frame).toMatch(/10/);
16
+ expect(frame).toMatch(/20/);
17
+ });
18
+ });
@@ -0,0 +1,19 @@
1
+ import { Text } from "ink";
2
+
3
+ export interface ReadInputProps {
4
+ filePath: string;
5
+ offset?: number | undefined;
6
+ limit?: number | undefined;
7
+ }
8
+
9
+ /**
10
+ * Renders the path being read by a Read tool_use. This is the *call*
11
+ * side; the resulting file content is filterable via Read.content on
12
+ * the result side. The call line itself is always shown — it's a
13
+ * tool-call header, not a filterable element.
14
+ */
15
+ export function ReadInput({ filePath, offset, limit }: ReadInputProps): JSX.Element {
16
+ const range =
17
+ offset !== undefined || limit !== undefined ? ` [${offset ?? 0}:${limit ?? "end"}]` : "";
18
+ return <Text>{`▸ Read: ${filePath}${range}`}</Text>;
19
+ }
@@ -0,0 +1,52 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { render } from "ink-testing-library";
3
+ import type { ResultEvent } from "../types/events.ts";
4
+ import { ResultRenderer } from "./ResultRenderer.tsx";
5
+
6
+ const baseEvent: ResultEvent = {
7
+ type: "result",
8
+ subtype: "success",
9
+ is_error: false,
10
+ session_id: "s1",
11
+ uuid: "u1",
12
+ result: "All good.",
13
+ num_turns: 1,
14
+ duration_ms: 10,
15
+ duration_api_ms: 5,
16
+ total_cost_usd: 0,
17
+ };
18
+
19
+ describe("ResultRenderer", () => {
20
+ test("renders the result text", () => {
21
+ const { lastFrame } = render(<ResultRenderer event={baseEvent} />);
22
+ expect(lastFrame() ?? "").toContain("All good.");
23
+ });
24
+
25
+ test("detects 'Unknown command:' prefix and styles as a soft warning", () => {
26
+ const ev: ResultEvent = { ...baseEvent, result: "Unknown command: /foo" };
27
+ const { lastFrame } = render(<ResultRenderer event={ev} />);
28
+ const frame = lastFrame() ?? "";
29
+ expect(frame).toContain("Unknown command: /foo");
30
+ // Yellow ANSI (CSI 33) for soft warning
31
+ expect(frame).toMatch(/\x1B\[33m/);
32
+ });
33
+
34
+ test("non-Unknown result does NOT use yellow warning color", () => {
35
+ const { lastFrame } = render(<ResultRenderer event={baseEvent} />);
36
+ const frame = lastFrame() ?? "";
37
+ expect(frame).not.toMatch(/\x1B\[33m/);
38
+ });
39
+
40
+ test("is_error: delegates to error styling (red)", () => {
41
+ const ev: ResultEvent = {
42
+ ...baseEvent,
43
+ is_error: true,
44
+ subtype: "error",
45
+ result: "kaboom",
46
+ };
47
+ const { lastFrame } = render(<ResultRenderer event={ev} />);
48
+ const frame = lastFrame() ?? "";
49
+ expect(frame).toContain("kaboom");
50
+ expect(frame).toMatch(/\x1B\[31m/);
51
+ });
52
+ });
@@ -0,0 +1,26 @@
1
+ import { Text } from "ink";
2
+ import type { ResultEvent } from "../types/events.ts";
3
+ import { ErrorRenderer } from "./ErrorRenderer.tsx";
4
+
5
+ export interface ResultRendererProps {
6
+ event: ResultEvent;
7
+ }
8
+
9
+ /**
10
+ * Renders a ResultEvent. The `result` text is always shown (status-line
11
+ * style). Two special cases:
12
+ *
13
+ * - is_error: true → delegate to ErrorRenderer (red bold).
14
+ * - "Unknown command:" prefix → soft warning (yellow), per
15
+ * docs/stream-json-findings.md: claude reports unknown slash commands
16
+ * via result.result with is_error: false.
17
+ */
18
+ export function ResultRenderer({ event }: ResultRendererProps): JSX.Element {
19
+ if (event.is_error) {
20
+ return <ErrorRenderer message={event.result} label="result" />;
21
+ }
22
+ if (event.result.startsWith("Unknown command:")) {
23
+ return <Text color="yellow">{event.result}</Text>;
24
+ }
25
+ return <Text>{event.result}</Text>;
26
+ }
@@ -0,0 +1,33 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { render } from "ink-testing-library";
3
+ import type { SystemEvent } from "../types/events.ts";
4
+ import { SystemInit } from "./SystemInit.tsx";
5
+
6
+ const baseEvent: SystemEvent = {
7
+ type: "system",
8
+ subtype: "init",
9
+ session_id: "sess-abc123",
10
+ uuid: "u-1",
11
+ cwd: "/home/user/proj",
12
+ model: "claude-sonnet-4",
13
+ };
14
+
15
+ describe("SystemInit", () => {
16
+ test("renders session_id, cwd, model on one line", () => {
17
+ const { lastFrame } = render(<SystemInit event={baseEvent} />);
18
+ const frame = lastFrame() ?? "";
19
+ expect(frame).toContain("sess-abc123");
20
+ expect(frame).toContain("/home/user/proj");
21
+ expect(frame).toContain("claude-sonnet-4");
22
+ // single line (no embedded newlines other than trailing)
23
+ const lines = frame.trim().split("\n");
24
+ expect(lines.length).toBe(1);
25
+ });
26
+
27
+ test("tolerates missing optional fields", () => {
28
+ const { lastFrame } = render(
29
+ <SystemInit event={{ type: "system", subtype: "init", session_id: "s1", uuid: "u1" }} />,
30
+ );
31
+ expect(lastFrame() ?? "").toContain("s1");
32
+ });
33
+ });
@@ -0,0 +1,22 @@
1
+ import { Text } from "ink";
2
+ import type { SystemEvent } from "../types/events.ts";
3
+
4
+ export interface SystemInitProps {
5
+ event: SystemEvent;
6
+ }
7
+
8
+ /**
9
+ * One-line session header for SystemEvent.subtype === "init".
10
+ * Mostly hidden by default; surfaces session_id, cwd, model on one line.
11
+ */
12
+ export function SystemInit({ event }: SystemInitProps): JSX.Element {
13
+ const parts: string[] = [`session=${event.session_id}`];
14
+ if (event.cwd) parts.push(`cwd=${event.cwd}`);
15
+ if (event.model) parts.push(`model=${event.model}`);
16
+ return (
17
+ <Text dimColor>
18
+ {"▸ "}
19
+ {parts.join(" ")}
20
+ </Text>
21
+ );
22
+ }
@@ -0,0 +1,58 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { render } from "ink-testing-library";
3
+ import { TaskNested } from "./TaskNested.tsx";
4
+
5
+ const prompt = `Find all TypeScript files.
6
+ Then count lines of code in each.
7
+ Report top 10 by size.
8
+ Include only .ts and .tsx.
9
+ Use ripgrep.
10
+ Output JSON.`;
11
+
12
+ describe("TaskNested", () => {
13
+ test("show: full prompt rendered", () => {
14
+ const { lastFrame } = render(
15
+ <TaskNested description="audit ts" prompt={prompt} visibility="show" />,
16
+ );
17
+ const frame = lastFrame() ?? "";
18
+ expect(frame).toContain("Find all TypeScript");
19
+ expect(frame).toContain("Output JSON");
20
+ });
21
+
22
+ test("hide: header only", () => {
23
+ const { lastFrame } = render(
24
+ <TaskNested description="audit ts" prompt={prompt} visibility="hide" />,
25
+ );
26
+ const frame = lastFrame() ?? "";
27
+ expect(frame).toContain("Task");
28
+ expect(frame).toContain("audit ts");
29
+ expect(frame).not.toContain("Find all TypeScript");
30
+ });
31
+
32
+ test("summary: first line + line count", () => {
33
+ const { lastFrame } = render(
34
+ <TaskNested description="audit ts" prompt={prompt} visibility="summary" />,
35
+ );
36
+ const frame = lastFrame() ?? "";
37
+ expect(frame).toContain("Find all TypeScript files.");
38
+ expect(frame).toMatch(/6 lines/);
39
+ expect(frame).not.toContain("Output JSON");
40
+ });
41
+
42
+ test("dim: full body with dim styling", () => {
43
+ const { lastFrame } = render(<TaskNested description="t" prompt="hello" visibility="dim" />);
44
+ const frame = lastFrame() ?? "";
45
+ expect(frame).toContain("hello");
46
+ expect(frame).toMatch(/\x1B\[2m/);
47
+ });
48
+
49
+ test("show vs hide vs summary differ", () => {
50
+ const p = { description: "d", prompt };
51
+ const a = render(<TaskNested {...p} visibility="show" />).lastFrame() ?? "";
52
+ const b = render(<TaskNested {...p} visibility="hide" />).lastFrame() ?? "";
53
+ const c = render(<TaskNested {...p} visibility="summary" />).lastFrame() ?? "";
54
+ expect(a).not.toBe(b);
55
+ expect(a).not.toBe(c);
56
+ expect(b).not.toBe(c);
57
+ });
58
+ });
@@ -0,0 +1,49 @@
1
+ import { Box, Text } from "ink";
2
+ import type { Visibility } from "../types/events.ts";
3
+ import { countLines } from "./summarize.ts";
4
+
5
+ export interface TaskNestedProps {
6
+ description?: string | undefined;
7
+ prompt: string;
8
+ visibility: Visibility;
9
+ }
10
+
11
+ /**
12
+ * Renders a Task tool_use: prompt sent to a subagent.
13
+ * Filterable element id: "Task.nested".
14
+ *
15
+ * Visibility:
16
+ * show — full subagent prompt
17
+ * hide — header only ("▸ Task: <description>")
18
+ * summary — first line of prompt + line count
19
+ * dim — full prompt, ANSI-faint
20
+ */
21
+ export function TaskNested({ description, prompt, visibility }: TaskNestedProps): JSX.Element {
22
+ const header = `▸ Task${description ? `: ${description}` : ""}`;
23
+
24
+ if (visibility === "hide") {
25
+ return <Text dimColor>{header}</Text>;
26
+ }
27
+
28
+ if (visibility === "summary") {
29
+ const firstLine = prompt.split("\n", 1)[0] ?? "";
30
+ const total = countLines(prompt);
31
+ return <Text>{`${header}\n ${firstLine} (${total} lines)`}</Text>;
32
+ }
33
+
34
+ if (visibility === "dim") {
35
+ return (
36
+ <Box flexDirection="column">
37
+ <Text dimColor>{header}</Text>
38
+ <Text dimColor>{prompt}</Text>
39
+ </Box>
40
+ );
41
+ }
42
+
43
+ return (
44
+ <Box flexDirection="column">
45
+ <Text>{header}</Text>
46
+ <Text>{prompt}</Text>
47
+ </Box>
48
+ );
49
+ }
@@ -0,0 +1,37 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { render } from "ink-testing-library";
3
+ import { TextRenderer } from "./TextRenderer.tsx";
4
+
5
+ const md = "# Heading\n\nSome *italic* text.";
6
+
7
+ describe("TextRenderer", () => {
8
+ test("glow=null: renders raw markdown unchanged", () => {
9
+ const { lastFrame } = render(<TextRenderer text={md} glow={null} />);
10
+ const frame = lastFrame() ?? "";
11
+ expect(frame).toContain("# Heading");
12
+ expect(frame).toContain("Some *italic* text.");
13
+ });
14
+
15
+ test("glow injected: output differs from raw markdown path", () => {
16
+ const fakeGlow = (input: string) => `<<RENDERED:${input}>>`;
17
+ const plain = render(<TextRenderer text={md} glow={null} />).lastFrame() ?? "";
18
+ const glowed = render(<TextRenderer text={md} glow={fakeGlow} />).lastFrame() ?? "";
19
+ expect(plain).not.toBe(glowed);
20
+ expect(glowed).toContain("<<RENDERED:");
21
+ expect(glowed).toContain("# Heading"); // the input gets embedded
22
+ });
23
+
24
+ test("glow output containing ANSI escapes is passed through", () => {
25
+ const ansiGlow = (_input: string) => "\x1B[1mBOLD\x1B[0m text";
26
+ const { lastFrame } = render(<TextRenderer text="anything" glow={ansiGlow} />);
27
+ const frame = lastFrame() ?? "";
28
+ expect(frame).toContain("BOLD");
29
+ // ANSI escape opening sequence should survive
30
+ expect(frame).toMatch(/\x1B\[/);
31
+ });
32
+
33
+ test("empty text produces empty output", () => {
34
+ const { lastFrame } = render(<TextRenderer text="" glow={null} />);
35
+ expect((lastFrame() ?? "").trim()).toBe("");
36
+ });
37
+ });
@@ -0,0 +1,80 @@
1
+ import { Text } from "ink";
2
+
3
+ /**
4
+ * A glow-runner function: takes raw markdown, returns rendered output
5
+ * (typically containing ANSI escapes from `glow -s dark`).
6
+ *
7
+ * Injectable so tests can avoid depending on glow being on PATH in CI.
8
+ * - `undefined` (omitted) → use the module default (detect + run glow if
9
+ * present; else pass markdown through unchanged).
10
+ * - `null` → explicitly disable glow; render raw markdown.
11
+ * - `(md) => rendered` → use the provided runner.
12
+ */
13
+ export type GlowRunner = (markdown: string) => string;
14
+
15
+ export interface TextRendererProps {
16
+ text: string;
17
+ glow?: GlowRunner | null;
18
+ }
19
+
20
+ let cachedGlowPath: string | null | undefined;
21
+
22
+ /**
23
+ * Detect glow on PATH via `which glow`. Memoized. Returns null if not
24
+ * found. Synchronous (single subprocess at module load); never throws —
25
+ * an unreachable binary just yields null.
26
+ */
27
+ export function detectGlowPath(): string | null {
28
+ if (cachedGlowPath !== undefined) return cachedGlowPath;
29
+ try {
30
+ const proc = Bun.spawnSync({ cmd: ["which", "glow"], stdout: "pipe", stderr: "ignore" });
31
+ if (proc.exitCode === 0) {
32
+ const out = proc.stdout.toString().trim();
33
+ cachedGlowPath = out.length > 0 ? out : null;
34
+ } else {
35
+ cachedGlowPath = null;
36
+ }
37
+ } catch {
38
+ cachedGlowPath = null;
39
+ }
40
+ return cachedGlowPath;
41
+ }
42
+
43
+ /**
44
+ * Run `glow -s dark` against the markdown. Returns the rendered output
45
+ * (ANSI-styled); falls back to the raw input if glow exits non-zero.
46
+ */
47
+ export function runGlow(markdown: string, glowPath: string): string {
48
+ try {
49
+ const proc = Bun.spawnSync({
50
+ cmd: [glowPath, "-s", "dark"],
51
+ stdin: new TextEncoder().encode(markdown),
52
+ stdout: "pipe",
53
+ stderr: "ignore",
54
+ });
55
+ if (proc.exitCode === 0) return proc.stdout.toString();
56
+ } catch {
57
+ // fall through
58
+ }
59
+ return markdown;
60
+ }
61
+
62
+ /** The default runner used when the prop is omitted. */
63
+ const defaultGlowRunner: GlowRunner | null = (() => {
64
+ const path = detectGlowPath();
65
+ if (path === null) return null;
66
+ return (md: string) => runGlow(md, path);
67
+ })();
68
+
69
+ /**
70
+ * Renders an AssistantEvent.message.content[] TextBlock.
71
+ *
72
+ * Glow integration: if glow is on PATH at module init, pipes markdown
73
+ * through `glow -s dark`; otherwise renders raw markdown. Ink's <Text>
74
+ * passes ANSI escapes through unchanged, so glow's coloring survives.
75
+ */
76
+ export function TextRenderer({ text, glow }: TextRendererProps): JSX.Element {
77
+ const runner = glow === undefined ? defaultGlowRunner : glow;
78
+ const rendered = runner ? runner(text) : text;
79
+ return <Text>{rendered}</Text>;
80
+ }
@@ -0,0 +1,45 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { render } from "ink-testing-library";
3
+ import { ThinkingRenderer } from "./ThinkingRenderer.tsx";
4
+
5
+ const longThought =
6
+ "This is the first sentence. And here is the second sentence which keeps going. " +
7
+ "Third sentence. Fourth. Fifth. Sixth. Seventh. Eighth.";
8
+
9
+ describe("ThinkingRenderer", () => {
10
+ test("show: full content with dim styling", () => {
11
+ const { lastFrame } = render(<ThinkingRenderer thinking={longThought} visibility="show" />);
12
+ const frame = lastFrame() ?? "";
13
+ expect(frame).toContain("first sentence");
14
+ expect(frame).toContain("Eighth");
15
+ // Dim ANSI (CSI 2) should be present
16
+ expect(frame).toMatch(/\x1B\[2m/);
17
+ });
18
+
19
+ test("hide: renders nothing visible", () => {
20
+ const { lastFrame } = render(<ThinkingRenderer thinking={longThought} visibility="hide" />);
21
+ expect((lastFrame() ?? "").trim()).toBe("");
22
+ });
23
+
24
+ test("summary: first sentence + (thinking continues)", () => {
25
+ const { lastFrame } = render(<ThinkingRenderer thinking={longThought} visibility="summary" />);
26
+ const frame = lastFrame() ?? "";
27
+ expect(frame).toContain("This is the first sentence.");
28
+ expect(frame).toContain("thinking continues");
29
+ expect(frame).not.toContain("Eighth");
30
+ });
31
+
32
+ test("dim: full content (with dim escape)", () => {
33
+ const { lastFrame } = render(<ThinkingRenderer thinking="short thought" visibility="dim" />);
34
+ const frame = lastFrame() ?? "";
35
+ expect(frame).toContain("short thought");
36
+ expect(frame).toMatch(/\x1B\[2m/);
37
+ });
38
+
39
+ test("summary with single short sentence keeps it whole, no continuation marker", () => {
40
+ const { lastFrame } = render(<ThinkingRenderer thinking="Just one." visibility="summary" />);
41
+ const frame = lastFrame() ?? "";
42
+ expect(frame).toContain("Just one.");
43
+ expect(frame).not.toContain("thinking continues");
44
+ });
45
+ });
@@ -0,0 +1,52 @@
1
+ import { Text } from "ink";
2
+ import type { Visibility } from "../types/events.ts";
3
+
4
+ export interface ThinkingRendererProps {
5
+ thinking: string;
6
+ visibility: Visibility;
7
+ }
8
+
9
+ /**
10
+ * Renders a ThinkingBlock. Filterable element id: "thinking".
11
+ *
12
+ * Visibility semantics (see docs/filter-state-spec.md):
13
+ * show — full content (dim styling because thinking is meta)
14
+ * hide — render nothing
15
+ * summary — first sentence + "(thinking continues)" if more
16
+ * dim — full content with ANSI faint
17
+ */
18
+ export function ThinkingRenderer({
19
+ thinking,
20
+ visibility,
21
+ }: ThinkingRendererProps): JSX.Element | null {
22
+ if (visibility === "hide") return null;
23
+
24
+ if (visibility === "summary") {
25
+ const { head, hasMore } = firstSentence(thinking);
26
+ return (
27
+ <Text dimColor>
28
+ {"💭 "}
29
+ {head}
30
+ {hasMore ? " (thinking continues)" : ""}
31
+ </Text>
32
+ );
33
+ }
34
+
35
+ // show and dim both render full content with dim styling. Thinking is
36
+ // always rendered dim — "show" just means "full body visible at all".
37
+ return (
38
+ <Text dimColor>
39
+ {"💭 "}
40
+ {thinking}
41
+ </Text>
42
+ );
43
+ }
44
+
45
+ function firstSentence(text: string): { head: string; hasMore: boolean } {
46
+ const trimmed = text.trim();
47
+ // Match up to and including the first sentence terminator.
48
+ const m = trimmed.match(/^.*?[.!?](?=\s|$)/);
49
+ if (!m) return { head: trimmed, hasMore: false };
50
+ const head = m[0];
51
+ return { head, hasMore: head.length < trimmed.length };
52
+ }
@@ -0,0 +1,105 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { render } from "ink-testing-library";
3
+ import type { ToolResultBlock, Visibility } from "../types/events.ts";
4
+ import { ToolResultRenderer } from "./ToolResultRenderer.tsx";
5
+
6
+ const visAll = (v: Visibility) => () => v;
7
+
8
+ describe("ToolResultRenderer", () => {
9
+ test("Bash result → BashOutput with Bash.output visibility", () => {
10
+ const block: ToolResultBlock = {
11
+ type: "tool_result",
12
+ tool_use_id: "tu1",
13
+ content: "stdout line",
14
+ };
15
+ const { lastFrame } = render(
16
+ <ToolResultRenderer block={block} toolName="Bash" visibilityFor={visAll("show")} />,
17
+ );
18
+ expect(lastFrame() ?? "").toContain("stdout line");
19
+ });
20
+
21
+ test("Read result → ReadContent with Read.content visibility", () => {
22
+ const body = "file body line 1\nfile body line 2";
23
+ const block: ToolResultBlock = {
24
+ type: "tool_result",
25
+ tool_use_id: "tu2",
26
+ content: body,
27
+ };
28
+ const { lastFrame } = render(
29
+ <ToolResultRenderer
30
+ block={block}
31
+ toolName="Read"
32
+ toolInput={{ file_path: "/etc/hosts" }}
33
+ visibilityFor={visAll("summary")}
34
+ />,
35
+ );
36
+ const frame = lastFrame() ?? "";
37
+ // summary mode: path + line count, no body
38
+ expect(frame).toContain("/etc/hosts");
39
+ expect(frame).toMatch(/2 lines/);
40
+ expect(frame).not.toContain("file body line 1");
41
+ });
42
+
43
+ test("is_error: delegates to ErrorRenderer (red)", () => {
44
+ const block: ToolResultBlock = {
45
+ type: "tool_result",
46
+ tool_use_id: "tuX",
47
+ content: "permission denied",
48
+ is_error: true,
49
+ };
50
+ const { lastFrame } = render(
51
+ <ToolResultRenderer block={block} toolName="Bash" visibilityFor={visAll("show")} />,
52
+ );
53
+ const frame = lastFrame() ?? "";
54
+ expect(frame).toContain("permission denied");
55
+ expect(frame).toMatch(/\x1B\[31m/);
56
+ });
57
+
58
+ test("accepts content as TextBlock[]", () => {
59
+ const block: ToolResultBlock = {
60
+ type: "tool_result",
61
+ tool_use_id: "tu3",
62
+ content: [
63
+ { type: "text", text: "part1" },
64
+ { type: "text", text: "part2" },
65
+ ],
66
+ };
67
+ const { lastFrame } = render(
68
+ <ToolResultRenderer block={block} toolName="Bash" visibilityFor={visAll("show")} />,
69
+ );
70
+ const frame = lastFrame() ?? "";
71
+ expect(frame).toContain("part1");
72
+ expect(frame).toContain("part2");
73
+ });
74
+
75
+ test("unknown tool result → generic text block", () => {
76
+ const block: ToolResultBlock = {
77
+ type: "tool_result",
78
+ tool_use_id: "tu4",
79
+ content: "some glob output",
80
+ };
81
+ const { lastFrame } = render(
82
+ <ToolResultRenderer block={block} toolName="Glob" visibilityFor={visAll("show")} />,
83
+ );
84
+ expect(lastFrame() ?? "").toContain("some glob output");
85
+ });
86
+
87
+ test("Bash output hide vs show differ", () => {
88
+ const block: ToolResultBlock = {
89
+ type: "tool_result",
90
+ tool_use_id: "tu5",
91
+ content: "interesting output",
92
+ };
93
+ const a =
94
+ render(
95
+ <ToolResultRenderer block={block} toolName="Bash" visibilityFor={visAll("show")} />,
96
+ ).lastFrame() ?? "";
97
+ const b =
98
+ render(
99
+ <ToolResultRenderer block={block} toolName="Bash" visibilityFor={visAll("hide")} />,
100
+ ).lastFrame() ?? "";
101
+ expect(a).not.toBe(b);
102
+ expect(a).toContain("interesting output");
103
+ expect(b).not.toContain("interesting output");
104
+ });
105
+ });
@@ -0,0 +1,66 @@
1
+ import { Text } from "ink";
2
+ import type { ElementId, ToolResultBlock, Visibility } from "../types/events.ts";
3
+ import { BashOutput } from "./BashOutput.tsx";
4
+ import { ErrorRenderer } from "./ErrorRenderer.tsx";
5
+ import { ReadContent } from "./ReadContent.tsx";
6
+
7
+ export interface ToolResultRendererProps {
8
+ block: ToolResultBlock;
9
+ /**
10
+ * Tool name resolved from the originating tool_use's id. Slice B maintains
11
+ * a tool_use_id → tool_name map (the cleanest representation: it lives
12
+ * naturally in the event-log reducer where both halves of the pair are
13
+ * observed) and passes the name in here.
14
+ */
15
+ toolName: string;
16
+ /**
17
+ * Optional tool_use input — needed for results that summarize against
18
+ * the call (e.g. Read.content's summary form wants the file_path).
19
+ * Pass-through from slice B's map; omitted if not relevant.
20
+ */
21
+ toolInput?: Record<string, unknown>;
22
+ visibilityFor: (id: ElementId) => Visibility;
23
+ }
24
+
25
+ /**
26
+ * Generic dispatcher for ToolResultBlocks. Maps the originating tool name
27
+ * to the right per-result component (BashOutput, ReadContent, …). Errors
28
+ * (is_error: true) always delegate to ErrorRenderer regardless of tool.
29
+ */
30
+ export function ToolResultRenderer({
31
+ block,
32
+ toolName,
33
+ toolInput,
34
+ visibilityFor,
35
+ }: ToolResultRendererProps): JSX.Element | null {
36
+ const content = stringifyContent(block.content);
37
+
38
+ if (block.is_error) {
39
+ return <ErrorRenderer message={content} label={toolName} />;
40
+ }
41
+
42
+ switch (toolName) {
43
+ case "Bash":
44
+ return <BashOutput content={content} visibility={visibilityFor("Bash.output")} />;
45
+
46
+ case "Read": {
47
+ const fp = toolInput?.file_path;
48
+ return (
49
+ <ReadContent
50
+ filePath={typeof fp === "string" ? fp : ""}
51
+ content={content}
52
+ visibility={visibilityFor("Read.content")}
53
+ />
54
+ );
55
+ }
56
+
57
+ default:
58
+ // No dedicated component — render the result body as plain text.
59
+ return <Text>{content}</Text>;
60
+ }
61
+ }
62
+
63
+ function stringifyContent(content: ToolResultBlock["content"]): string {
64
+ if (typeof content === "string") return content;
65
+ return content.map((b) => b.text).join("");
66
+ }