@fnclaude/renderer 0.0.1 → 2.0.0

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,148 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { type Key, dispatchKey } from "./keybinds.ts";
3
+
4
+ const baseKey: Key = {
5
+ upArrow: false,
6
+ downArrow: false,
7
+ leftArrow: false,
8
+ rightArrow: false,
9
+ pageDown: false,
10
+ pageUp: false,
11
+ return: false,
12
+ escape: false,
13
+ ctrl: false,
14
+ shift: false,
15
+ tab: false,
16
+ backspace: false,
17
+ delete: false,
18
+ meta: false,
19
+ };
20
+
21
+ const meta = (input: string): { input: string; key: Key } => ({
22
+ input,
23
+ key: { ...baseKey, meta: true },
24
+ });
25
+
26
+ const ctrl = (input: string): { input: string; key: Key } => ({
27
+ input,
28
+ key: { ...baseKey, ctrl: true },
29
+ });
30
+
31
+ describe("dispatchKey — Alt+1-8 element toggles", () => {
32
+ test("Alt+1 → toggle thinking", () => {
33
+ const { input, key } = meta("1");
34
+ expect(dispatchKey(input, key)).toEqual({
35
+ kind: "toggleElement",
36
+ element: "thinking",
37
+ });
38
+ });
39
+
40
+ test("Alt+2 → toggle Bash.input", () => {
41
+ const { input, key } = meta("2");
42
+ expect(dispatchKey(input, key)).toEqual({
43
+ kind: "toggleElement",
44
+ element: "Bash.input",
45
+ });
46
+ });
47
+
48
+ test("Alt+3 → toggle Bash.output", () => {
49
+ const { input, key } = meta("3");
50
+ expect(dispatchKey(input, key)).toEqual({
51
+ kind: "toggleElement",
52
+ element: "Bash.output",
53
+ });
54
+ });
55
+
56
+ test("Alt+4 → toggle Edit.diff", () => {
57
+ const { input, key } = meta("4");
58
+ expect(dispatchKey(input, key)).toEqual({
59
+ kind: "toggleElement",
60
+ element: "Edit.diff",
61
+ });
62
+ });
63
+
64
+ test("Alt+5 → toggle Read.content", () => {
65
+ const { input, key } = meta("5");
66
+ expect(dispatchKey(input, key)).toEqual({
67
+ kind: "toggleElement",
68
+ element: "Read.content",
69
+ });
70
+ });
71
+
72
+ test("Alt+6 → toggle Write.content", () => {
73
+ const { input, key } = meta("6");
74
+ expect(dispatchKey(input, key)).toEqual({
75
+ kind: "toggleElement",
76
+ element: "Write.content",
77
+ });
78
+ });
79
+
80
+ test("Alt+7 → toggle Task.nested", () => {
81
+ const { input, key } = meta("7");
82
+ expect(dispatchKey(input, key)).toEqual({
83
+ kind: "toggleElement",
84
+ element: "Task.nested",
85
+ });
86
+ });
87
+
88
+ test("Alt+8 → toggle errors", () => {
89
+ const { input, key } = meta("8");
90
+ expect(dispatchKey(input, key)).toEqual({
91
+ kind: "toggleElement",
92
+ element: "errors",
93
+ });
94
+ });
95
+ });
96
+
97
+ describe("dispatchKey — preset cycle", () => {
98
+ test("Alt+0 → cycle forward", () => {
99
+ const { input, key } = meta("0");
100
+ expect(dispatchKey(input, key)).toEqual({
101
+ kind: "cyclePreset",
102
+ direction: 1,
103
+ });
104
+ });
105
+
106
+ test("Alt+9 → cycle backward", () => {
107
+ const { input, key } = meta("9");
108
+ expect(dispatchKey(input, key)).toEqual({
109
+ kind: "cyclePreset",
110
+ direction: -1,
111
+ });
112
+ });
113
+ });
114
+
115
+ describe("dispatchKey — control combos", () => {
116
+ test("Ctrl+L → repaint", () => {
117
+ const { input, key } = ctrl("l");
118
+ expect(dispatchKey(input, key)).toEqual({ kind: "repaint" });
119
+ });
120
+
121
+ test("Ctrl+D → close stdin", () => {
122
+ const { input, key } = ctrl("d");
123
+ expect(dispatchKey(input, key)).toEqual({ kind: "closeStdin" });
124
+ });
125
+
126
+ test("Ctrl+C → interrupt", () => {
127
+ const { input, key } = ctrl("c");
128
+ expect(dispatchKey(input, key)).toEqual({ kind: "interrupt" });
129
+ });
130
+ });
131
+
132
+ describe("dispatchKey — non-matches return null", () => {
133
+ test("plain letter is null (let App handle text input)", () => {
134
+ expect(dispatchKey("a", baseKey)).toBeNull();
135
+ });
136
+
137
+ test("Alt+a (non-digit meta) is null", () => {
138
+ expect(dispatchKey("a", { ...baseKey, meta: true })).toBeNull();
139
+ });
140
+
141
+ test("Ctrl+x (unbound) is null", () => {
142
+ expect(dispatchKey("x", { ...baseKey, ctrl: true })).toBeNull();
143
+ });
144
+
145
+ test("Enter alone is null (App handles submit)", () => {
146
+ expect(dispatchKey("", { ...baseKey, return: true })).toBeNull();
147
+ });
148
+ });
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Pure keybind dispatch. Maps `(input, key)` (the args Ink's `useInput`
3
+ * passes) to a `KeybindAction`, or `null` when the input should fall
4
+ * through to the App's text-input handling.
5
+ *
6
+ * Pure means: no React, no side effects, no Ink imports. The App owns the
7
+ * effect — this module just decides which action a keystroke is.
8
+ *
9
+ * See docs/keybind-spec.md.
10
+ */
11
+
12
+ import type { ElementId } from "./types/events.ts";
13
+
14
+ /**
15
+ * Mirror of ink's `Key` shape, decoupled so the dispatch logic stays
16
+ * Ink-free for direct unit testing. Anything passing a structurally-
17
+ * matching object satisfies it.
18
+ */
19
+ export interface Key {
20
+ upArrow: boolean;
21
+ downArrow: boolean;
22
+ leftArrow: boolean;
23
+ rightArrow: boolean;
24
+ pageDown: boolean;
25
+ pageUp: boolean;
26
+ return: boolean;
27
+ escape: boolean;
28
+ ctrl: boolean;
29
+ shift: boolean;
30
+ tab: boolean;
31
+ backspace: boolean;
32
+ delete: boolean;
33
+ meta: boolean;
34
+ }
35
+
36
+ export type KeybindAction =
37
+ | { kind: "toggleElement"; element: ElementId }
38
+ | { kind: "cyclePreset"; direction: 1 | -1 }
39
+ | { kind: "repaint" }
40
+ | { kind: "closeStdin" }
41
+ | { kind: "interrupt" };
42
+
43
+ /**
44
+ * Element order matches `Alt+1` … `Alt+8` exactly (docs/keybind-spec.md).
45
+ */
46
+ const ALT_DIGIT_ELEMENTS: Record<string, ElementId> = {
47
+ "1": "thinking",
48
+ "2": "Bash.input",
49
+ "3": "Bash.output",
50
+ "4": "Edit.diff",
51
+ "5": "Read.content",
52
+ "6": "Write.content",
53
+ "7": "Task.nested",
54
+ "8": "errors",
55
+ };
56
+
57
+ export function dispatchKey(input: string, key: Key): KeybindAction | null {
58
+ // Alt + digit
59
+ if (key.meta && /^[0-9]$/.test(input)) {
60
+ if (input === "0") return { kind: "cyclePreset", direction: 1 };
61
+ if (input === "9") return { kind: "cyclePreset", direction: -1 };
62
+ const element = ALT_DIGIT_ELEMENTS[input];
63
+ if (element !== undefined) return { kind: "toggleElement", element };
64
+ return null;
65
+ }
66
+
67
+ // Ctrl combos
68
+ if (key.ctrl && !key.meta) {
69
+ if (input === "l") return { kind: "repaint" };
70
+ if (input === "d") return { kind: "closeStdin" };
71
+ if (input === "c") return { kind: "interrupt" };
72
+ }
73
+
74
+ return null;
75
+ }
@@ -0,0 +1,61 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { render } from "ink-testing-library";
3
+ import { BashInput } from "./BashInput.tsx";
4
+
5
+ const multilineCmd = "echo line1\necho line2\necho line3\necho line4\necho line5\necho line6";
6
+
7
+ describe("BashInput", () => {
8
+ test("show: full command rendered", () => {
9
+ const { lastFrame } = render(
10
+ <BashInput command="ls -la" description="list files" visibility="show" />,
11
+ );
12
+ const frame = lastFrame() ?? "";
13
+ expect(frame).toContain("ls -la");
14
+ });
15
+
16
+ test("hide: shows a one-line tool header but not the command", () => {
17
+ const { lastFrame } = render(
18
+ <BashInput command="rm -rf /" description="dangerous op" visibility="hide" />,
19
+ );
20
+ const frame = lastFrame() ?? "";
21
+ expect(frame).not.toContain("rm -rf /");
22
+ expect(frame).toContain("Bash");
23
+ });
24
+
25
+ test("summary: first 5 lines + remaining-line indicator for multi-line scripts", () => {
26
+ const { lastFrame } = render(
27
+ <BashInput command={multilineCmd} description="multi" visibility="summary" />,
28
+ );
29
+ const frame = lastFrame() ?? "";
30
+ expect(frame).toContain("echo line1");
31
+ expect(frame).toContain("echo line5");
32
+ expect(frame).not.toContain("echo line6");
33
+ expect(frame).toMatch(/1 more line/);
34
+ });
35
+
36
+ test("summary on single-line command: shows whole command (no truncation)", () => {
37
+ const { lastFrame } = render(
38
+ <BashInput command="ls -la" description="d" visibility="summary" />,
39
+ );
40
+ const frame = lastFrame() ?? "";
41
+ expect(frame).toContain("ls -la");
42
+ expect(frame).not.toMatch(/more line/);
43
+ });
44
+
45
+ test("dim: full command with dim styling", () => {
46
+ const { lastFrame } = render(<BashInput command="ls -la" description="d" visibility="dim" />);
47
+ const frame = lastFrame() ?? "";
48
+ expect(frame).toContain("ls -la");
49
+ expect(frame).toMatch(/\x1B\[2m/);
50
+ });
51
+
52
+ test("show vs hide vs summary produce different output", () => {
53
+ const props = { command: multilineCmd, description: "m" };
54
+ const showFrame = render(<BashInput {...props} visibility="show" />).lastFrame() ?? "";
55
+ const hideFrame = render(<BashInput {...props} visibility="hide" />).lastFrame() ?? "";
56
+ const sumFrame = render(<BashInput {...props} visibility="summary" />).lastFrame() ?? "";
57
+ expect(showFrame).not.toBe(hideFrame);
58
+ expect(showFrame).not.toBe(sumFrame);
59
+ expect(sumFrame).not.toBe(hideFrame);
60
+ });
61
+ });
@@ -0,0 +1,44 @@
1
+ import { Text } from "ink";
2
+ import type { Visibility } from "../types/events.ts";
3
+ import { firstNLines } from "./summarize.ts";
4
+
5
+ export interface BashInputProps {
6
+ command: string;
7
+ description?: string | undefined;
8
+ visibility: Visibility;
9
+ }
10
+
11
+ /**
12
+ * Renders the `command` field of a Bash tool_use block.
13
+ * Filterable element id: "Bash.input".
14
+ *
15
+ * Visibility:
16
+ * show — full command
17
+ * hide — single-line "▸ Bash: <description>" header only
18
+ * summary — first 5 lines (+ "N more lines" indicator for multi-line)
19
+ * dim — full command, ANSI-faint
20
+ */
21
+ export function BashInput({ command, description, visibility }: BashInputProps): JSX.Element {
22
+ const header = `▸ Bash${description ? `: ${description}` : ""}`;
23
+
24
+ if (visibility === "hide") {
25
+ return <Text dimColor>{header}</Text>;
26
+ }
27
+
28
+ if (visibility === "summary") {
29
+ const { head, hiddenLines } = firstNLines(command);
30
+ return (
31
+ <Text>
32
+ {`$ ${head}`}
33
+ {hiddenLines > 0 ? `\n(… ${hiddenLines} more line${hiddenLines === 1 ? "" : "s"})` : ""}
34
+ </Text>
35
+ );
36
+ }
37
+
38
+ if (visibility === "dim") {
39
+ return <Text dimColor>{`$ ${command}`}</Text>;
40
+ }
41
+
42
+ // show
43
+ return <Text>{`$ ${command}`}</Text>;
44
+ }
@@ -0,0 +1,44 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { render } from "ink-testing-library";
3
+ import { BashOutput } from "./BashOutput.tsx";
4
+
5
+ const longOutput = Array.from({ length: 12 }, (_, i) => `line${i + 1}`).join("\n");
6
+
7
+ describe("BashOutput", () => {
8
+ test("show: full output rendered", () => {
9
+ const { lastFrame } = render(<BashOutput content={longOutput} visibility="show" />);
10
+ const frame = lastFrame() ?? "";
11
+ expect(frame).toContain("line1");
12
+ expect(frame).toContain("line12");
13
+ });
14
+
15
+ test("hide: empty", () => {
16
+ const { lastFrame } = render(<BashOutput content={longOutput} visibility="hide" />);
17
+ expect((lastFrame() ?? "").trim()).toBe("");
18
+ });
19
+
20
+ test("summary: first 5 lines + (N lines hidden)", () => {
21
+ const { lastFrame } = render(<BashOutput content={longOutput} visibility="summary" />);
22
+ const frame = lastFrame() ?? "";
23
+ expect(frame).toContain("line1");
24
+ expect(frame).toContain("line5");
25
+ expect(frame).not.toContain("line6");
26
+ expect(frame).toMatch(/7 lines hidden/);
27
+ });
28
+
29
+ test("dim: dim styling, full content", () => {
30
+ const { lastFrame } = render(<BashOutput content="hello" visibility="dim" />);
31
+ const frame = lastFrame() ?? "";
32
+ expect(frame).toContain("hello");
33
+ expect(frame).toMatch(/\x1B\[2m/);
34
+ });
35
+
36
+ test("show vs hide vs summary differ", () => {
37
+ const a = render(<BashOutput content={longOutput} visibility="show" />).lastFrame() ?? "";
38
+ const b = render(<BashOutput content={longOutput} visibility="hide" />).lastFrame() ?? "";
39
+ const c = render(<BashOutput content={longOutput} visibility="summary" />).lastFrame() ?? "";
40
+ expect(a).not.toBe(b);
41
+ expect(a).not.toBe(c);
42
+ expect(b).not.toBe(c);
43
+ });
44
+ });
@@ -0,0 +1,36 @@
1
+ import { Text } from "ink";
2
+ import type { Visibility } from "../types/events.ts";
3
+ import { firstNLines } from "./summarize.ts";
4
+
5
+ export interface BashOutputProps {
6
+ content: string;
7
+ visibility: Visibility;
8
+ isError?: boolean | undefined;
9
+ }
10
+
11
+ /**
12
+ * Renders the tool_result output of a Bash invocation.
13
+ * Filterable element id: "Bash.output".
14
+ *
15
+ * Visibility:
16
+ * show — full output
17
+ * hide — render nothing
18
+ * summary — first 5 lines + "(… N lines hidden)"
19
+ * dim — full output, ANSI-faint
20
+ */
21
+ export function BashOutput({ content, visibility, isError }: BashOutputProps): JSX.Element | null {
22
+ if (visibility === "hide") return null;
23
+
24
+ if (visibility === "summary") {
25
+ const { head, hiddenLines } = firstNLines(content);
26
+ const body = `${head}${hiddenLines > 0 ? `\n(… ${hiddenLines} lines hidden)` : ""}`;
27
+ return isError ? <Text color="red">{body}</Text> : <Text>{body}</Text>;
28
+ }
29
+
30
+ if (visibility === "dim") {
31
+ return <Text dimColor>{content}</Text>;
32
+ }
33
+
34
+ // show
35
+ return isError ? <Text color="red">{content}</Text> : <Text>{content}</Text>;
36
+ }
@@ -0,0 +1,65 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { render } from "ink-testing-library";
3
+ import { EditDiff } from "./EditDiff.tsx";
4
+
5
+ const oldStr = "old line 1\nold line 2";
6
+ const newStr = "new line 1\nnew line 2\nnew line 3";
7
+
8
+ describe("EditDiff", () => {
9
+ test("show: file path + diff body", () => {
10
+ const { lastFrame } = render(
11
+ <EditDiff filePath="/tmp/foo.txt" oldString={oldStr} newString={newStr} visibility="show" />,
12
+ );
13
+ const frame = lastFrame() ?? "";
14
+ expect(frame).toContain("/tmp/foo.txt");
15
+ expect(frame).toContain("old line 1");
16
+ expect(frame).toContain("new line 1");
17
+ });
18
+
19
+ test("hide: header only, no diff body", () => {
20
+ const { lastFrame } = render(
21
+ <EditDiff filePath="/tmp/foo.txt" oldString={oldStr} newString={newStr} visibility="hide" />,
22
+ );
23
+ const frame = lastFrame() ?? "";
24
+ expect(frame).toContain("/tmp/foo.txt");
25
+ expect(frame).not.toContain("old line 1");
26
+ expect(frame).not.toContain("new line 1");
27
+ });
28
+
29
+ test("summary: file path + change line count, no diff body", () => {
30
+ const { lastFrame } = render(
31
+ <EditDiff
32
+ filePath="/tmp/foo.txt"
33
+ oldString={oldStr}
34
+ newString={newStr}
35
+ visibility="summary"
36
+ />,
37
+ );
38
+ const frame = lastFrame() ?? "";
39
+ expect(frame).toContain("/tmp/foo.txt");
40
+ // 2 lines removed + 3 lines added
41
+ expect(frame).toMatch(/-2/);
42
+ expect(frame).toMatch(/\+3/);
43
+ expect(frame).not.toContain("old line 1");
44
+ expect(frame).not.toContain("new line 1");
45
+ });
46
+
47
+ test("dim: full content with dim styling", () => {
48
+ const { lastFrame } = render(
49
+ <EditDiff filePath="/tmp/foo.txt" oldString={oldStr} newString={newStr} visibility="dim" />,
50
+ );
51
+ const frame = lastFrame() ?? "";
52
+ expect(frame).toContain("/tmp/foo.txt");
53
+ expect(frame).toMatch(/\x1B\[2m/);
54
+ });
55
+
56
+ test("show vs hide vs summary differ", () => {
57
+ const p = { filePath: "/tmp/x", oldString: oldStr, newString: newStr };
58
+ const a = render(<EditDiff {...p} visibility="show" />).lastFrame() ?? "";
59
+ const b = render(<EditDiff {...p} visibility="hide" />).lastFrame() ?? "";
60
+ const c = render(<EditDiff {...p} visibility="summary" />).lastFrame() ?? "";
61
+ expect(a).not.toBe(b);
62
+ expect(a).not.toBe(c);
63
+ expect(b).not.toBe(c);
64
+ });
65
+ });
@@ -0,0 +1,73 @@
1
+ import { Box, Text } from "ink";
2
+ import type { Visibility } from "../types/events.ts";
3
+ import { countLines } from "./summarize.ts";
4
+
5
+ function prefixLines(text: string, prefix: string): string {
6
+ return text
7
+ .split("\n")
8
+ .map((line) => `${prefix}${line}`)
9
+ .join("\n");
10
+ }
11
+
12
+ export interface EditDiffProps {
13
+ filePath: string;
14
+ oldString: string;
15
+ newString: string;
16
+ visibility: Visibility;
17
+ }
18
+
19
+ /**
20
+ * Renders an Edit tool_use: file path + minimal diff body.
21
+ * Filterable element id: "Edit.diff".
22
+ *
23
+ * Visibility:
24
+ * show — file path header + simple line-prefixed diff
25
+ * hide — header only ("▸ Edit: <path>")
26
+ * summary — file path + line count of change (e.g. "-2 +3 lines")
27
+ * dim — full content, ANSI-faint
28
+ */
29
+ export function EditDiff({
30
+ filePath,
31
+ oldString,
32
+ newString,
33
+ visibility,
34
+ }: EditDiffProps): JSX.Element {
35
+ const removedLines = countLines(oldString);
36
+ const addedLines = countLines(newString);
37
+
38
+ if (visibility === "hide") {
39
+ return <Text dimColor>{`▸ Edit: ${filePath}`}</Text>;
40
+ }
41
+
42
+ if (visibility === "summary") {
43
+ return (
44
+ <Text>
45
+ {`▸ Edit: ${filePath} `}
46
+ <Text color="red">{`-${removedLines}`}</Text> <Text color="green">{`+${addedLines}`}</Text>
47
+ {" lines"}
48
+ </Text>
49
+ );
50
+ }
51
+
52
+ const removed = prefixLines(oldString, "- ");
53
+ const added = prefixLines(newString, "+ ");
54
+
55
+ if (visibility === "dim") {
56
+ return (
57
+ <Box flexDirection="column">
58
+ <Text dimColor>{`▸ Edit: ${filePath}`}</Text>
59
+ <Text dimColor>{removed}</Text>
60
+ <Text dimColor>{added}</Text>
61
+ </Box>
62
+ );
63
+ }
64
+
65
+ // show
66
+ return (
67
+ <Box flexDirection="column">
68
+ <Text>{`▸ Edit: ${filePath}`}</Text>
69
+ <Text color="red">{removed}</Text>
70
+ <Text color="green">{added}</Text>
71
+ </Box>
72
+ );
73
+ }
@@ -0,0 +1,25 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { render } from "ink-testing-library";
3
+ import { ErrorRenderer } from "./ErrorRenderer.tsx";
4
+
5
+ describe("ErrorRenderer", () => {
6
+ test("renders error text with red styling (ANSI escape present)", () => {
7
+ const { lastFrame } = render(<ErrorRenderer message="boom: file not found" />);
8
+ const frame = lastFrame() ?? "";
9
+ expect(frame).toContain("boom: file not found");
10
+ // CSI escape opens with ESC[ (\x1B[). Confirm color/bold escapes present.
11
+ expect(frame).toMatch(/\x1B\[/);
12
+ });
13
+
14
+ test("renders multi-line errors", () => {
15
+ const { lastFrame } = render(<ErrorRenderer message={"line one\nline two"} />);
16
+ const frame = lastFrame() ?? "";
17
+ expect(frame).toContain("line one");
18
+ expect(frame).toContain("line two");
19
+ });
20
+
21
+ test("includes optional label prefix", () => {
22
+ const { lastFrame } = render(<ErrorRenderer message="bad" label="Bash" />);
23
+ expect(lastFrame() ?? "").toContain("Bash");
24
+ });
25
+ });
@@ -0,0 +1,22 @@
1
+ import { Text } from "ink";
2
+
3
+ export interface ErrorRendererProps {
4
+ message: string;
5
+ /** Optional label, e.g. tool name or "result". */
6
+ label?: string;
7
+ }
8
+
9
+ /**
10
+ * Error renderer for any block with is_error: true and ResultEvent with
11
+ * is_error: true. Always shown (errors element defaults to "show" on every
12
+ * preset; respects overrides only if the user explicitly hides errors).
13
+ */
14
+ export function ErrorRenderer({ message, label }: ErrorRendererProps): JSX.Element {
15
+ const prefix = label ? `✖ ${label}: ` : "✖ ";
16
+ return (
17
+ <Text color="red" bold>
18
+ {prefix}
19
+ {message}
20
+ </Text>
21
+ );
22
+ }
@@ -0,0 +1,46 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { render } from "ink-testing-library";
3
+ import { ReadContent } from "./ReadContent.tsx";
4
+
5
+ const bigFile = Array.from({ length: 50 }, (_, i) => `line${i + 1}`).join("\n");
6
+
7
+ describe("ReadContent", () => {
8
+ test("show: full content", () => {
9
+ const { lastFrame } = render(<ReadContent filePath="/x" content={bigFile} visibility="show" />);
10
+ const frame = lastFrame() ?? "";
11
+ expect(frame).toContain("line1");
12
+ expect(frame).toContain("line50");
13
+ });
14
+
15
+ test("hide: empty", () => {
16
+ const { lastFrame } = render(<ReadContent filePath="/x" content={bigFile} visibility="hide" />);
17
+ expect((lastFrame() ?? "").trim()).toBe("");
18
+ });
19
+
20
+ test("summary: file path + line count, no content", () => {
21
+ const { lastFrame } = render(
22
+ <ReadContent filePath="/etc/hosts" content={bigFile} visibility="summary" />,
23
+ );
24
+ const frame = lastFrame() ?? "";
25
+ expect(frame).toContain("/etc/hosts");
26
+ expect(frame).toMatch(/50 lines/);
27
+ expect(frame).not.toContain("line1\nline2");
28
+ });
29
+
30
+ test("dim: full content with dim styling", () => {
31
+ const { lastFrame } = render(<ReadContent filePath="/x" content="hello" visibility="dim" />);
32
+ const frame = lastFrame() ?? "";
33
+ expect(frame).toContain("hello");
34
+ expect(frame).toMatch(/\x1B\[2m/);
35
+ });
36
+
37
+ test("show vs hide vs summary differ", () => {
38
+ const p = { filePath: "/x", content: bigFile };
39
+ const a = render(<ReadContent {...p} visibility="show" />).lastFrame() ?? "";
40
+ const b = render(<ReadContent {...p} visibility="hide" />).lastFrame() ?? "";
41
+ const c = render(<ReadContent {...p} visibility="summary" />).lastFrame() ?? "";
42
+ expect(a).not.toBe(b);
43
+ expect(a).not.toBe(c);
44
+ expect(b).not.toBe(c);
45
+ });
46
+ });
@@ -0,0 +1,37 @@
1
+ import { Text } from "ink";
2
+ import type { Visibility } from "../types/events.ts";
3
+ import { countLines } from "./summarize.ts";
4
+
5
+ export interface ReadContentProps {
6
+ filePath: string;
7
+ content: string;
8
+ visibility: Visibility;
9
+ }
10
+
11
+ /**
12
+ * Renders the tool_result content of a Read tool call (large file body).
13
+ * Filterable element id: "Read.content".
14
+ *
15
+ * Visibility:
16
+ * show — full file content
17
+ * hide — render nothing
18
+ * summary — file path + line count of file
19
+ * dim — full content, ANSI-faint
20
+ */
21
+ export function ReadContent({
22
+ filePath,
23
+ content,
24
+ visibility,
25
+ }: ReadContentProps): JSX.Element | null {
26
+ if (visibility === "hide") return null;
27
+
28
+ if (visibility === "summary") {
29
+ return <Text dimColor>{` ↳ ${filePath} (${countLines(content)} lines)`}</Text>;
30
+ }
31
+
32
+ if (visibility === "dim") {
33
+ return <Text dimColor>{content}</Text>;
34
+ }
35
+
36
+ return <Text>{content}</Text>;
37
+ }