@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.
- package/LICENSE +21 -0
- package/README.md +75 -0
- package/package.json +47 -3
- package/src/App.test.tsx +130 -0
- package/src/App.tsx +321 -0
- package/src/__fixtures__/events.ts +66 -0
- package/src/__fixtures__/multi-turn.ndjson +11 -0
- package/src/__fixtures__/slash-command-turn.ndjson +3 -0
- package/src/__fixtures__/text-turn.ndjson +4 -0
- package/src/__fixtures__/tool-use-turn.ndjson +6 -0
- package/src/__fixtures__/unknown-slash-command-turn.ndjson +2 -0
- package/src/claude-process.test.ts +196 -0
- package/src/claude-process.ts +138 -0
- package/src/event-parser.test.ts +271 -0
- package/src/event-parser.ts +89 -0
- package/src/filter-state.test.ts +189 -0
- package/src/filter-state.ts +110 -0
- package/src/index.tsx +11 -0
- package/src/keybinds.test.ts +148 -0
- package/src/keybinds.ts +75 -0
- package/src/renderers/BashInput.test.tsx +61 -0
- package/src/renderers/BashInput.tsx +44 -0
- package/src/renderers/BashOutput.test.tsx +44 -0
- package/src/renderers/BashOutput.tsx +36 -0
- package/src/renderers/EditDiff.test.tsx +65 -0
- package/src/renderers/EditDiff.tsx +73 -0
- package/src/renderers/ErrorRenderer.test.tsx +25 -0
- package/src/renderers/ErrorRenderer.tsx +22 -0
- package/src/renderers/ReadContent.test.tsx +46 -0
- package/src/renderers/ReadContent.tsx +37 -0
- package/src/renderers/ReadInput.test.tsx +18 -0
- package/src/renderers/ReadInput.tsx +19 -0
- package/src/renderers/ResultRenderer.test.tsx +52 -0
- package/src/renderers/ResultRenderer.tsx +26 -0
- package/src/renderers/SystemInit.test.tsx +33 -0
- package/src/renderers/SystemInit.tsx +22 -0
- package/src/renderers/TaskNested.test.tsx +58 -0
- package/src/renderers/TaskNested.tsx +49 -0
- package/src/renderers/TextRenderer.test.tsx +37 -0
- package/src/renderers/TextRenderer.tsx +80 -0
- package/src/renderers/ThinkingRenderer.test.tsx +45 -0
- package/src/renderers/ThinkingRenderer.tsx +52 -0
- package/src/renderers/ToolResultRenderer.test.tsx +105 -0
- package/src/renderers/ToolResultRenderer.tsx +66 -0
- package/src/renderers/ToolUseRenderer.test.tsx +99 -0
- package/src/renderers/ToolUseRenderer.tsx +112 -0
- package/src/renderers/WriteContent.test.tsx +53 -0
- package/src/renderers/WriteContent.tsx +47 -0
- package/src/renderers/index.ts +50 -0
- package/src/renderers/summarize.ts +27 -0
- package/src/types/events.test.ts +43 -0
- 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
|
+
});
|
package/src/keybinds.ts
ADDED
|
@@ -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
|
+
}
|