@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,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the NDJSON event parser.
|
|
3
|
+
*
|
|
4
|
+
* Strategy: feed real fixtures (recorded from `claude --print --verbose
|
|
5
|
+
* --input-format stream-json --output-format stream-json`) through the parser
|
|
6
|
+
* and assert on the discriminated union shapes. No live claude invocation
|
|
7
|
+
* needed.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, expect, test } from "bun:test";
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { parseNdjsonStream } from "./event-parser.ts";
|
|
13
|
+
import type {
|
|
14
|
+
AssistantEvent,
|
|
15
|
+
ContentBlock,
|
|
16
|
+
ResultEvent,
|
|
17
|
+
SystemEvent,
|
|
18
|
+
TextBlock,
|
|
19
|
+
ToolResultBlock,
|
|
20
|
+
ToolUseBlock,
|
|
21
|
+
} from "./types/events.ts";
|
|
22
|
+
|
|
23
|
+
const fixtureDir = join(import.meta.dir, "__fixtures__");
|
|
24
|
+
|
|
25
|
+
function fixtureStream(name: string): ReadableStream<Uint8Array> {
|
|
26
|
+
const bytes = readFileSync(join(fixtureDir, name));
|
|
27
|
+
return new ReadableStream<Uint8Array>({
|
|
28
|
+
start(controller) {
|
|
29
|
+
controller.enqueue(new Uint8Array(bytes));
|
|
30
|
+
controller.close();
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe("parseNdjsonStream — text turn", () => {
|
|
36
|
+
test("emits system/init, rate_limit_event, assistant, result in order", async () => {
|
|
37
|
+
const events = [];
|
|
38
|
+
for await (const ev of parseNdjsonStream(fixtureStream("text-turn.ndjson"))) {
|
|
39
|
+
events.push(ev);
|
|
40
|
+
}
|
|
41
|
+
const types = events.map((e) => e.type);
|
|
42
|
+
expect(types).toContain("system");
|
|
43
|
+
expect(types).toContain("assistant");
|
|
44
|
+
expect(types).toContain("result");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("system event has subtype init and session_id", async () => {
|
|
48
|
+
for await (const ev of parseNdjsonStream(fixtureStream("text-turn.ndjson"))) {
|
|
49
|
+
if (ev.type === "system") {
|
|
50
|
+
const sys = ev as SystemEvent;
|
|
51
|
+
expect(sys.subtype).toBe("init");
|
|
52
|
+
expect(typeof sys.session_id).toBe("string");
|
|
53
|
+
expect(sys.session_id.length).toBeGreaterThan(0);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
throw new Error("no system event found");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("assistant event has text content block", async () => {
|
|
61
|
+
for await (const ev of parseNdjsonStream(fixtureStream("text-turn.ndjson"))) {
|
|
62
|
+
if (ev.type === "assistant") {
|
|
63
|
+
const ast = ev as AssistantEvent;
|
|
64
|
+
const textBlock = ast.message.content.find((b: ContentBlock) => b.type === "text") as
|
|
65
|
+
| TextBlock
|
|
66
|
+
| undefined;
|
|
67
|
+
expect(textBlock).toBeDefined();
|
|
68
|
+
expect(typeof textBlock?.text).toBe("string");
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
throw new Error("no assistant event found");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("result event is success and carries num_turns", async () => {
|
|
76
|
+
for await (const ev of parseNdjsonStream(fixtureStream("text-turn.ndjson"))) {
|
|
77
|
+
if (ev.type === "result") {
|
|
78
|
+
const res = ev as ResultEvent;
|
|
79
|
+
expect(res.subtype).toBe("success");
|
|
80
|
+
expect(res.is_error).toBe(false);
|
|
81
|
+
expect(typeof res.num_turns).toBe("number");
|
|
82
|
+
expect(typeof res.duration_ms).toBe("number");
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
throw new Error("no result event found");
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("parseNdjsonStream — tool-use turn", () => {
|
|
91
|
+
test("assistant event contains tool_use block", async () => {
|
|
92
|
+
for await (const ev of parseNdjsonStream(fixtureStream("tool-use-turn.ndjson"))) {
|
|
93
|
+
if (ev.type === "assistant") {
|
|
94
|
+
const ast = ev as AssistantEvent;
|
|
95
|
+
const toolBlock = ast.message.content.find((b: ContentBlock) => b.type === "tool_use") as
|
|
96
|
+
| ToolUseBlock
|
|
97
|
+
| undefined;
|
|
98
|
+
if (toolBlock !== undefined) {
|
|
99
|
+
expect(toolBlock.id).toBeTruthy();
|
|
100
|
+
expect(typeof toolBlock.name).toBe("string");
|
|
101
|
+
expect(typeof toolBlock.input).toBe("object");
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
throw new Error("no tool_use block found in assistant event");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("user event contains tool_result block", async () => {
|
|
110
|
+
for await (const ev of parseNdjsonStream(fixtureStream("tool-use-turn.ndjson"))) {
|
|
111
|
+
if (ev.type === "user") {
|
|
112
|
+
const msg = ev.message;
|
|
113
|
+
const content = Array.isArray(msg.content) ? msg.content : [];
|
|
114
|
+
const resultBlock = content.find((b: ContentBlock) => b.type === "tool_result") as
|
|
115
|
+
| ToolResultBlock
|
|
116
|
+
| undefined;
|
|
117
|
+
if (resultBlock !== undefined) {
|
|
118
|
+
expect(typeof resultBlock.tool_use_id).toBe("string");
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
throw new Error("no tool_result block found in user event");
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("parseNdjsonStream — slash-command turn", () => {
|
|
128
|
+
test("assistant event has model <synthetic>", async () => {
|
|
129
|
+
for await (const ev of parseNdjsonStream(fixtureStream("slash-command-turn.ndjson"))) {
|
|
130
|
+
if (ev.type === "assistant") {
|
|
131
|
+
const ast = ev as AssistantEvent;
|
|
132
|
+
expect(ast.message.model).toBe("<synthetic>");
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
throw new Error("no assistant event found");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("result event is success", async () => {
|
|
140
|
+
for await (const ev of parseNdjsonStream(fixtureStream("slash-command-turn.ndjson"))) {
|
|
141
|
+
if (ev.type === "result") {
|
|
142
|
+
const res = ev as ResultEvent;
|
|
143
|
+
expect(res.subtype).toBe("success");
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
throw new Error("no result event found");
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe("parseNdjsonStream — unknown slash command", () => {
|
|
152
|
+
test("no assistant event emitted", async () => {
|
|
153
|
+
let sawAssistant = false;
|
|
154
|
+
for await (const ev of parseNdjsonStream(fixtureStream("unknown-slash-command-turn.ndjson"))) {
|
|
155
|
+
if (ev.type === "assistant") {
|
|
156
|
+
sawAssistant = true;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
expect(sawAssistant).toBe(false);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("result.result contains Unknown command and is_error is false", async () => {
|
|
163
|
+
for await (const ev of parseNdjsonStream(fixtureStream("unknown-slash-command-turn.ndjson"))) {
|
|
164
|
+
if (ev.type === "result") {
|
|
165
|
+
const res = ev as ResultEvent;
|
|
166
|
+
expect(res.result).toContain("Unknown command");
|
|
167
|
+
expect(res.is_error).toBe(false);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
throw new Error("no result event found");
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe("parseNdjsonStream — multi-turn", () => {
|
|
176
|
+
test("all events share the same session_id", async () => {
|
|
177
|
+
const sessionIds = new Set<string>();
|
|
178
|
+
for await (const ev of parseNdjsonStream(fixtureStream("multi-turn.ndjson"))) {
|
|
179
|
+
if ("session_id" in ev && typeof ev.session_id === "string") {
|
|
180
|
+
sessionIds.add(ev.session_id);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
expect(sessionIds.size).toBe(1);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("result event carries num_turns reflecting both turns", async () => {
|
|
187
|
+
// When two user turns are piped back-to-back without waiting for a result
|
|
188
|
+
// between them, claude processes them as one session run and emits a single
|
|
189
|
+
// result with num_turns reflecting the combined work (observed: 4 turns due
|
|
190
|
+
// to internal tool calls). The important invariant is that num_turns >= 1.
|
|
191
|
+
for await (const ev of parseNdjsonStream(fixtureStream("multi-turn.ndjson"))) {
|
|
192
|
+
if (ev.type === "result") {
|
|
193
|
+
const res = ev as ResultEvent;
|
|
194
|
+
expect(res.num_turns).toBeGreaterThanOrEqual(1);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
throw new Error("no result event found");
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
describe("parseNdjsonStream — large-line tolerance", () => {
|
|
203
|
+
test("handles a line of 64KB without dropping it", async () => {
|
|
204
|
+
// Synthetic: a valid JSON object whose text field is 64KB long.
|
|
205
|
+
const bigText = "x".repeat(64 * 1024);
|
|
206
|
+
const payload = JSON.stringify({
|
|
207
|
+
type: "assistant",
|
|
208
|
+
session_id: "test-session",
|
|
209
|
+
uuid: "test-uuid",
|
|
210
|
+
message: {
|
|
211
|
+
id: "msg_test",
|
|
212
|
+
model: "test-model",
|
|
213
|
+
role: "assistant",
|
|
214
|
+
content: [{ type: "text", text: bigText }],
|
|
215
|
+
stop_reason: "end_turn",
|
|
216
|
+
stop_sequence: null,
|
|
217
|
+
usage: { input_tokens: 1, output_tokens: 1 },
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
const bytes = new TextEncoder().encode(`${payload}\n`);
|
|
221
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
222
|
+
start(controller) {
|
|
223
|
+
controller.enqueue(bytes);
|
|
224
|
+
controller.close();
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
const events = [];
|
|
228
|
+
for await (const ev of parseNdjsonStream(stream)) {
|
|
229
|
+
events.push(ev);
|
|
230
|
+
}
|
|
231
|
+
expect(events.length).toBe(1);
|
|
232
|
+
const ast = events[0] as AssistantEvent;
|
|
233
|
+
expect(ast.type).toBe("assistant");
|
|
234
|
+
const textBlock = ast.message.content[0] as TextBlock;
|
|
235
|
+
expect(textBlock.text.length).toBe(64 * 1024);
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
describe("parseNdjsonStream — partial trailing data", () => {
|
|
240
|
+
test("discards incomplete trailing line without throwing", async () => {
|
|
241
|
+
// A complete line followed by partial (no trailing newline).
|
|
242
|
+
const complete = JSON.stringify({
|
|
243
|
+
type: "result",
|
|
244
|
+
subtype: "success",
|
|
245
|
+
is_error: false,
|
|
246
|
+
session_id: "s1",
|
|
247
|
+
uuid: "u1",
|
|
248
|
+
result: "ok",
|
|
249
|
+
num_turns: 1,
|
|
250
|
+
duration_ms: 100,
|
|
251
|
+
duration_api_ms: 100,
|
|
252
|
+
total_cost_usd: 0,
|
|
253
|
+
});
|
|
254
|
+
const partial = '{"type":"assistant","partial":true';
|
|
255
|
+
const text = `${complete}\n${partial}`;
|
|
256
|
+
const bytes = new TextEncoder().encode(text);
|
|
257
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
258
|
+
start(controller) {
|
|
259
|
+
controller.enqueue(bytes);
|
|
260
|
+
controller.close();
|
|
261
|
+
},
|
|
262
|
+
});
|
|
263
|
+
const events = [];
|
|
264
|
+
for await (const ev of parseNdjsonStream(stream)) {
|
|
265
|
+
events.push(ev);
|
|
266
|
+
}
|
|
267
|
+
// Only the complete line should yield an event; partial is silently dropped.
|
|
268
|
+
expect(events.length).toBe(1);
|
|
269
|
+
expect(events[0]?.type).toBe("result");
|
|
270
|
+
});
|
|
271
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Line-buffered NDJSON parser for claude's stream-json output.
|
|
3
|
+
*
|
|
4
|
+
* Reads a ReadableStream<Uint8Array> and yields parsed ClaudeEvent objects.
|
|
5
|
+
* Designed for Bun.spawn's stdout stream.
|
|
6
|
+
*
|
|
7
|
+
* Design notes:
|
|
8
|
+
* - Buffers bytes across chunks and splits on `\n`. Lines can be ~3KB+ in
|
|
9
|
+
* practice (system/init lines embed all slash commands and tool names);
|
|
10
|
+
* the 64KB default chunk size for the dynamic buffer is intentionally
|
|
11
|
+
* generous. There is no per-line cap.
|
|
12
|
+
* - Partial trailing data (no terminating `\n` when the stream closes) is
|
|
13
|
+
* silently dropped — this matches observed behaviour where the process
|
|
14
|
+
* always terminates cleanly with a fully-written result line.
|
|
15
|
+
* - JSON.parse errors on individual lines are silently dropped so that
|
|
16
|
+
* unexpected diagnostic output or future unknown event types do not break
|
|
17
|
+
* callers. The parser is meant to be forward-compatible.
|
|
18
|
+
*/
|
|
19
|
+
import type { ClaudeEvent } from "./types/events.ts";
|
|
20
|
+
|
|
21
|
+
const NEWLINE = 0x0a; // '\n'
|
|
22
|
+
|
|
23
|
+
export async function* parseNdjsonStream(
|
|
24
|
+
stream: ReadableStream<Uint8Array>,
|
|
25
|
+
): AsyncGenerator<ClaudeEvent> {
|
|
26
|
+
const reader = stream.getReader();
|
|
27
|
+
|
|
28
|
+
// Dynamic byte accumulator — grows on demand.
|
|
29
|
+
let buf = new Uint8Array(64 * 1024);
|
|
30
|
+
let bufLen = 0;
|
|
31
|
+
|
|
32
|
+
const decoder = new TextDecoder();
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
while (true) {
|
|
36
|
+
const { done, value } = await reader.read();
|
|
37
|
+
|
|
38
|
+
if (value !== undefined) {
|
|
39
|
+
// Grow buffer if needed.
|
|
40
|
+
while (bufLen + value.length > buf.length) {
|
|
41
|
+
const next = new Uint8Array(buf.length * 2);
|
|
42
|
+
next.set(buf.subarray(0, bufLen));
|
|
43
|
+
buf = next;
|
|
44
|
+
}
|
|
45
|
+
buf.set(value, bufLen);
|
|
46
|
+
bufLen += value.length;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Drain all complete lines from the buffer.
|
|
50
|
+
let start = 0;
|
|
51
|
+
while (start < bufLen) {
|
|
52
|
+
const newlinePos = buf.subarray(start, bufLen).indexOf(NEWLINE);
|
|
53
|
+
if (newlinePos === -1) break;
|
|
54
|
+
|
|
55
|
+
const lineEnd = start + newlinePos;
|
|
56
|
+
const line = decoder.decode(buf.subarray(start, lineEnd)).trim();
|
|
57
|
+
start = lineEnd + 1;
|
|
58
|
+
|
|
59
|
+
if (line.length === 0) continue;
|
|
60
|
+
|
|
61
|
+
const event = tryParseLine(line);
|
|
62
|
+
if (event !== null) yield event;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Compact: move remaining bytes to front.
|
|
66
|
+
if (start > 0) {
|
|
67
|
+
buf.copyWithin(0, start, bufLen);
|
|
68
|
+
bufLen -= start;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (done) break;
|
|
72
|
+
}
|
|
73
|
+
} finally {
|
|
74
|
+
reader.releaseLock();
|
|
75
|
+
}
|
|
76
|
+
// Any bytes remaining in buf are a partial line — silently dropped.
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function tryParseLine(line: string): ClaudeEvent | null {
|
|
80
|
+
try {
|
|
81
|
+
const obj = JSON.parse(line) as unknown;
|
|
82
|
+
if (obj !== null && typeof obj === "object" && "type" in obj) {
|
|
83
|
+
return obj as ClaudeEvent;
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
} catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { cyclePreset, defaultState, resolve, toggleElement } from "./filter-state.ts";
|
|
3
|
+
import type { FilterState } from "./types/events.ts";
|
|
4
|
+
|
|
5
|
+
describe("defaultState", () => {
|
|
6
|
+
test("starts on normal preset with no overrides", () => {
|
|
7
|
+
const state = defaultState();
|
|
8
|
+
expect(state.preset).toBe("normal");
|
|
9
|
+
expect(state.overrides).toEqual({});
|
|
10
|
+
});
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe("resolve — preset defaults", () => {
|
|
14
|
+
const presets = (overrides: FilterState["overrides"] = {}) => ({
|
|
15
|
+
quiet: { preset: "quiet", overrides } as FilterState,
|
|
16
|
+
normal: { preset: "normal", overrides } as FilterState,
|
|
17
|
+
verbose: { preset: "verbose", overrides } as FilterState,
|
|
18
|
+
debug: { preset: "debug", overrides } as FilterState,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("thinking: hide / dim / dim / show", () => {
|
|
22
|
+
const p = presets();
|
|
23
|
+
expect(resolve("thinking", p.quiet)).toBe("hide");
|
|
24
|
+
expect(resolve("thinking", p.normal)).toBe("dim");
|
|
25
|
+
expect(resolve("thinking", p.verbose)).toBe("dim");
|
|
26
|
+
expect(resolve("thinking", p.debug)).toBe("show");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("Bash.input: summary / show / show / show", () => {
|
|
30
|
+
const p = presets();
|
|
31
|
+
expect(resolve("Bash.input", p.quiet)).toBe("summary");
|
|
32
|
+
expect(resolve("Bash.input", p.normal)).toBe("show");
|
|
33
|
+
expect(resolve("Bash.input", p.verbose)).toBe("show");
|
|
34
|
+
expect(resolve("Bash.input", p.debug)).toBe("show");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("Bash.output: hide / hide / show / show", () => {
|
|
38
|
+
const p = presets();
|
|
39
|
+
expect(resolve("Bash.output", p.quiet)).toBe("hide");
|
|
40
|
+
expect(resolve("Bash.output", p.normal)).toBe("hide");
|
|
41
|
+
expect(resolve("Bash.output", p.verbose)).toBe("show");
|
|
42
|
+
expect(resolve("Bash.output", p.debug)).toBe("show");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("Edit.diff: summary / show / show / show", () => {
|
|
46
|
+
const p = presets();
|
|
47
|
+
expect(resolve("Edit.diff", p.quiet)).toBe("summary");
|
|
48
|
+
expect(resolve("Edit.diff", p.normal)).toBe("show");
|
|
49
|
+
expect(resolve("Edit.diff", p.verbose)).toBe("show");
|
|
50
|
+
expect(resolve("Edit.diff", p.debug)).toBe("show");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("Read.content: hide / hide / summary / show", () => {
|
|
54
|
+
const p = presets();
|
|
55
|
+
expect(resolve("Read.content", p.quiet)).toBe("hide");
|
|
56
|
+
expect(resolve("Read.content", p.normal)).toBe("hide");
|
|
57
|
+
expect(resolve("Read.content", p.verbose)).toBe("summary");
|
|
58
|
+
expect(resolve("Read.content", p.debug)).toBe("show");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("Write.content: summary / summary / show / show", () => {
|
|
62
|
+
const p = presets();
|
|
63
|
+
expect(resolve("Write.content", p.quiet)).toBe("summary");
|
|
64
|
+
expect(resolve("Write.content", p.normal)).toBe("summary");
|
|
65
|
+
expect(resolve("Write.content", p.verbose)).toBe("show");
|
|
66
|
+
expect(resolve("Write.content", p.debug)).toBe("show");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("Task.nested: summary / summary / show / show", () => {
|
|
70
|
+
const p = presets();
|
|
71
|
+
expect(resolve("Task.nested", p.quiet)).toBe("summary");
|
|
72
|
+
expect(resolve("Task.nested", p.normal)).toBe("summary");
|
|
73
|
+
expect(resolve("Task.nested", p.verbose)).toBe("show");
|
|
74
|
+
expect(resolve("Task.nested", p.debug)).toBe("show");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("errors: show across all presets", () => {
|
|
78
|
+
const p = presets();
|
|
79
|
+
expect(resolve("errors", p.quiet)).toBe("show");
|
|
80
|
+
expect(resolve("errors", p.normal)).toBe("show");
|
|
81
|
+
expect(resolve("errors", p.verbose)).toBe("show");
|
|
82
|
+
expect(resolve("errors", p.debug)).toBe("show");
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe("resolve — overrides take precedence", () => {
|
|
87
|
+
test("override beats preset default", () => {
|
|
88
|
+
const state: FilterState = {
|
|
89
|
+
preset: "normal",
|
|
90
|
+
overrides: { "Bash.output": "show" },
|
|
91
|
+
};
|
|
92
|
+
expect(resolve("Bash.output", state)).toBe("show");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("non-overridden element still uses preset default", () => {
|
|
96
|
+
const state: FilterState = {
|
|
97
|
+
preset: "verbose",
|
|
98
|
+
overrides: { "Bash.output": "hide" },
|
|
99
|
+
};
|
|
100
|
+
expect(resolve("Read.content", state)).toBe("summary");
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("toggleElement", () => {
|
|
105
|
+
test("no override on a normally-shown element → flips to opposite (hide)", () => {
|
|
106
|
+
const state = defaultState(); // normal
|
|
107
|
+
// Bash.input default in normal is "show"
|
|
108
|
+
const next = toggleElement(state, "Bash.input");
|
|
109
|
+
expect(next.overrides["Bash.input"]).toBe("hide");
|
|
110
|
+
expect(resolve("Bash.input", next)).toBe("hide");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("no override on a hidden element → flips to opposite (show)", () => {
|
|
114
|
+
const state = defaultState(); // normal
|
|
115
|
+
// Bash.output default in normal is "hide"
|
|
116
|
+
const next = toggleElement(state, "Bash.output");
|
|
117
|
+
expect(next.overrides["Bash.output"]).toBe("show");
|
|
118
|
+
expect(resolve("Bash.output", next)).toBe("show");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("dim default → flips to hide (opposite-of-shown)", () => {
|
|
122
|
+
// thinking default in normal is "dim" — dim is a shown variant,
|
|
123
|
+
// so toggling should flip to "hide".
|
|
124
|
+
const state = defaultState();
|
|
125
|
+
const next = toggleElement(state, "thinking");
|
|
126
|
+
expect(next.overrides.thinking).toBe("hide");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("summary default → flips to hide", () => {
|
|
130
|
+
// Edit.diff default in normal is "show" actually — pick one that's summary.
|
|
131
|
+
// Task.nested in normal is "summary".
|
|
132
|
+
const state = defaultState();
|
|
133
|
+
const next = toggleElement(state, "Task.nested");
|
|
134
|
+
expect(next.overrides["Task.nested"]).toBe("hide");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("existing override → clears (reverts to preset default)", () => {
|
|
138
|
+
const state: FilterState = {
|
|
139
|
+
preset: "normal",
|
|
140
|
+
overrides: { "Bash.output": "show" },
|
|
141
|
+
};
|
|
142
|
+
const next = toggleElement(state, "Bash.output");
|
|
143
|
+
expect(next.overrides["Bash.output"]).toBeUndefined();
|
|
144
|
+
expect(resolve("Bash.output", next)).toBe("hide");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("does not mutate input state", () => {
|
|
148
|
+
const state = defaultState();
|
|
149
|
+
const before = JSON.stringify(state);
|
|
150
|
+
toggleElement(state, "Bash.output");
|
|
151
|
+
expect(JSON.stringify(state)).toBe(before);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe("cyclePreset", () => {
|
|
156
|
+
test("forward: quiet → normal → verbose → debug → quiet", () => {
|
|
157
|
+
let s: FilterState = { preset: "quiet", overrides: {} };
|
|
158
|
+
s = cyclePreset(s, 1);
|
|
159
|
+
expect(s.preset).toBe("normal");
|
|
160
|
+
s = cyclePreset(s, 1);
|
|
161
|
+
expect(s.preset).toBe("verbose");
|
|
162
|
+
s = cyclePreset(s, 1);
|
|
163
|
+
expect(s.preset).toBe("debug");
|
|
164
|
+
s = cyclePreset(s, 1);
|
|
165
|
+
expect(s.preset).toBe("quiet");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("backward: quiet → debug → verbose → normal → quiet", () => {
|
|
169
|
+
let s: FilterState = { preset: "quiet", overrides: {} };
|
|
170
|
+
s = cyclePreset(s, -1);
|
|
171
|
+
expect(s.preset).toBe("debug");
|
|
172
|
+
s = cyclePreset(s, -1);
|
|
173
|
+
expect(s.preset).toBe("verbose");
|
|
174
|
+
s = cyclePreset(s, -1);
|
|
175
|
+
expect(s.preset).toBe("normal");
|
|
176
|
+
s = cyclePreset(s, -1);
|
|
177
|
+
expect(s.preset).toBe("quiet");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("clears all overrides", () => {
|
|
181
|
+
const state: FilterState = {
|
|
182
|
+
preset: "normal",
|
|
183
|
+
overrides: { "Bash.output": "show", thinking: "hide" },
|
|
184
|
+
};
|
|
185
|
+
const next = cyclePreset(state, 1);
|
|
186
|
+
expect(next.preset).toBe("verbose");
|
|
187
|
+
expect(next.overrides).toEqual({});
|
|
188
|
+
});
|
|
189
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filter state reducer + utilities.
|
|
3
|
+
*
|
|
4
|
+
* See docs/filter-state-spec.md for the table of preset defaults and the
|
|
5
|
+
* toggle semantics. Pure functions; no React. Callers in App.tsx hold the
|
|
6
|
+
* state in a useState/useReducer.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { ElementId, FilterState, Preset, Visibility } from "./types/events.ts";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Preset defaults table. Mirror of the table in docs/filter-state-spec.md.
|
|
13
|
+
* Source of truth for `resolve()` when no per-element override is set.
|
|
14
|
+
*/
|
|
15
|
+
const PRESET_DEFAULTS: Record<Preset, Record<ElementId, Visibility>> = {
|
|
16
|
+
quiet: {
|
|
17
|
+
thinking: "hide",
|
|
18
|
+
"Bash.input": "summary",
|
|
19
|
+
"Bash.output": "hide",
|
|
20
|
+
"Edit.diff": "summary",
|
|
21
|
+
"Read.content": "hide",
|
|
22
|
+
"Write.content": "summary",
|
|
23
|
+
"Task.nested": "summary",
|
|
24
|
+
errors: "show",
|
|
25
|
+
},
|
|
26
|
+
normal: {
|
|
27
|
+
thinking: "dim",
|
|
28
|
+
"Bash.input": "show",
|
|
29
|
+
"Bash.output": "hide",
|
|
30
|
+
"Edit.diff": "show",
|
|
31
|
+
"Read.content": "hide",
|
|
32
|
+
"Write.content": "summary",
|
|
33
|
+
"Task.nested": "summary",
|
|
34
|
+
errors: "show",
|
|
35
|
+
},
|
|
36
|
+
verbose: {
|
|
37
|
+
thinking: "dim",
|
|
38
|
+
"Bash.input": "show",
|
|
39
|
+
"Bash.output": "show",
|
|
40
|
+
"Edit.diff": "show",
|
|
41
|
+
"Read.content": "summary",
|
|
42
|
+
"Write.content": "show",
|
|
43
|
+
"Task.nested": "show",
|
|
44
|
+
errors: "show",
|
|
45
|
+
},
|
|
46
|
+
debug: {
|
|
47
|
+
thinking: "show",
|
|
48
|
+
"Bash.input": "show",
|
|
49
|
+
"Bash.output": "show",
|
|
50
|
+
"Edit.diff": "show",
|
|
51
|
+
"Read.content": "show",
|
|
52
|
+
"Write.content": "show",
|
|
53
|
+
"Task.nested": "show",
|
|
54
|
+
errors: "show",
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const PRESET_CYCLE: Preset[] = ["quiet", "normal", "verbose", "debug"];
|
|
59
|
+
|
|
60
|
+
export function defaultState(): FilterState {
|
|
61
|
+
return { preset: "normal", overrides: {} };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function resolve(element: ElementId, state: FilterState): Visibility {
|
|
65
|
+
const override = state.overrides[element];
|
|
66
|
+
if (override !== undefined) return override;
|
|
67
|
+
return PRESET_DEFAULTS[state.preset][element];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Visibility "polarity": dim/show/summary are all "shown to the user in some
|
|
72
|
+
* form" — toggling away from any of them sets `hide`. Toggling from `hide`
|
|
73
|
+
* sets `show`. This is the "opposite of preset" rule from the spec.
|
|
74
|
+
*/
|
|
75
|
+
function oppositeOf(v: Visibility): Visibility {
|
|
76
|
+
return v === "hide" ? "show" : "hide";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function toggleElement(state: FilterState, element: ElementId): FilterState {
|
|
80
|
+
const nextOverrides = { ...state.overrides };
|
|
81
|
+
if (state.overrides[element] !== undefined) {
|
|
82
|
+
// Existing override → clear it (revert to preset default).
|
|
83
|
+
delete nextOverrides[element];
|
|
84
|
+
} else {
|
|
85
|
+
// No override → set opposite-of-preset.
|
|
86
|
+
const presetDefault = PRESET_DEFAULTS[state.preset][element];
|
|
87
|
+
nextOverrides[element] = oppositeOf(presetDefault);
|
|
88
|
+
}
|
|
89
|
+
return { preset: state.preset, overrides: nextOverrides };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function cyclePreset(state: FilterState, direction: 1 | -1): FilterState {
|
|
93
|
+
const i = PRESET_CYCLE.indexOf(state.preset);
|
|
94
|
+
const len = PRESET_CYCLE.length;
|
|
95
|
+
const nextIdx = (i + direction + len) % len;
|
|
96
|
+
const nextPreset = PRESET_CYCLE[nextIdx];
|
|
97
|
+
// Type-narrow: nextIdx is in [0, len-1], so PRESET_CYCLE[nextIdx] is defined.
|
|
98
|
+
// noUncheckedIndexedAccess requires the guard.
|
|
99
|
+
if (nextPreset === undefined) {
|
|
100
|
+
throw new Error(`cyclePreset: invalid index ${nextIdx}`);
|
|
101
|
+
}
|
|
102
|
+
return { preset: nextPreset, overrides: {} };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Count of currently-active overrides, for the status line.
|
|
107
|
+
*/
|
|
108
|
+
export function overrideCount(state: FilterState): number {
|
|
109
|
+
return Object.keys(state.overrides).length;
|
|
110
|
+
}
|
package/src/index.tsx
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Entry point: mounts the App into Ink and lets slice A's subscription
|
|
4
|
+
* stream events into it. Don't add behaviour here — keep this file thin
|
|
5
|
+
* so the App is the testable surface.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { render } from "ink";
|
|
9
|
+
import { App } from "./App.tsx";
|
|
10
|
+
|
|
11
|
+
render(<App />);
|