@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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Thomas Butler
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,75 @@
1
+ # @fnclaude/renderer
2
+
3
+ Configurable TUI front-end for Claude Code. Bidirectional stream-json driver, repaint-on-toggle visibility filters (Alt+1-8), pretty markdown via [glow](https://github.com/charmbracelet/glow).
4
+
5
+ ## Status
6
+
7
+ Pre-v1. Active development. v0 ships the renderer in standalone wrapper mode — `fnclaude-renderer ...` spawns claude internally, renders the stream.
8
+
9
+ > Previously published as `fnclaude-renderer` on npm. Renamed to `@fnclaude/renderer` when imported into the [fnclaude monorepo](https://github.com/fnclaude/fnclaude).
10
+
11
+ ## Goals
12
+
13
+ - Replace the Claude Code CLI's fixed output with **verbosity presets** (quiet / normal / verbose / debug) and **per-element show/hide toggles**.
14
+ - **Repaint past content on toggle** — flip a filter and previously-hidden content appears in place; previously-shown content collapses to a header. The transcript is a live view of the event log under the current filter.
15
+ - Pipe assistant markdown through `glow` when available; raw fallback otherwise.
16
+ - One persistent `claude` subprocess per session, driven via `--input-format stream-json --output-format stream-json`. No `--resume`-per-turn latency.
17
+
18
+ ## Install
19
+
20
+ ### npm
21
+
22
+ ```sh
23
+ npm install -g @fnclaude/renderer
24
+ # then
25
+ fnclaude-renderer ...
26
+ ```
27
+
28
+ ### Arch Linux (AUR)
29
+
30
+ ```sh
31
+ yay -S fnclaude-renderer-bin
32
+ ```
33
+
34
+ ### Other platforms
35
+
36
+ Download the pre-built binary for your platform from the [latest release](https://github.com/fnclaude/fnclaude/releases/latest):
37
+
38
+ - Linux x86_64 / arm64
39
+ - macOS x86_64 (Intel) / arm64 (Apple Silicon)
40
+ - Windows x86_64 / arm64
41
+
42
+ Extract and place `fnclaude-renderer` on your `$PATH`.
43
+
44
+ ### Optional: glow
45
+
46
+ [glow](https://github.com/charmbracelet/glow) renders assistant markdown with syntax highlighting. If it's on your `$PATH` it gets used automatically; otherwise raw markdown is shown.
47
+
48
+ ## Keybinds
49
+
50
+ | Keybind | Element toggled |
51
+ |---|---|
52
+ | `Alt+1` | `thinking` blocks |
53
+ | `Alt+2` | `Bash.input` (commands) |
54
+ | `Alt+3` | `Bash.output` |
55
+ | `Alt+4` | `Edit.diff` |
56
+ | `Alt+5` | `Read.content` |
57
+ | `Alt+6` | `Write.content` |
58
+ | `Alt+7` | `Task.nested` (subagent prompts) |
59
+ | `Alt+8` | `errors` |
60
+ | `Alt+0` / `Alt+9` | cycle preset forward / backward |
61
+ | `Ctrl+L` | force repaint |
62
+ | `Ctrl+D` | clean exit |
63
+
64
+ See [docs/keybind-spec.md](docs/keybind-spec.md) for terminal-specific caveats (especially macOS Terminal.app's Option/Meta handling).
65
+
66
+ ## Architecture
67
+
68
+ - [docs/stream-json-findings.md](docs/stream-json-findings.md) — empirical notes on `claude`'s stream-json mode (what works, what's a footgun).
69
+ - [docs/event-spec.md](docs/event-spec.md) — the typed event contract between the process driver and the UI.
70
+ - [docs/filter-state-spec.md](docs/filter-state-spec.md) — verbosity presets, per-element override mechanics, repaint semantics.
71
+ - [docs/keybind-spec.md](docs/keybind-spec.md) — Alt+1-8 mapping, terminal caveats.
72
+
73
+ ## License
74
+
75
+ MIT.
package/package.json CHANGED
@@ -1,6 +1,50 @@
1
1
  {
2
2
  "name": "@fnclaude/renderer",
3
- "version": "0.0.1",
4
- "description": "Placeholder; real package coming.",
5
- "license": "MIT"
3
+ "version": "2.0.0",
4
+ "type": "module",
5
+ "description": "Configurable TUI front-end for Claude Code with stream-json driving, toggleable visibility filters, and glow-piped markdown.",
6
+ "license": "MIT",
7
+ "author": "fnrhombus",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/fnclaude/fnclaude.git",
11
+ "directory": "packages/renderer"
12
+ },
13
+ "bin": {
14
+ "fnclaude-renderer": "./src/index.tsx"
15
+ },
16
+ "files": [
17
+ "src",
18
+ "bin",
19
+ "README.md",
20
+ "LICENSE"
21
+ ],
22
+ "scripts": {
23
+ "dev": "bun run src/index.tsx",
24
+ "build": "bun build --compile --outfile=bin/fnclaude-renderer ./src/index.tsx",
25
+ "typecheck": "tsc --noEmit",
26
+ "check": "biome check .",
27
+ "fmt": "biome format --write .",
28
+ "lint": "biome lint .",
29
+ "test": "bun test"
30
+ },
31
+ "dependencies": {
32
+ "ink": "^5.0.0",
33
+ "react": "^18.3.1",
34
+ "react-devtools-core": "^7.0.1"
35
+ },
36
+ "devDependencies": {
37
+ "@biomejs/biome": "^1.9.0",
38
+ "@types/react": "^18.3.0",
39
+ "bun-types": "^1.3.14",
40
+ "ink-testing-library": "^4.0.0",
41
+ "typescript": "^5.5.0"
42
+ },
43
+ "engines": {
44
+ "bun": ">=1.1.0"
45
+ },
46
+ "publishConfig": {
47
+ "access": "public",
48
+ "provenance": true
49
+ }
6
50
  }
@@ -0,0 +1,130 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { render } from "ink-testing-library";
3
+ import { App } from "./App.tsx";
4
+ import { fixtureSession } from "./__fixtures__/events.ts";
5
+ import type { Key } from "./keybinds.ts";
6
+
7
+ const baseKey: Key = {
8
+ upArrow: false,
9
+ downArrow: false,
10
+ leftArrow: false,
11
+ rightArrow: false,
12
+ pageDown: false,
13
+ pageUp: false,
14
+ return: false,
15
+ escape: false,
16
+ ctrl: false,
17
+ shift: false,
18
+ tab: false,
19
+ backspace: false,
20
+ delete: false,
21
+ meta: false,
22
+ };
23
+
24
+ /**
25
+ * Small helper: tick a few times so async useEffect / fixture ingestion
26
+ * settle before asserting frame content. ink-testing-library doesn't
27
+ * expose a "wait" helper — yielding the microtask queue is sufficient
28
+ * because our fixture iterator only awaits Promise.resolve().
29
+ */
30
+ async function flush(): Promise<void> {
31
+ // Both microtask drain (for our awaited fixture iterator) and a real
32
+ // macrotask tick (for React's useEffect commit phase under
33
+ // ink-testing-library). Microtask-only flushing isn't sufficient — the
34
+ // testInputBus callback only fires after the effect commits.
35
+ for (let i = 0; i < 5; i++) await Promise.resolve();
36
+ await new Promise((resolve) => setTimeout(resolve, 10));
37
+ }
38
+
39
+ describe("<App />", () => {
40
+ test("renders initial status line with normal preset", async () => {
41
+ const instance = render(<App initialEvents={[]} />);
42
+ await flush();
43
+ const frame = instance.lastFrame() ?? "";
44
+ expect(frame).toContain("preset: normal");
45
+ instance.unmount();
46
+ });
47
+
48
+ test("ingests events and shows Bash command (input is `show` in normal)", async () => {
49
+ const instance = render(<App initialEvents={fixtureSession} />);
50
+ await flush();
51
+ const frame = instance.lastFrame() ?? "";
52
+ // Bash.input default in `normal` is `show` — slice C's BashInput
53
+ // renders the command with a "$ " prefix.
54
+ expect(frame).toContain("$ ls -la");
55
+ // Assistant text is always shown.
56
+ expect(frame).toContain("Listing files now.");
57
+ instance.unmount();
58
+ });
59
+
60
+ test("Bash.output is hidden under the `normal` preset by default", async () => {
61
+ const instance = render(<App initialEvents={fixtureSession} />);
62
+ await flush();
63
+ const frame = instance.lastFrame() ?? "";
64
+ // Bash.output default in `normal` is `hide` — BashOutput renders null.
65
+ expect(frame).not.toContain("total 0");
66
+ instance.unmount();
67
+ });
68
+
69
+ test("Alt+3 toggles Bash.output → repaints past content into view", async () => {
70
+ // Inject a fake-input handler so the test can drive useInput
71
+ // deterministically without depending on stdin TTY behaviour.
72
+ let dispatch: ((input: string, key: Key) => void) | null = null;
73
+ const instance = render(
74
+ <App
75
+ initialEvents={fixtureSession}
76
+ testInputBus={(handler) => {
77
+ dispatch = handler;
78
+ }}
79
+ />,
80
+ );
81
+ await flush();
82
+ // Sanity: hidden before toggle.
83
+ expect(instance.lastFrame() ?? "").not.toContain("total 0");
84
+ expect(dispatch).not.toBeNull();
85
+
86
+ // Simulate Alt+3 → toggle Bash.output (was `hide` in `normal`,
87
+ // override flips to `show`).
88
+ (dispatch as unknown as (i: string, k: Key) => void)("3", {
89
+ ...baseKey,
90
+ meta: true,
91
+ });
92
+ await flush();
93
+
94
+ const after = instance.lastFrame() ?? "";
95
+ // Repaint: past Bash output content now visible.
96
+ expect(after).toContain("total 0");
97
+ // Override count surfaces in the status line.
98
+ expect(after).toContain("1 override");
99
+ instance.unmount();
100
+ });
101
+
102
+ test("Alt+0 cycles preset forward and clears overrides", async () => {
103
+ let dispatch: ((input: string, key: Key) => void) | null = null;
104
+ const instance = render(
105
+ <App
106
+ initialEvents={fixtureSession}
107
+ testInputBus={(handler) => {
108
+ dispatch = handler;
109
+ }}
110
+ />,
111
+ );
112
+ await flush();
113
+ expect(dispatch).not.toBeNull();
114
+ const send = dispatch as unknown as (i: string, k: Key) => void;
115
+
116
+ // Set an override first via Alt+3.
117
+ send("3", { ...baseKey, meta: true });
118
+ await flush();
119
+ expect(instance.lastFrame() ?? "").toContain("1 override");
120
+
121
+ // Cycle: normal → verbose.
122
+ send("0", { ...baseKey, meta: true });
123
+ await flush();
124
+ const after = instance.lastFrame() ?? "";
125
+ expect(after).toContain("preset: verbose");
126
+ // Overrides cleared.
127
+ expect(after).not.toContain("1 override");
128
+ instance.unmount();
129
+ });
130
+ });
package/src/App.tsx ADDED
@@ -0,0 +1,321 @@
1
+ /**
2
+ * Top-level Ink component. Owns:
3
+ *
4
+ * - the in-memory event log (`ClaudeEvent[]`, append-only per session)
5
+ * - the filter state (`FilterState`)
6
+ * - input handling (Alt+1-8, Alt+0/9, Ctrl+L/D/C, typed text + Enter)
7
+ * - status surface (preset, override count, momentary toast)
8
+ *
9
+ * Filter applies at render time (see docs/filter-state-spec.md), so a
10
+ * toggle re-renders the whole transcript from the log through the new
11
+ * filter — Ink reconciles the screen diff.
12
+ *
13
+ * `subscribeToClaude` is imported from `./claude-process.ts` (slice A).
14
+ * The returned `ClaudeSubscription` exposes `.events` (the async iterable),
15
+ * `.sendUserTurn(text)`, and `.close()`.
16
+ * Per-element renderers are imported from `./renderers/` (slice C;
17
+ * stubbed locally until that slice merges).
18
+ */
19
+
20
+ import { Box, Text, useInput } from "ink";
21
+ import { useEffect, useMemo, useRef, useState } from "react";
22
+ import { type ClaudeSubscription, subscribeToClaude } from "./claude-process.ts";
23
+ import {
24
+ cyclePreset,
25
+ defaultState,
26
+ overrideCount,
27
+ resolve,
28
+ toggleElement,
29
+ } from "./filter-state.ts";
30
+ import { type Key, dispatchKey } from "./keybinds.ts";
31
+ import {
32
+ ErrorRenderer,
33
+ ResultRenderer,
34
+ TextRenderer,
35
+ ThinkingRenderer,
36
+ ToolResultRenderer,
37
+ ToolUseRenderer,
38
+ } from "./renderers/index.ts";
39
+ import type {
40
+ AssistantEvent,
41
+ ClaudeEvent,
42
+ ElementId,
43
+ FilterState,
44
+ ResultEvent,
45
+ UserEvent,
46
+ Visibility,
47
+ } from "./types/events.ts";
48
+
49
+ const TOAST_DURATION_MS = 2000;
50
+
51
+ export interface AppProps {
52
+ /**
53
+ * Seed the event log. Omit (or pass `undefined`) to subscribe live to
54
+ * claude via slice A's `subscribeToClaude`. Tests pass a fixed array
55
+ * so they don't need a running subprocess.
56
+ */
57
+ initialEvents?: ClaudeEvent[];
58
+ /**
59
+ * Test hook: receives the same handler `useInput` registers, so a test
60
+ * can drive input deterministically without a TTY. Production code
61
+ * never passes this — Ink wires real stdin.
62
+ */
63
+ testInputBus?: (handler: (input: string, key: Key) => void) => void;
64
+ }
65
+
66
+ interface ToolCallInfo {
67
+ name: string;
68
+ input: Record<string, unknown>;
69
+ }
70
+
71
+ function AssistantRender({
72
+ event,
73
+ visibilityFor,
74
+ }: {
75
+ event: AssistantEvent;
76
+ visibilityFor: (id: ElementId) => Visibility;
77
+ }): React.ReactElement {
78
+ return (
79
+ <Box flexDirection="column">
80
+ {event.message.content.map((block, idx) => {
81
+ const k = `${event.uuid}-${idx}`;
82
+ if (block.type === "text") {
83
+ return <TextRenderer key={k} text={block.text} />;
84
+ }
85
+ if (block.type === "thinking") {
86
+ return (
87
+ <ThinkingRenderer
88
+ key={k}
89
+ thinking={block.thinking}
90
+ visibility={visibilityFor("thinking")}
91
+ />
92
+ );
93
+ }
94
+ if (block.type === "tool_use") {
95
+ return <ToolUseRenderer key={k} block={block} visibilityFor={visibilityFor} />;
96
+ }
97
+ return null;
98
+ })}
99
+ </Box>
100
+ );
101
+ }
102
+
103
+ function UserRender({
104
+ event,
105
+ toolCallById,
106
+ visibilityFor,
107
+ }: {
108
+ event: UserEvent;
109
+ toolCallById: Map<string, ToolCallInfo>;
110
+ visibilityFor: (id: ElementId) => Visibility;
111
+ }): React.ReactElement {
112
+ const content = event.message.content;
113
+ if (typeof content === "string") {
114
+ return <Text>{content}</Text>;
115
+ }
116
+ return (
117
+ <Box flexDirection="column">
118
+ {content.map((block, idx) => {
119
+ const k = `${event.uuid ?? "user"}-${idx}`;
120
+ if (block.type === "text") {
121
+ return <Text key={k}>{block.text}</Text>;
122
+ }
123
+ if (block.type === "tool_result") {
124
+ const call = toolCallById.get(block.tool_use_id);
125
+ return (
126
+ <ToolResultRenderer
127
+ key={k}
128
+ block={block}
129
+ toolName={call?.name ?? ""}
130
+ {...(call?.input ? { toolInput: call.input } : {})}
131
+ visibilityFor={visibilityFor}
132
+ />
133
+ );
134
+ }
135
+ return null;
136
+ })}
137
+ </Box>
138
+ );
139
+ }
140
+
141
+ function ResultRender({
142
+ event,
143
+ }: {
144
+ event: ResultEvent;
145
+ }): React.ReactElement {
146
+ if (event.is_error) {
147
+ return <ErrorRenderer message={event.result} />;
148
+ }
149
+ return <ResultRenderer event={event} />;
150
+ }
151
+
152
+ export function App(props: AppProps): React.ReactElement {
153
+ const { initialEvents, testInputBus } = props;
154
+ const [events, setEvents] = useState<ClaudeEvent[]>(() => initialEvents ?? []);
155
+ const [filter, setFilter] = useState<FilterState>(defaultState);
156
+ const [draft, setDraft] = useState<string>("");
157
+ const [toast, setToast] = useState<string | null>(null);
158
+ const toastTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
159
+ const subRef = useRef<ClaudeSubscription | null>(null);
160
+
161
+ // Subscribe to slice A only when no initialEvents were provided — tests
162
+ // pass a static log; production starts empty and ingests live.
163
+ useEffect(() => {
164
+ if (initialEvents !== undefined) return;
165
+ let cancelled = false;
166
+ const sub = subscribeToClaude();
167
+ subRef.current = sub;
168
+ (async () => {
169
+ for await (const event of sub.events) {
170
+ if (cancelled) break;
171
+ setEvents((prev) => [...prev, event]);
172
+ }
173
+ })();
174
+ return () => {
175
+ cancelled = true;
176
+ sub.close().catch(() => undefined);
177
+ subRef.current = null;
178
+ };
179
+ }, [initialEvents]);
180
+
181
+ // Build a tool_use_id → { name, input } index for tool_result dispatch.
182
+ // Re-derived from the log on every render; cheap, log is in-memory.
183
+ // Slice C's ToolResultRenderer needs the original `input` for results
184
+ // that summarize against the call (e.g. Read.content's file_path).
185
+ const toolCallById = useMemo(() => {
186
+ const m = new Map<string, ToolCallInfo>();
187
+ for (const event of events) {
188
+ if (event.type === "assistant") {
189
+ for (const block of event.message.content) {
190
+ if (block.type === "tool_use") {
191
+ m.set(block.id, { name: block.name, input: block.input });
192
+ }
193
+ }
194
+ }
195
+ }
196
+ return m;
197
+ }, [events]);
198
+
199
+ const visibilityFor = useMemo(() => (id: ElementId) => resolve(id, filter), [filter]);
200
+
201
+ const flashToast = (msg: string) => {
202
+ setToast(msg);
203
+ if (toastTimer.current !== null) clearTimeout(toastTimer.current);
204
+ toastTimer.current = setTimeout(() => setToast(null), TOAST_DURATION_MS);
205
+ };
206
+
207
+ // Cleanup the toast timer on unmount.
208
+ useEffect(() => {
209
+ return () => {
210
+ if (toastTimer.current !== null) clearTimeout(toastTimer.current);
211
+ };
212
+ }, []);
213
+
214
+ const handleKey = (input: string, key: Key) => {
215
+ const action = dispatchKey(input, key);
216
+ if (action !== null) {
217
+ switch (action.kind) {
218
+ case "toggleElement":
219
+ setFilter((f) => toggleElement(f, action.element));
220
+ flashToast(`toggled ${action.element}`);
221
+ return;
222
+ case "cyclePreset":
223
+ setFilter((f) => cyclePreset(f, action.direction));
224
+ flashToast(action.direction === 1 ? "preset cycled forward" : "preset cycled backward");
225
+ return;
226
+ case "repaint":
227
+ // Force a no-op state update to trigger a fresh paint. The
228
+ // event log already lives in state, so React's reconciler
229
+ // walks it again on any state change.
230
+ setFilter((f) => ({ ...f }));
231
+ flashToast("repaint");
232
+ return;
233
+ case "closeStdin":
234
+ subRef.current?.close().catch(() => undefined);
235
+ flashToast("close stdin");
236
+ return;
237
+ case "interrupt":
238
+ flashToast("interrupt");
239
+ return;
240
+ }
241
+ return;
242
+ }
243
+ // Text input: typed chars go into the draft; Enter submits.
244
+ if (key.return) {
245
+ if (draft.length > 0) {
246
+ subRef.current?.sendUserTurn(draft);
247
+ setDraft("");
248
+ }
249
+ return;
250
+ }
251
+ if (key.backspace || key.delete) {
252
+ setDraft((d) => d.slice(0, -1));
253
+ return;
254
+ }
255
+ // Ignore control/meta-only inputs that didn't match a bind.
256
+ if (key.ctrl || key.meta) return;
257
+ if (input.length > 0) {
258
+ setDraft((d) => d + input);
259
+ }
260
+ };
261
+
262
+ // Production: register the handler with Ink. Tests: expose it via
263
+ // testInputBus instead so they don't need real stdin.
264
+ //
265
+ // `handleKey` closes over draft/filter state but reads them via
266
+ // setState callbacks where it matters — passing it through a ref
267
+ // avoids re-running the testInputBus effect on every render while
268
+ // still letting the test observe the latest closure.
269
+ const handleKeyRef = useRef(handleKey);
270
+ handleKeyRef.current = handleKey;
271
+ useInput((input, key) => handleKeyRef.current(input, key as unknown as Key), {
272
+ isActive: testInputBus === undefined,
273
+ });
274
+ useEffect(() => {
275
+ if (testInputBus !== undefined) {
276
+ testInputBus((input, key) => handleKeyRef.current(input, key));
277
+ }
278
+ }, [testInputBus]);
279
+
280
+ const statusLine = (() => {
281
+ const n = overrideCount(filter);
282
+ const parts = [`preset: ${filter.preset}`];
283
+ if (n > 0) parts.push(`${n} override${n === 1 ? "" : "s"}`);
284
+ if (toast !== null) parts.push(toast);
285
+ return parts.join(" | ");
286
+ })();
287
+
288
+ return (
289
+ <Box flexDirection="column">
290
+ <Box flexDirection="column">
291
+ {events.map((event) => {
292
+ const key =
293
+ "uuid" in event && event.uuid ? event.uuid : `${event.type}-${events.indexOf(event)}`;
294
+ if (event.type === "assistant") {
295
+ return <AssistantRender key={key} event={event} visibilityFor={visibilityFor} />;
296
+ }
297
+ if (event.type === "user") {
298
+ return (
299
+ <UserRender
300
+ key={key}
301
+ event={event}
302
+ toolCallById={toolCallById}
303
+ visibilityFor={visibilityFor}
304
+ />
305
+ );
306
+ }
307
+ if (event.type === "result") {
308
+ return <ResultRender key={key} event={event} />;
309
+ }
310
+ // system / rate_limit_event: header-only, always shown.
311
+ if (event.type === "system") {
312
+ return null;
313
+ }
314
+ return null;
315
+ })}
316
+ </Box>
317
+ {draft.length > 0 ? <Text>{`> ${draft}`}</Text> : null}
318
+ <Text>{statusLine}</Text>
319
+ </Box>
320
+ );
321
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Hand-rolled fixture events for App.test.tsx + the stub subscription.
3
+ *
4
+ * Keep these small and focused — they only need to be expressive enough to
5
+ * verify the renderer dispatch + filter repaint behaviour. Real captures
6
+ * live in slice A's recordings.
7
+ */
8
+
9
+ import type { ClaudeEvent } from "../types/events.ts";
10
+
11
+ export const fixtureSession: ClaudeEvent[] = [
12
+ {
13
+ type: "system",
14
+ subtype: "init",
15
+ session_id: "fixture-session",
16
+ uuid: "u-system-1",
17
+ cwd: "/tmp",
18
+ model: "claude-opus-4-7",
19
+ tools: ["Bash", "Read", "Edit", "Write", "Task"],
20
+ },
21
+ {
22
+ type: "assistant",
23
+ session_id: "fixture-session",
24
+ uuid: "u-asst-1",
25
+ message: {
26
+ role: "assistant",
27
+ model: "claude-opus-4-7",
28
+ content: [
29
+ { type: "text", text: "Listing files now." },
30
+ {
31
+ type: "tool_use",
32
+ id: "tool-1",
33
+ name: "Bash",
34
+ input: { command: "ls -la", description: "List files" },
35
+ },
36
+ ],
37
+ },
38
+ },
39
+ {
40
+ type: "user",
41
+ session_id: "fixture-session",
42
+ uuid: "u-user-1",
43
+ message: {
44
+ role: "user",
45
+ content: [
46
+ {
47
+ type: "tool_result",
48
+ tool_use_id: "tool-1",
49
+ content: "total 0\ndrwxr-xr-x 2 tom tom 60 May 19 04:59 .\n",
50
+ },
51
+ ],
52
+ },
53
+ },
54
+ {
55
+ type: "result",
56
+ subtype: "success",
57
+ is_error: false,
58
+ session_id: "fixture-session",
59
+ uuid: "u-result-1",
60
+ result: "Done.",
61
+ num_turns: 1,
62
+ duration_ms: 100,
63
+ duration_api_ms: 50,
64
+ total_cost_usd: 0.001,
65
+ },
66
+ ];