@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.
- 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,99 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { render } from "ink-testing-library";
|
|
3
|
+
import type { ToolUseBlock, Visibility } from "../types/events.ts";
|
|
4
|
+
import { ToolUseRenderer } from "./ToolUseRenderer.tsx";
|
|
5
|
+
|
|
6
|
+
const visAll = (v: Visibility) => () => v;
|
|
7
|
+
|
|
8
|
+
describe("ToolUseRenderer", () => {
|
|
9
|
+
test("dispatches Bash → BashInput", () => {
|
|
10
|
+
const block: ToolUseBlock = {
|
|
11
|
+
type: "tool_use",
|
|
12
|
+
id: "tu1",
|
|
13
|
+
name: "Bash",
|
|
14
|
+
input: { command: "ls -la", description: "list files" },
|
|
15
|
+
};
|
|
16
|
+
const { lastFrame } = render(<ToolUseRenderer block={block} visibilityFor={visAll("show")} />);
|
|
17
|
+
expect(lastFrame() ?? "").toContain("ls -la");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("dispatches Edit → EditDiff", () => {
|
|
21
|
+
const block: ToolUseBlock = {
|
|
22
|
+
type: "tool_use",
|
|
23
|
+
id: "tu2",
|
|
24
|
+
name: "Edit",
|
|
25
|
+
input: { file_path: "/x.txt", old_string: "a", new_string: "b" },
|
|
26
|
+
};
|
|
27
|
+
const { lastFrame } = render(<ToolUseRenderer block={block} visibilityFor={visAll("show")} />);
|
|
28
|
+
const frame = lastFrame() ?? "";
|
|
29
|
+
expect(frame).toContain("/x.txt");
|
|
30
|
+
expect(frame).toContain("- a");
|
|
31
|
+
expect(frame).toContain("+ b");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("dispatches Read → ReadInput (path always shown)", () => {
|
|
35
|
+
const block: ToolUseBlock = {
|
|
36
|
+
type: "tool_use",
|
|
37
|
+
id: "tu3",
|
|
38
|
+
name: "Read",
|
|
39
|
+
input: { file_path: "/etc/hosts" },
|
|
40
|
+
};
|
|
41
|
+
const { lastFrame } = render(<ToolUseRenderer block={block} visibilityFor={visAll("hide")} />);
|
|
42
|
+
expect(lastFrame() ?? "").toContain("/etc/hosts");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("dispatches Write → WriteContent", () => {
|
|
46
|
+
const block: ToolUseBlock = {
|
|
47
|
+
type: "tool_use",
|
|
48
|
+
id: "tu4",
|
|
49
|
+
name: "Write",
|
|
50
|
+
input: { file_path: "/o.txt", content: "hello world" },
|
|
51
|
+
};
|
|
52
|
+
const { lastFrame } = render(<ToolUseRenderer block={block} visibilityFor={visAll("show")} />);
|
|
53
|
+
const frame = lastFrame() ?? "";
|
|
54
|
+
expect(frame).toContain("/o.txt");
|
|
55
|
+
expect(frame).toContain("hello world");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("dispatches Task → TaskNested", () => {
|
|
59
|
+
const block: ToolUseBlock = {
|
|
60
|
+
type: "tool_use",
|
|
61
|
+
id: "tu5",
|
|
62
|
+
name: "Task",
|
|
63
|
+
input: { description: "do thing", prompt: "do the thing" },
|
|
64
|
+
};
|
|
65
|
+
const { lastFrame } = render(<ToolUseRenderer block={block} visibilityFor={visAll("show")} />);
|
|
66
|
+
const frame = lastFrame() ?? "";
|
|
67
|
+
expect(frame).toContain("Task");
|
|
68
|
+
expect(frame).toContain("do the thing");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("unknown tool → generic fallback with tool name and input summary", () => {
|
|
72
|
+
const block: ToolUseBlock = {
|
|
73
|
+
type: "tool_use",
|
|
74
|
+
id: "tu6",
|
|
75
|
+
name: "Glob",
|
|
76
|
+
input: { pattern: "**/*.ts" },
|
|
77
|
+
};
|
|
78
|
+
const { lastFrame } = render(<ToolUseRenderer block={block} visibilityFor={visAll("show")} />);
|
|
79
|
+
const frame = lastFrame() ?? "";
|
|
80
|
+
expect(frame).toContain("Glob");
|
|
81
|
+
expect(frame).toContain("**/*.ts");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("visibilityFor is consulted per element id", () => {
|
|
85
|
+
const queriedIds: string[] = [];
|
|
86
|
+
const visFor = (id: string) => {
|
|
87
|
+
queriedIds.push(id);
|
|
88
|
+
return "hide" as const;
|
|
89
|
+
};
|
|
90
|
+
const block: ToolUseBlock = {
|
|
91
|
+
type: "tool_use",
|
|
92
|
+
id: "tu7",
|
|
93
|
+
name: "Bash",
|
|
94
|
+
input: { command: "rm -rf /" },
|
|
95
|
+
};
|
|
96
|
+
render(<ToolUseRenderer block={block} visibilityFor={visFor} />);
|
|
97
|
+
expect(queriedIds).toContain("Bash.input");
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { Text } from "ink";
|
|
2
|
+
import type { ElementId, ToolUseBlock, Visibility } from "../types/events.ts";
|
|
3
|
+
import { BashInput } from "./BashInput.tsx";
|
|
4
|
+
import { EditDiff } from "./EditDiff.tsx";
|
|
5
|
+
import { ReadInput } from "./ReadInput.tsx";
|
|
6
|
+
import { TaskNested } from "./TaskNested.tsx";
|
|
7
|
+
import { WriteContent } from "./WriteContent.tsx";
|
|
8
|
+
|
|
9
|
+
export interface ToolUseRendererProps {
|
|
10
|
+
block: ToolUseBlock;
|
|
11
|
+
/**
|
|
12
|
+
* Resolves the current visibility for a given filterable element id.
|
|
13
|
+
* Slice B owns filter state; this dispatcher only consults the
|
|
14
|
+
* function for the element id corresponding to the block's tool.
|
|
15
|
+
*/
|
|
16
|
+
visibilityFor: (id: ElementId) => Visibility;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Generic dispatcher for ToolUseBlocks. Reads `block.name`, pulls the
|
|
21
|
+
* right input fields, and renders the matching per-tool component with
|
|
22
|
+
* the visibility resolved for that tool's element id.
|
|
23
|
+
*
|
|
24
|
+
* Unknown tools fall back to a one-line header showing the tool name and
|
|
25
|
+
* a compact input summary.
|
|
26
|
+
*/
|
|
27
|
+
export function ToolUseRenderer({ block, visibilityFor }: ToolUseRendererProps): JSX.Element {
|
|
28
|
+
const input = block.input;
|
|
29
|
+
|
|
30
|
+
switch (block.name) {
|
|
31
|
+
case "Bash":
|
|
32
|
+
return (
|
|
33
|
+
<BashInput
|
|
34
|
+
command={asString(input.command)}
|
|
35
|
+
description={asOptionalString(input.description)}
|
|
36
|
+
visibility={visibilityFor("Bash.input")}
|
|
37
|
+
/>
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
case "Edit":
|
|
41
|
+
return (
|
|
42
|
+
<EditDiff
|
|
43
|
+
filePath={asString(input.file_path)}
|
|
44
|
+
oldString={asString(input.old_string)}
|
|
45
|
+
newString={asString(input.new_string)}
|
|
46
|
+
visibility={visibilityFor("Edit.diff")}
|
|
47
|
+
/>
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
case "Read":
|
|
51
|
+
return (
|
|
52
|
+
<ReadInput
|
|
53
|
+
filePath={asString(input.file_path)}
|
|
54
|
+
offset={asOptionalNumber(input.offset)}
|
|
55
|
+
limit={asOptionalNumber(input.limit)}
|
|
56
|
+
/>
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
case "Write":
|
|
60
|
+
return (
|
|
61
|
+
<WriteContent
|
|
62
|
+
filePath={asString(input.file_path)}
|
|
63
|
+
content={asString(input.content)}
|
|
64
|
+
visibility={visibilityFor("Write.content")}
|
|
65
|
+
/>
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
case "Task":
|
|
69
|
+
return (
|
|
70
|
+
<TaskNested
|
|
71
|
+
description={asOptionalString(input.description)}
|
|
72
|
+
prompt={asString(input.prompt)}
|
|
73
|
+
visibility={visibilityFor("Task.nested")}
|
|
74
|
+
/>
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
default:
|
|
78
|
+
return <Text>{`▸ ${block.name}: ${summarizeInput(input)}`}</Text>;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function asString(v: unknown): string {
|
|
83
|
+
return typeof v === "string" ? v : "";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function asOptionalString(v: unknown): string | undefined {
|
|
87
|
+
return typeof v === "string" ? v : undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function asOptionalNumber(v: unknown): number | undefined {
|
|
91
|
+
return typeof v === "number" ? v : undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Compact one-line summary of arbitrary tool input. */
|
|
95
|
+
function summarizeInput(input: Record<string, unknown>): string {
|
|
96
|
+
const entries = Object.entries(input);
|
|
97
|
+
if (entries.length === 0) return "(no input)";
|
|
98
|
+
const parts = entries.slice(0, 3).map(([k, v]) => `${k}=${oneLine(v)}`);
|
|
99
|
+
return parts.join(" ");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function oneLine(v: unknown): string {
|
|
103
|
+
if (typeof v === "string") {
|
|
104
|
+
const flat = v.replace(/\s+/g, " ").trim();
|
|
105
|
+
return flat.length > 60 ? `${flat.slice(0, 57)}...` : flat;
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
return JSON.stringify(v);
|
|
109
|
+
} catch {
|
|
110
|
+
return String(v);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { render } from "ink-testing-library";
|
|
3
|
+
import { WriteContent } from "./WriteContent.tsx";
|
|
4
|
+
|
|
5
|
+
const body = Array.from({ length: 8 }, (_, i) => `body${i + 1}`).join("\n");
|
|
6
|
+
|
|
7
|
+
describe("WriteContent", () => {
|
|
8
|
+
test("show: file path + full content", () => {
|
|
9
|
+
const { lastFrame } = render(
|
|
10
|
+
<WriteContent filePath="/tmp/out.txt" content={body} visibility="show" />,
|
|
11
|
+
);
|
|
12
|
+
const frame = lastFrame() ?? "";
|
|
13
|
+
expect(frame).toContain("/tmp/out.txt");
|
|
14
|
+
expect(frame).toContain("body1");
|
|
15
|
+
expect(frame).toContain("body8");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("hide: header only, no content body", () => {
|
|
19
|
+
const { lastFrame } = render(
|
|
20
|
+
<WriteContent filePath="/tmp/out.txt" content={body} visibility="hide" />,
|
|
21
|
+
);
|
|
22
|
+
const frame = lastFrame() ?? "";
|
|
23
|
+
expect(frame).toContain("/tmp/out.txt");
|
|
24
|
+
expect(frame).not.toContain("body1");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("summary: file path + line count, no body", () => {
|
|
28
|
+
const { lastFrame } = render(
|
|
29
|
+
<WriteContent filePath="/tmp/out.txt" content={body} visibility="summary" />,
|
|
30
|
+
);
|
|
31
|
+
const frame = lastFrame() ?? "";
|
|
32
|
+
expect(frame).toContain("/tmp/out.txt");
|
|
33
|
+
expect(frame).toMatch(/8 lines/);
|
|
34
|
+
expect(frame).not.toContain("body1");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("dim: full body with dim styling", () => {
|
|
38
|
+
const { lastFrame } = render(<WriteContent filePath="/x" content="hi" visibility="dim" />);
|
|
39
|
+
const frame = lastFrame() ?? "";
|
|
40
|
+
expect(frame).toContain("hi");
|
|
41
|
+
expect(frame).toMatch(/\x1B\[2m/);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("show vs hide vs summary differ", () => {
|
|
45
|
+
const p = { filePath: "/x", content: body };
|
|
46
|
+
const a = render(<WriteContent {...p} visibility="show" />).lastFrame() ?? "";
|
|
47
|
+
const b = render(<WriteContent {...p} visibility="hide" />).lastFrame() ?? "";
|
|
48
|
+
const c = render(<WriteContent {...p} visibility="summary" />).lastFrame() ?? "";
|
|
49
|
+
expect(a).not.toBe(b);
|
|
50
|
+
expect(a).not.toBe(c);
|
|
51
|
+
expect(b).not.toBe(c);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Box, Text } from "ink";
|
|
2
|
+
import type { Visibility } from "../types/events.ts";
|
|
3
|
+
import { countLines } from "./summarize.ts";
|
|
4
|
+
|
|
5
|
+
export interface WriteContentProps {
|
|
6
|
+
filePath: string;
|
|
7
|
+
content: string;
|
|
8
|
+
visibility: Visibility;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Renders a Write tool_use: file path + new file content.
|
|
13
|
+
* Filterable element id: "Write.content".
|
|
14
|
+
*
|
|
15
|
+
* Visibility:
|
|
16
|
+
* show — file path header + full body
|
|
17
|
+
* hide — header only ("▸ Write: <path>")
|
|
18
|
+
* summary — file path + line count of new content
|
|
19
|
+
* dim — full body, ANSI-faint
|
|
20
|
+
*/
|
|
21
|
+
export function WriteContent({ filePath, content, visibility }: WriteContentProps): JSX.Element {
|
|
22
|
+
const header = `▸ Write: ${filePath}`;
|
|
23
|
+
|
|
24
|
+
if (visibility === "hide") {
|
|
25
|
+
return <Text dimColor>{header}</Text>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (visibility === "summary") {
|
|
29
|
+
return <Text>{`${header} (${countLines(content)} lines)`}</Text>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (visibility === "dim") {
|
|
33
|
+
return (
|
|
34
|
+
<Box flexDirection="column">
|
|
35
|
+
<Text dimColor>{header}</Text>
|
|
36
|
+
<Text dimColor>{content}</Text>
|
|
37
|
+
</Box>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<Box flexDirection="column">
|
|
43
|
+
<Text>{header}</Text>
|
|
44
|
+
<Text>{content}</Text>
|
|
45
|
+
</Box>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public surface of slice C — per-event Ink renderers.
|
|
3
|
+
*
|
|
4
|
+
* Slice B imports from this module:
|
|
5
|
+
* import { TextRenderer, ToolUseRenderer, ToolResultRenderer, ... } from "./renderers";
|
|
6
|
+
*
|
|
7
|
+
* Three categories:
|
|
8
|
+
* 1. Top-level dispatchers — ToolUseRenderer, ToolResultRenderer
|
|
9
|
+
* 2. Per-event renderers — TextRenderer, ThinkingRenderer, SystemInit,
|
|
10
|
+
* ErrorRenderer, ResultRenderer
|
|
11
|
+
* 3. Per-tool element views — BashInput, BashOutput, EditDiff,
|
|
12
|
+
* ReadInput, ReadContent, WriteContent,
|
|
13
|
+
* TaskNested
|
|
14
|
+
*
|
|
15
|
+
* All filterable per-tool views accept a `visibility: Visibility` prop;
|
|
16
|
+
* the dispatchers consume a `visibilityFor: (id) => Visibility` resolver
|
|
17
|
+
* supplied by slice B.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export { BashInput } from "./BashInput.tsx";
|
|
21
|
+
export type { BashInputProps } from "./BashInput.tsx";
|
|
22
|
+
export { BashOutput } from "./BashOutput.tsx";
|
|
23
|
+
export type { BashOutputProps } from "./BashOutput.tsx";
|
|
24
|
+
export { EditDiff } from "./EditDiff.tsx";
|
|
25
|
+
export type { EditDiffProps } from "./EditDiff.tsx";
|
|
26
|
+
export { ErrorRenderer } from "./ErrorRenderer.tsx";
|
|
27
|
+
export type { ErrorRendererProps } from "./ErrorRenderer.tsx";
|
|
28
|
+
export { ReadContent } from "./ReadContent.tsx";
|
|
29
|
+
export type { ReadContentProps } from "./ReadContent.tsx";
|
|
30
|
+
export { ReadInput } from "./ReadInput.tsx";
|
|
31
|
+
export type { ReadInputProps } from "./ReadInput.tsx";
|
|
32
|
+
export { ResultRenderer } from "./ResultRenderer.tsx";
|
|
33
|
+
export type { ResultRendererProps } from "./ResultRenderer.tsx";
|
|
34
|
+
export { SystemInit } from "./SystemInit.tsx";
|
|
35
|
+
export type { SystemInitProps } from "./SystemInit.tsx";
|
|
36
|
+
export { TaskNested } from "./TaskNested.tsx";
|
|
37
|
+
export type { TaskNestedProps } from "./TaskNested.tsx";
|
|
38
|
+
export {
|
|
39
|
+
detectGlowPath,
|
|
40
|
+
type GlowRunner,
|
|
41
|
+
runGlow,
|
|
42
|
+
TextRenderer,
|
|
43
|
+
} from "./TextRenderer.tsx";
|
|
44
|
+
export type { TextRendererProps } from "./TextRenderer.tsx";
|
|
45
|
+
export { ThinkingRenderer } from "./ThinkingRenderer.tsx";
|
|
46
|
+
export type { ThinkingRendererProps } from "./ThinkingRenderer.tsx";
|
|
47
|
+
export { ToolResultRenderer } from "./ToolResultRenderer.tsx";
|
|
48
|
+
export type { ToolResultRendererProps } from "./ToolResultRenderer.tsx";
|
|
49
|
+
export { ToolUseRenderer } from "./ToolUseRenderer.tsx";
|
|
50
|
+
export type { ToolUseRendererProps } from "./ToolUseRenderer.tsx";
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Visibility-summary helpers shared by per-tool renderers.
|
|
3
|
+
*
|
|
4
|
+
* The generic "summary" rule from docs/filter-state-spec.md is:
|
|
5
|
+
* first 5 lines + "(… N more lines)"
|
|
6
|
+
*
|
|
7
|
+
* Per-element variants (Edit.diff, Read.content, Write.content) override
|
|
8
|
+
* this with path + line count; those live in their own renderers.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const SUMMARY_LINE_LIMIT = 5;
|
|
12
|
+
|
|
13
|
+
export interface FirstNLinesResult {
|
|
14
|
+
head: string;
|
|
15
|
+
hiddenLines: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function firstNLines(text: string, n: number = SUMMARY_LINE_LIMIT): FirstNLinesResult {
|
|
19
|
+
const lines = text.split("\n");
|
|
20
|
+
if (lines.length <= n) return { head: text, hiddenLines: 0 };
|
|
21
|
+
return { head: lines.slice(0, n).join("\n"), hiddenLines: lines.length - n };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function countLines(text: string): number {
|
|
25
|
+
if (text.length === 0) return 0;
|
|
26
|
+
return text.split("\n").length;
|
|
27
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { ClaudeEvent, FilterState, UserTurn, Visibility } from "./events.ts";
|
|
3
|
+
|
|
4
|
+
describe("events contract", () => {
|
|
5
|
+
test("UserTurn shape is constructible", () => {
|
|
6
|
+
const turn: UserTurn = {
|
|
7
|
+
type: "user",
|
|
8
|
+
message: {
|
|
9
|
+
role: "user",
|
|
10
|
+
content: [{ type: "text", text: "hello" }],
|
|
11
|
+
},
|
|
12
|
+
};
|
|
13
|
+
expect(turn.type).toBe("user");
|
|
14
|
+
expect(turn.message.content[0].text).toBe("hello");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("ClaudeEvent discriminator narrows correctly", () => {
|
|
18
|
+
const evt: ClaudeEvent = {
|
|
19
|
+
type: "result",
|
|
20
|
+
subtype: "success",
|
|
21
|
+
is_error: false,
|
|
22
|
+
session_id: "s",
|
|
23
|
+
uuid: "u",
|
|
24
|
+
result: "ok",
|
|
25
|
+
num_turns: 1,
|
|
26
|
+
duration_ms: 0,
|
|
27
|
+
duration_api_ms: 0,
|
|
28
|
+
total_cost_usd: 0,
|
|
29
|
+
};
|
|
30
|
+
if (evt.type === "result") {
|
|
31
|
+
expect(evt.is_error).toBe(false);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("FilterState accepts preset + overrides", () => {
|
|
36
|
+
const state: FilterState = {
|
|
37
|
+
preset: "normal",
|
|
38
|
+
overrides: { "Bash.output": "show" },
|
|
39
|
+
};
|
|
40
|
+
const visibility: Visibility = state.overrides["Bash.output"] ?? "hide";
|
|
41
|
+
expect(visibility).toBe("show");
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stream-json event types emitted by:
|
|
3
|
+
* claude --print --verbose --input-format stream-json --output-format stream-json
|
|
4
|
+
*
|
|
5
|
+
* See docs/stream-json-findings.md for provenance and docs/event-spec.md for
|
|
6
|
+
* the slice-level contract.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type ClaudeEvent = SystemEvent | AssistantEvent | UserEvent | ResultEvent | RateLimitEvent;
|
|
10
|
+
|
|
11
|
+
export interface SystemEvent {
|
|
12
|
+
type: "system";
|
|
13
|
+
subtype: "init" | (string & {});
|
|
14
|
+
session_id: string;
|
|
15
|
+
uuid: string;
|
|
16
|
+
cwd?: string;
|
|
17
|
+
model?: string;
|
|
18
|
+
tools?: string[];
|
|
19
|
+
slash_commands?: string[];
|
|
20
|
+
permissionMode?: string;
|
|
21
|
+
memory_paths?: string[];
|
|
22
|
+
claude_code_version?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface AssistantEvent {
|
|
26
|
+
type: "assistant";
|
|
27
|
+
session_id: string;
|
|
28
|
+
uuid: string;
|
|
29
|
+
parent_tool_use_id?: string | null;
|
|
30
|
+
request_id?: string;
|
|
31
|
+
message: AssistantMessage;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface AssistantMessage {
|
|
35
|
+
id?: string;
|
|
36
|
+
model: string;
|
|
37
|
+
role: "assistant";
|
|
38
|
+
content: ContentBlock[];
|
|
39
|
+
stop_reason?: string | null;
|
|
40
|
+
stop_sequence?: string | null;
|
|
41
|
+
usage?: TokenUsage;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface UserEvent {
|
|
45
|
+
type: "user";
|
|
46
|
+
session_id: string;
|
|
47
|
+
uuid?: string;
|
|
48
|
+
message: UserMessage;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface UserMessage {
|
|
52
|
+
role: "user";
|
|
53
|
+
content: ContentBlock[] | string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type ContentBlock = TextBlock | ToolUseBlock | ToolResultBlock | ThinkingBlock;
|
|
57
|
+
|
|
58
|
+
export interface TextBlock {
|
|
59
|
+
type: "text";
|
|
60
|
+
text: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface ToolUseBlock {
|
|
64
|
+
type: "tool_use";
|
|
65
|
+
id: string;
|
|
66
|
+
name: string;
|
|
67
|
+
input: Record<string, unknown>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface ToolResultBlock {
|
|
71
|
+
type: "tool_result";
|
|
72
|
+
tool_use_id: string;
|
|
73
|
+
content: string | TextBlock[];
|
|
74
|
+
is_error?: boolean;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface ThinkingBlock {
|
|
78
|
+
type: "thinking";
|
|
79
|
+
thinking: string;
|
|
80
|
+
signature?: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface ResultEvent {
|
|
84
|
+
type: "result";
|
|
85
|
+
subtype: "success" | "error" | (string & {});
|
|
86
|
+
is_error: boolean;
|
|
87
|
+
session_id: string;
|
|
88
|
+
uuid: string;
|
|
89
|
+
result: string;
|
|
90
|
+
num_turns: number;
|
|
91
|
+
duration_ms: number;
|
|
92
|
+
duration_api_ms: number;
|
|
93
|
+
total_cost_usd: number;
|
|
94
|
+
usage?: TokenUsage;
|
|
95
|
+
modelUsage?: Record<string, TokenUsage>;
|
|
96
|
+
stop_reason?: string | null;
|
|
97
|
+
terminal_reason?: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface RateLimitEvent {
|
|
101
|
+
type: "rate_limit_event";
|
|
102
|
+
session_id?: string;
|
|
103
|
+
rate_limit_info?: Record<string, unknown>;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface TokenUsage {
|
|
107
|
+
input_tokens: number;
|
|
108
|
+
output_tokens: number;
|
|
109
|
+
cache_creation_input_tokens?: number;
|
|
110
|
+
cache_read_input_tokens?: number;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Input event: what the renderer writes to claude's stdin as a user turn.
|
|
115
|
+
* Emit as one JSON object per line, `\n` terminated.
|
|
116
|
+
*/
|
|
117
|
+
export interface UserTurn {
|
|
118
|
+
type: "user";
|
|
119
|
+
message: {
|
|
120
|
+
role: "user";
|
|
121
|
+
content: [{ type: "text"; text: string }];
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Filter state types (see docs/filter-state-spec.md).
|
|
127
|
+
*/
|
|
128
|
+
export type Preset = "quiet" | "normal" | "verbose" | "debug";
|
|
129
|
+
|
|
130
|
+
export type Visibility = "show" | "hide" | "summary" | "dim";
|
|
131
|
+
|
|
132
|
+
export type ElementId =
|
|
133
|
+
| "thinking"
|
|
134
|
+
| "Bash.input"
|
|
135
|
+
| "Bash.output"
|
|
136
|
+
| "Edit.diff"
|
|
137
|
+
| "Read.content"
|
|
138
|
+
| "Write.content"
|
|
139
|
+
| "Task.nested"
|
|
140
|
+
| "errors";
|
|
141
|
+
|
|
142
|
+
export interface FilterState {
|
|
143
|
+
preset: Preset;
|
|
144
|
+
overrides: Partial<Record<ElementId, Visibility>>;
|
|
145
|
+
}
|