camouflage-tui 1.0.0-beta.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/README.md ADDED
@@ -0,0 +1,142 @@
1
+ # camouflage-tui
2
+
3
+ Node SDK for [Camouflage](https://github.com/sinameraji/camouflage) — a high-performance terminal renderer for AI agent applications.
4
+
5
+ If you're building an LLM-powered CLI tool and need streaming chat UI, tool execution display, permission modals, forms, and more — without fighting React Ink's re-render performance — this is for you.
6
+
7
+ ```bash
8
+ npm install camouflage-tui
9
+ ```
10
+
11
+ The `postinstall` script downloads a pre-built native binary for your platform (macOS, Linux). No Rust toolchain required.
12
+
13
+ ## Quick start
14
+
15
+ ```js
16
+ import { mount } from "camouflage-tui";
17
+
18
+ const cam = await mount();
19
+
20
+ cam.send("SessionStarted", {});
21
+ cam.send("UserMessageCreated", { text: "investigate failing test" });
22
+ cam.send("AssistantStreamStarted", { stream_id: "s1" });
23
+ cam.send("AssistantTokenDelta", { stream_id: "s1", token: "Looking " });
24
+ cam.send("AssistantTokenDelta", { stream_id: "s1", token: "into it..." });
25
+ cam.send("AssistantMessageCompleted", { stream_id: "s1" });
26
+
27
+ // Listen for user input typed into the renderer
28
+ cam.on("userInput", (text) => {
29
+ console.log("user said:", text);
30
+ });
31
+
32
+ // Listen for permission responses
33
+ cam.on("permissionResponse", ({ request_id, choice, feedback }) => {
34
+ console.log(`${request_id} → ${choice}`);
35
+ });
36
+
37
+ await cam.close();
38
+ ```
39
+
40
+ ## API
41
+
42
+ ### `mount(opts?) => Promise<CamouflageHandle>`
43
+
44
+ Spawns the renderer and resolves once the child has spawned. Rejects with
45
+ a friendly error if the binary can't be found.
46
+
47
+ ```ts
48
+ interface MountOptions {
49
+ bin?: string; // default: bundled binary, then PATH lookup
50
+ args?: string[]; // extra args after --stdin-events --emit-responses
51
+ env?: NodeJS.ProcessEnv; // merged with process.env
52
+ inheritStderr?: boolean; // default true; false → "stderr" event
53
+ renderToTerminal?: boolean; // true → stdout goes to terminal, responses on fd 3
54
+ }
55
+ ```
56
+
57
+ ### `CamouflageHandle`
58
+
59
+ Extends `EventEmitter`. Send events with `send()`; subscribe to outbound events with `on()`.
60
+
61
+ | Method | What |
62
+ |---------------------------------|-------------------------------------------------|
63
+ | `send(event_type, payload?)` | Write one event into the renderer. |
64
+ | `sendEvent({ event_type, payload? })` | Write a pre-built `Event`. |
65
+ | `close()` → `Promise<number>` | Graceful shutdown; resolves with exit code. |
66
+ | `kill(signal?)` | Forceful — only if `close()` is wedged. |
67
+
68
+ | Event | Payload |
69
+ |----------------------|---------------------------------------------------|
70
+ | `"userInput"` | `string` — user typed into the input box |
71
+ | `"permissionResponse"` | `{ request_id, choice, feedback? }` |
72
+ | `"selectListResponse"` | `{ id, value?, cancelled }` |
73
+ | `"confirmResponse"` | `{ id, value?, cancelled }` |
74
+ | `"formResponse"` | `{ id, values?, cancelled }` |
75
+ | `"wizardCompleted"` | `{ id, results }` |
76
+ | `"wizardCancelled"` | `{ id, at_step }` |
77
+ | `"cancelRequested"` | `{}` — user pressed Esc |
78
+ | `"event"` | any outbound `Event` (raw) |
79
+ | `"exit"` | `{ code, signal }` — renderer exited |
80
+
81
+ ### Convenience helpers
82
+
83
+ ```js
84
+ import { mount, selectList, confirm, toast, table, form, wizard } from "camouflage-tui";
85
+
86
+ const cam = await mount();
87
+
88
+ // Show a filterable list picker
89
+ const choice = await selectList(cam, {
90
+ id: "model",
91
+ prompt: "Choose a model",
92
+ options: [
93
+ { value: "gpt-4", label: "GPT-4" },
94
+ { value: "claude", label: "Claude" },
95
+ ],
96
+ });
97
+
98
+ // Show a yes/no confirmation
99
+ const ok = await confirm(cam, { id: "deploy", prompt: "Deploy to production?" });
100
+
101
+ // Show a brief notification
102
+ toast(cam, "Build succeeded");
103
+
104
+ // Show a multi-field form
105
+ const creds = await form(cam, {
106
+ id: "login",
107
+ title: "API credentials",
108
+ fields: [
109
+ { name: "key", label: "API Key", kind: "text" },
110
+ { name: "secret", label: "Secret", kind: "password" },
111
+ ],
112
+ });
113
+ ```
114
+
115
+ ### Types-only import
116
+
117
+ Don't need the runtime binding? Import just the protocol types and the
118
+ NDJSON parser helpers — zero subprocess overhead:
119
+
120
+ ```ts
121
+ import { Event, reader, validate, encode } from "camouflage-tui/types";
122
+ ```
123
+
124
+ ## Migration from Ink
125
+
126
+ If you're replacing a React/Ink TUI:
127
+
128
+ 1. **Mount Camouflage** instead of `render(<App/>)`.
129
+ 2. **Replace state setters** (`setEvents([...e, newEv])`, `setTurnPhase("thinking")`, etc.) with `cam.send(...)` calls.
130
+ 3. **Replace `<TextInput onSubmit>`** with `cam.on("userInput", ...)`.
131
+ 4. **Replace permission UI state** with `cam.on("permissionResponse", ...)`.
132
+ 5. **Delete `<App/>`, the Ink dependency, and any per-component layout code** — the renderer owns layout.
133
+
134
+ ## Versioning
135
+
136
+ Versions track the main Camouflage workspace. Within `SCHEMA_VERSION: 1`,
137
+ payload fields only grow — never renamed, never semantically changed — so a
138
+ newer renderer is always backward compatible with an older SDK.
139
+
140
+ ## License
141
+
142
+ Apache-2.0.
package/bin/.gitignore ADDED
@@ -0,0 +1,2 @@
1
+ *
2
+ !.gitignore
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "camouflage-tui",
3
+ "version": "1.0.0-beta.1",
4
+ "description": "High-performance terminal renderer for AI agent applications. A React Ink alternative built for streaming, persistence, and replay.",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "main": "./src/index.js",
8
+ "types": "./src/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./src/index.d.ts",
12
+ "import": "./src/index.js"
13
+ },
14
+ "./types": {
15
+ "types": "./src/types.d.ts",
16
+ "import": "./src/types.js"
17
+ }
18
+ },
19
+ "bin": {
20
+ "camouflage-tui": "./bin/camouflage-tui"
21
+ },
22
+ "files": [
23
+ "src/",
24
+ "bin/",
25
+ "scripts/",
26
+ "README.md"
27
+ ],
28
+ "scripts": {
29
+ "postinstall": "node scripts/install.js",
30
+ "test": "node --test src/*.test.js"
31
+ },
32
+ "engines": {
33
+ "node": ">=18"
34
+ },
35
+ "keywords": ["camouflage", "tui", "terminal", "agent", "ai", "rendering", "ink", "cli", "llm", "streaming"],
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "https://github.com/sinameraji/camouflage.git",
39
+ "directory": "sdk/node"
40
+ },
41
+ "homepage": "https://github.com/sinameraji/camouflage",
42
+ "bugs": "https://github.com/sinameraji/camouflage/issues"
43
+ }
@@ -0,0 +1,105 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * postinstall script — downloads the pre-built camouflage-tui binary
5
+ * for the current platform from GitHub Releases.
6
+ *
7
+ * Falls back gracefully: if the download fails (e.g. no internet, unsupported
8
+ * platform, CI without GitHub access), prints instructions to build from source.
9
+ */
10
+
11
+ import { createWriteStream, mkdirSync, chmodSync, existsSync, unlinkSync } from "node:fs";
12
+ import { execSync } from "node:child_process";
13
+ import { join, dirname } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+ import { get as httpsGet } from "node:https";
16
+
17
+ const __dirname = dirname(fileURLToPath(import.meta.url));
18
+ const BIN_DIR = join(__dirname, "..", "bin");
19
+ const BIN_PATH = join(BIN_DIR, "camouflage-tui");
20
+
21
+ const PLATFORM_MAP = {
22
+ "darwin-x64": "x86_64-apple-darwin",
23
+ "darwin-arm64": "aarch64-apple-darwin",
24
+ "linux-x64": "x86_64-unknown-linux-gnu",
25
+ "linux-arm64": "aarch64-unknown-linux-gnu",
26
+ };
27
+
28
+ const REPO = "sinameraji/camouflage";
29
+
30
+ async function main() {
31
+ // Skip if binary already exists (e.g. user built from source)
32
+ if (existsSync(BIN_PATH)) {
33
+ return;
34
+ }
35
+
36
+ const key = `${process.platform}-${process.arch}`;
37
+ const target = PLATFORM_MAP[key];
38
+ if (!target) {
39
+ warn(`Unsupported platform: ${key}`);
40
+ return;
41
+ }
42
+
43
+ // Read version from package.json
44
+ const { default: pkg } = await import("../package.json", { with: { type: "json" } });
45
+ const version = pkg.version;
46
+ const tag = `v${version}`;
47
+ const archive = `camouflage-tui-${target}.tar.gz`;
48
+ const url = `https://github.com/${REPO}/releases/download/${tag}/${archive}`;
49
+
50
+ mkdirSync(BIN_DIR, { recursive: true });
51
+
52
+ const tmpPath = `${BIN_PATH}.tmp`;
53
+
54
+ try {
55
+ process.stdout.write(`camouflage-tui: downloading ${target} binary...\n`);
56
+ await download(url, tmpPath);
57
+
58
+ // Extract the binary from the tarball
59
+ execSync(`tar xzf "${tmpPath}" -C "${BIN_DIR}"`, { stdio: "pipe" });
60
+ unlinkSync(tmpPath);
61
+
62
+ chmodSync(BIN_PATH, 0o755);
63
+ process.stdout.write(`camouflage-tui: installed to ${BIN_PATH}\n`);
64
+ } catch (err) {
65
+ // Clean up partial downloads
66
+ try { unlinkSync(tmpPath); } catch {}
67
+ try { unlinkSync(BIN_PATH); } catch {}
68
+ warn(`Download failed: ${err.message}`);
69
+ }
70
+ }
71
+
72
+ function download(url, dest, redirects = 0) {
73
+ if (redirects > 5) return Promise.reject(new Error("Too many redirects"));
74
+
75
+ return new Promise((resolve, reject) => {
76
+ httpsGet(url, { headers: { "User-Agent": "camouflage-tui-postinstall" } }, (res) => {
77
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
78
+ res.resume();
79
+ return resolve(download(res.headers.location, dest, redirects + 1));
80
+ }
81
+ if (res.statusCode !== 200) {
82
+ res.resume();
83
+ return reject(new Error(`HTTP ${res.statusCode} from ${url}`));
84
+ }
85
+ const file = createWriteStream(dest);
86
+ res.pipe(file);
87
+ file.on("finish", () => { file.close(); resolve(); });
88
+ file.on("error", reject);
89
+ res.on("error", reject);
90
+ }).on("error", reject);
91
+ });
92
+ }
93
+
94
+ function warn(msg) {
95
+ process.stdout.write(
96
+ `\ncamouflage-tui: ${msg}\n` +
97
+ `\nTo install manually, build from source:\n` +
98
+ ` cargo install --path crates/tui --root ~/.local\n` +
99
+ ` # or: cargo install --git https://github.com/${REPO} camouflage-tui\n\n`,
100
+ );
101
+ }
102
+
103
+ main().catch((err) => {
104
+ warn(err.message);
105
+ });
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Test harness only — stands in for `camouflage-tui` in binding tests.
4
+ *
5
+ * Behaviour: reads NDJSON lines from stdin and for each known event_type,
6
+ * echoes it back on stdout (simulating what camouflage-tui would emit as
7
+ * outbound traffic). This lets us validate that the Node binding's
8
+ * spawn → write → readline parse → typed-event pipeline works end-to-end
9
+ * without needing a real Rust binary or a TTY.
10
+ *
11
+ * The real binding does NOT echo inbound back — but for test purposes,
12
+ * any inbound event is "valid outbound" since both directions use the
13
+ * same NDJSON shape.
14
+ */
15
+ import { createInterface } from "node:readline";
16
+
17
+ const rl = createInterface({ input: process.stdin });
18
+ rl.on("line", (line) => {
19
+ const trimmed = line.trim();
20
+ if (!trimmed) return;
21
+ try {
22
+ JSON.parse(trimmed); // sanity check
23
+ process.stdout.write(trimmed + "\n");
24
+ } catch {
25
+ // skip malformed
26
+ }
27
+ });
28
+ rl.on("close", () => process.exit(0));
@@ -0,0 +1,237 @@
1
+ import { EventEmitter } from "node:events";
2
+ import type { Event } from "./types.js";
3
+
4
+ export interface MountOptions {
5
+ /** Executable name or path. Defaults to "camouflage-tui" (PATH lookup). */
6
+ bin?: string;
7
+ /** Extra args to pass to the renderer. `--stdin-events --emit-responses`
8
+ * are always appended by the binding. */
9
+ args?: string[];
10
+ /** Environment overrides for the child. Merged with process.env. */
11
+ env?: NodeJS.ProcessEnv;
12
+ /** If true (default), renderer stderr is forwarded to Node's stderr.
13
+ * If false, captured and exposed via the "stderr" event. */
14
+ inheritStderr?: boolean;
15
+ /** When true, skip the default `--stdin-events --emit-responses` args.
16
+ * Only used to point the binding at a non-Camouflage binary for tests
17
+ * or specialised harnesses. */
18
+ skipDefaultArgs?: boolean;
19
+ /** When true, the renderer's stdout and stderr go directly to the user's
20
+ * terminal (TUI rendering is visible to the user), and outbound
21
+ * NDJSON arrives on fd 3 via the new --responses-fd path. Use this when
22
+ * building a "host CLI that internally drives Camouflage" — e.g.
23
+ * KimiFlare's eventual Ink replacement. Default false: backward-
24
+ * compatible programmatic mode where both directions ride on the
25
+ * pipes the binding manages. */
26
+ renderToTerminal?: boolean;
27
+ }
28
+
29
+ export interface PermissionResponseEvent {
30
+ request_id: string;
31
+ choice: "allow_once" | "allow_session" | "deny";
32
+ feedback?: string;
33
+ }
34
+
35
+ export interface SelectListResponseEvent {
36
+ id: string;
37
+ value?: string;
38
+ cancelled: boolean;
39
+ }
40
+
41
+ export interface ConfirmResponseEvent {
42
+ id: string;
43
+ value?: boolean;
44
+ cancelled: boolean;
45
+ }
46
+
47
+ /**
48
+ * Convenience helper: emit a ShowConfirm and resolve to the user's
49
+ * ConfirmResponse for that id.
50
+ */
51
+ export function confirm(
52
+ cam: CamouflageHandle,
53
+ spec: {
54
+ id: string;
55
+ prompt: string;
56
+ yes_label?: string;
57
+ no_label?: string;
58
+ default?: "yes" | "no";
59
+ allow_cancel?: boolean;
60
+ },
61
+ ): Promise<ConfirmResponseEvent>;
62
+
63
+ /**
64
+ * Convenience helper: show a brief inline toast. Display-only — no
65
+ * response.
66
+ */
67
+ export function toast(
68
+ cam: CamouflageHandle,
69
+ spec: string | {
70
+ text: string;
71
+ kind?: "info" | "success" | "warn" | "error";
72
+ ttl_ms?: number;
73
+ },
74
+ ): void;
75
+
76
+ /** Convenience helper: show a tabular data view. Display-only. */
77
+ export function table(
78
+ cam: CamouflageHandle,
79
+ spec: {
80
+ id: string;
81
+ title?: string;
82
+ columns: {
83
+ name: string;
84
+ label?: string;
85
+ align?: "left" | "right" | "center";
86
+ }[];
87
+ rows: Record<string, unknown>[];
88
+ },
89
+ ): void;
90
+
91
+ /** Convenience helper: show a label/value list. Display-only. */
92
+ export function keyValueView(
93
+ cam: CamouflageHandle,
94
+ spec: {
95
+ id: string;
96
+ title?: string;
97
+ items: { label: string; value: string }[];
98
+ },
99
+ ): void;
100
+
101
+ export interface FormResponseEvent {
102
+ id: string;
103
+ values?: Record<string, string>;
104
+ cancelled: boolean;
105
+ }
106
+
107
+ /**
108
+ * Convenience helper: show a multi-field form and resolve to the user's
109
+ * FormResponse for that id.
110
+ */
111
+ export function form(
112
+ cam: CamouflageHandle,
113
+ spec: {
114
+ id: string;
115
+ title?: string;
116
+ fields: {
117
+ name: string;
118
+ label: string;
119
+ kind?: "text" | "password";
120
+ default?: string;
121
+ placeholder?: string;
122
+ required?: boolean;
123
+ }[];
124
+ allow_cancel?: boolean;
125
+ },
126
+ ): Promise<FormResponseEvent>;
127
+
128
+ export interface WizardCompletedEvent {
129
+ id: string;
130
+ results: Record<string, unknown>;
131
+ }
132
+
133
+ export interface WizardCancelledEvent {
134
+ id: string;
135
+ at_step: number;
136
+ }
137
+
138
+ export type WizardResolved =
139
+ | (WizardCompletedEvent & { cancelled?: undefined })
140
+ | (WizardCancelledEvent & { cancelled: true });
141
+
142
+ /**
143
+ * Convenience helper: show a multi-step wizard. Resolves to either
144
+ * `{ id, results }` (completion) or `{ id, cancelled: true, at_step }`
145
+ * (user cancelled).
146
+ */
147
+ export function wizard(
148
+ cam: CamouflageHandle,
149
+ spec: {
150
+ id: string;
151
+ title?: string;
152
+ steps: (
153
+ | { kind: "select"; id: string; prompt: string; options: { value: string; label: string; description?: string }[]; default?: string }
154
+ | { kind: "confirm"; id: string; prompt: string; yes_label?: string; no_label?: string }
155
+ | { kind: "form"; id: string; title?: string; fields: { name: string; label: string; kind?: "text" | "password"; default?: string; placeholder?: string; required?: boolean }[] }
156
+ )[];
157
+ allow_cancel?: boolean;
158
+ },
159
+ ): Promise<WizardResolved>;
160
+
161
+ /**
162
+ * Convenience helper: emit a `ShowSelectList` and return a Promise that
163
+ * resolves to the user's `SelectListResponseEvent` for that id. The host
164
+ * picks the id; the helper subscribes once, filters by id, and unsubscribes
165
+ * automatically.
166
+ *
167
+ * @example
168
+ * const choice = await selectList(cam, {
169
+ * id: "resume-picker",
170
+ * prompt: "Resume which session?",
171
+ * options: [{ value: "a", label: "Session A" }],
172
+ * });
173
+ * if (choice.cancelled) return;
174
+ * console.log("user picked:", choice.value);
175
+ */
176
+ export function selectList(
177
+ cam: CamouflageHandle,
178
+ spec: {
179
+ id: string;
180
+ prompt: string;
181
+ options: { value: string; label: string; description?: string }[];
182
+ default?: string;
183
+ allow_filter?: boolean;
184
+ allow_cancel?: boolean;
185
+ },
186
+ ): Promise<SelectListResponseEvent>;
187
+
188
+ export interface InvalidEvent {
189
+ line: string;
190
+ error: string;
191
+ }
192
+
193
+ export interface ExitEvent {
194
+ code: number | null;
195
+ signal: NodeJS.Signals | null;
196
+ }
197
+
198
+ export interface CamouflageHandle extends EventEmitter {
199
+ /** Send one event INTO the renderer. */
200
+ send(event_type: string, payload?: object): boolean;
201
+ /** Send a pre-built Event object. */
202
+ sendEvent(ev: { event_type: string; payload?: object }): boolean;
203
+ /** Gracefully close: end stdin, wait for child exit, resolve with code. */
204
+ close(): Promise<number>;
205
+ /** Force-kill the renderer. */
206
+ kill(signal?: NodeJS.Signals): void;
207
+
208
+ // Typed event subscriptions (EventEmitter overrides):
209
+ on(event: "userInput", listener: (text: string) => void): this;
210
+ on(event: "permissionResponse", listener: (resp: PermissionResponseEvent) => void): this;
211
+ on(event: "selectListResponse", listener: (resp: SelectListResponseEvent) => void): this;
212
+ on(event: "confirmResponse", listener: (resp: ConfirmResponseEvent) => void): this;
213
+ on(event: "formResponse", listener: (resp: FormResponseEvent) => void): this;
214
+ on(event: "wizardCompleted", listener: (resp: WizardCompletedEvent) => void): this;
215
+ on(event: "wizardCancelled", listener: (resp: WizardCancelledEvent) => void): this;
216
+ on(event: "modeChangeRequested", listener: (resp: { direction: "next" | "prev" }) => void): this;
217
+ on(event: "cancelRequested", listener: () => void): this;
218
+ on(event: "event", listener: (ev: Event) => void): this;
219
+ on(event: "invalid", listener: (info: InvalidEvent) => void): this;
220
+ on(event: "stderr", listener: (chunk: string) => void): this;
221
+ on(event: "exit", listener: (info: ExitEvent) => void): this;
222
+ on(event: string, listener: (...args: any[]) => void): this;
223
+ }
224
+
225
+ /**
226
+ * Spawn the Camouflage renderer and return a handle for emitting events
227
+ * and listening to renderer→host outbound events.
228
+ *
229
+ * @example
230
+ * import { mount } from "camouflage";
231
+ * const cam = await mount();
232
+ * cam.send("SessionStarted", {});
233
+ * cam.on("userInput", (text) => console.log("got input:", text));
234
+ * // ... when done:
235
+ * await cam.close();
236
+ */
237
+ export function mount(opts?: MountOptions): Promise<CamouflageHandle>;