@valyrianjs/terminal 0.2.1 → 0.2.2

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 (80) hide show
  1. package/dist/ansi.d.ts.map +1 -1
  2. package/dist/ansi.js +12 -14
  3. package/dist/ansi.js.map +1 -1
  4. package/dist/events.d.ts.map +1 -1
  5. package/dist/events.js +4 -0
  6. package/dist/events.js.map +1 -1
  7. package/dist/frame-style.d.ts +7 -0
  8. package/dist/frame-style.d.ts.map +1 -0
  9. package/dist/frame-style.js +27 -0
  10. package/dist/frame-style.js.map +1 -0
  11. package/dist/layout.d.ts +5 -1
  12. package/dist/layout.d.ts.map +1 -1
  13. package/dist/layout.js +53 -23
  14. package/dist/layout.js.map +1 -1
  15. package/dist/mouse.d.ts.map +1 -1
  16. package/dist/mouse.js +8 -1
  17. package/dist/mouse.js.map +1 -1
  18. package/dist/render.d.ts.map +1 -1
  19. package/dist/render.js +87 -48
  20. package/dist/render.js.map +1 -1
  21. package/dist/session.d.ts.map +1 -1
  22. package/dist/session.js +2 -0
  23. package/dist/session.js.map +1 -1
  24. package/dist/text.d.ts +7 -0
  25. package/dist/text.d.ts.map +1 -1
  26. package/dist/text.js +114 -0
  27. package/dist/text.js.map +1 -1
  28. package/dist/types.d.ts +3 -0
  29. package/dist/types.d.ts.map +1 -1
  30. package/docs/api-reference.md +6 -3
  31. package/docs/cookbook.md +1 -1
  32. package/docs/interaction-model.md +5 -5
  33. package/docs/primitive-gallery.md +4 -4
  34. package/examples/basic.tsx +22 -0
  35. package/examples/cli.tsx +55 -0
  36. package/examples/demo.tsx +98 -0
  37. package/examples/docs/background-fill.tsx +107 -0
  38. package/examples/docs/component-composition.tsx +140 -0
  39. package/examples/docs/cursor.tsx +121 -0
  40. package/examples/docs/employees-list.tsx +138 -0
  41. package/examples/docs/hello.tsx +98 -0
  42. package/examples/docs/interactive-note.tsx +111 -0
  43. package/examples/docs/module-api-dashboard.tsx +307 -0
  44. package/examples/docs/module-flux-store.tsx +181 -0
  45. package/examples/docs/module-form-workflow.tsx +339 -0
  46. package/examples/docs/module-forms.tsx +218 -0
  47. package/examples/docs/module-money.tsx +175 -0
  48. package/examples/docs/module-native-store.tsx +188 -0
  49. package/examples/docs/module-pulses.tsx +142 -0
  50. package/examples/docs/module-query.tsx +209 -0
  51. package/examples/docs/module-request.tsx +194 -0
  52. package/examples/docs/module-state-workbench.tsx +283 -0
  53. package/examples/docs/module-tasks.tsx +223 -0
  54. package/examples/docs/module-translate.tsx +194 -0
  55. package/examples/docs/module-utils.tsx +168 -0
  56. package/examples/docs/module-valyrian-core.tsx +159 -0
  57. package/examples/docs/pizza-builder.tsx +463 -0
  58. package/examples/docs/primitive-activity-console.tsx +113 -0
  59. package/examples/docs/primitive-command-panel.tsx +186 -0
  60. package/examples/docs/primitive-data-explorer.tsx +155 -0
  61. package/examples/docs/primitive-input-workbench.tsx +128 -0
  62. package/examples/docs/primitive-layout-shell.tsx +115 -0
  63. package/examples/docs/responsive-split.tsx +186 -0
  64. package/examples/docs/style-system.tsx +209 -0
  65. package/examples/docs/theme-colors.tsx +225 -0
  66. package/examples/docs/virtualized-list-workbench.tsx +232 -0
  67. package/examples/opencode-dogfood-app.tsx +215 -0
  68. package/examples/opencode-dogfood-lifecycle.tsx +194 -0
  69. package/examples/opencode-dogfood.tsx +11 -0
  70. package/llms-full.txt +16 -13
  71. package/package.json +3 -2
  72. package/src/ansi.ts +12 -14
  73. package/src/events.ts +2 -0
  74. package/src/frame-style.ts +36 -0
  75. package/src/layout.ts +57 -24
  76. package/src/mouse.ts +10 -1
  77. package/src/render.ts +92 -48
  78. package/src/session.ts +2 -0
  79. package/src/text.ts +148 -0
  80. package/src/types.ts +3 -0
@@ -0,0 +1,232 @@
1
+ import {
2
+ Fixed,
3
+ List,
4
+ Pane,
5
+ Row,
6
+ Screen,
7
+ Split,
8
+ Table,
9
+ Td,
10
+ Text,
11
+ mountTerminal
12
+ } from "@valyrianjs/terminal";
13
+ import type { TerminalMountOptions, TerminalSession } from "@valyrianjs/terminal";
14
+
15
+ type WorkItem = {
16
+ id: string;
17
+ title: string;
18
+ team: string;
19
+ status: string;
20
+ };
21
+
22
+ interface VirtualizedListWorkbenchState {
23
+ items: WorkItem[];
24
+ activeIndex: number;
25
+ selectedIndex: number;
26
+ viewportOffset: number;
27
+ viewportRows: number;
28
+ lastAction: string;
29
+ running: boolean;
30
+ }
31
+
32
+ export interface VirtualizedListWorkbenchDemo {
33
+ session: TerminalSession;
34
+ dispatchKey(key: string): string;
35
+ output(): string;
36
+ ansiOutput(): string;
37
+ isRunning(): boolean;
38
+ destroy(): void;
39
+ }
40
+
41
+ function shouldRunSnapshot() {
42
+ return process.argv.includes("--snapshot") || process.env.VALYRIAN_TERMINAL_EXAMPLE_SNAPSHOT === "1" || !process.stdin.isTTY;
43
+ }
44
+
45
+ function createItems(count = 2_000): WorkItem[] {
46
+ const teams = ["Ops", "Support", "Data", "Billing", "Release"];
47
+ const statuses = ["ready", "queued", "blocked", "review", "done"];
48
+
49
+ return Array.from({ length: count }, (_, index) => ({
50
+ id: `job-${String(index + 1).padStart(4, "0")}`,
51
+ title: index === 3
52
+ ? "Long wrapped item proving list text wrapping across multiple terminal rows without losing selection"
53
+ : `Batch export ${String(index + 1).padStart(4, "0")}`,
54
+ team: teams[index % teams.length],
55
+ status: statuses[index % statuses.length]
56
+ }));
57
+ }
58
+
59
+ function clampIndex(index: number, length: number) {
60
+ if (length <= 0) {
61
+ return 0;
62
+ }
63
+
64
+ return Math.max(0, Math.min(length - 1, index));
65
+ }
66
+
67
+ function createInitialState(): VirtualizedListWorkbenchState {
68
+ return {
69
+ items: createItems(),
70
+ activeIndex: 0,
71
+ selectedIndex: 0,
72
+ viewportOffset: 0,
73
+ viewportRows: 1,
74
+ lastAction: "opened workbench",
75
+ running: true
76
+ };
77
+ }
78
+
79
+ function selectedItem(state: VirtualizedListWorkbenchState) {
80
+ return state.items[clampIndex(state.selectedIndex, state.items.length)]!;
81
+ }
82
+
83
+ function activeItem(state: VirtualizedListWorkbenchState) {
84
+ return state.items[clampIndex(state.activeIndex, state.items.length)]!;
85
+ }
86
+
87
+ function observeListState(state: VirtualizedListWorkbenchState, event: { activeIndex: number; selectedIndex: number | null; viewportOffset: number; viewportRows: number }) {
88
+ state.activeIndex = event.activeIndex;
89
+ if (event.selectedIndex !== null) {
90
+ state.selectedIndex = event.selectedIndex;
91
+ }
92
+ state.viewportOffset = event.viewportOffset;
93
+ state.viewportRows = event.viewportRows;
94
+ }
95
+
96
+ export function App({ state }: { state: VirtualizedListWorkbenchState }) {
97
+ const selected = selectedItem(state);
98
+ const active = activeItem(state);
99
+
100
+ return (
101
+ <Screen title="Virtualized List Workbench">
102
+ <Fixed position="top" size={1}>
103
+ <Text>Virtualized List Workbench</Text>
104
+ </Fixed>
105
+ <Split gap={1} sizes={["44%", "1fr", "28%"]} breakpoints={[{ maxCols: 82, direction: "column", sizes: ["2fr", "1fr", "1fr"], gap: 1 }]}>
106
+ <Pane style={{ background: "#111827", padding: { x: 1, y: 0 } }}>
107
+ <Fixed position="top" size={1}>
108
+ <Text>2,000 jobs · virtualized viewport</Text>
109
+ </Fixed>
110
+ <List
111
+ id="virtualized-jobs"
112
+ virtualized
113
+ wrap
114
+ items={state.items}
115
+ itemKey={(item) => item.id}
116
+ onchange={(event) => {
117
+ observeListState(state, event);
118
+ state.lastAction = `active moved to ${event.key}`;
119
+ }}
120
+ onpress={(event) => {
121
+ observeListState(state, event);
122
+ state.lastAction = `selected ${event.key}`;
123
+ }}
124
+ ondoublepress={(event) => {
125
+ observeListState(state, event);
126
+ state.lastAction = `double pressed ${event.key}`;
127
+ }}
128
+ onviewportchange={(event) => {
129
+ observeListState(state, event);
130
+ state.lastAction = `viewport ${event.offset}-${event.offset + Math.max(0, event.rows - 1)}`;
131
+ }}
132
+ >
133
+ {(item, ctx) => `${ctx.active ? "active" : " "} ${ctx.selected ? "✓" : " "} ${ctx.key} ${item.title} · ${item.status}`}
134
+ </List>
135
+ </Pane>
136
+ <Pane style={{ background: "#1f2937", padding: { x: 1, y: 0 } }}>
137
+ <Text>Selection detail</Text>
138
+ <Table>
139
+ <Row><Td><Text>Selected</Text></Td><Td><Text>{selected.id}</Text></Td></Row>
140
+ <Row><Td><Text>Title</Text></Td><Td><Text>{selected.title}</Text></Td></Row>
141
+ <Row><Td><Text>Team</Text></Td><Td><Text>{selected.team}</Text></Td></Row>
142
+ <Row><Td><Text>Status</Text></Td><Td><Text>{selected.status}</Text></Td></Row>
143
+ </Table>
144
+ <Text>Active row: {active.id}</Text>
145
+ <Text>Active follows J/K, arrow keys, PageUp, PageDown, Home, and End. Enter selects.</Text>
146
+ <Text>Shift+Up/Down run custom app actions here. The List does not reorder by default.</Text>
147
+ </Pane>
148
+ <Pane style={{ background: "#0f172a", padding: { x: 1, y: 0 } }}>
149
+ <Text>Viewport state</Text>
150
+ <Text>Offset: {String(state.viewportOffset)}</Text>
151
+ <Text>Active index: {String(state.activeIndex)}</Text>
152
+ <Text>Selected index: {String(state.selectedIndex)}</Text>
153
+ <Text>Last action: {state.lastAction}</Text>
154
+ </Pane>
155
+ </Split>
156
+ <Fixed position="bottom" size={1}>
157
+ <Text>J/K or Up/Down: move active Shift+Up/Down: custom action PageUp/PageDown/Home/End: jump Enter/click: select Ctrl+C: quit</Text>
158
+ </Fixed>
159
+ </Screen>
160
+ );
161
+ }
162
+
163
+ export function createVirtualizedListWorkbenchDemo(options: TerminalMountOptions = {}): VirtualizedListWorkbenchDemo {
164
+ const state = createInitialState();
165
+ let session: TerminalSession;
166
+
167
+ function quit() {
168
+ state.running = false;
169
+ session.destroy();
170
+ }
171
+
172
+ session = mountTerminal(<App state={state} />, {
173
+ ...options,
174
+ keymap: {
175
+ ...options.keymap,
176
+ bindings: [
177
+ ...(options.keymap?.bindings || []),
178
+ { key: "j", command: { id: "list.next" }, scope: "list", when: { focusedTag: "terminal-list" } },
179
+ { key: "J", command: { id: "list.next" }, scope: "list", when: { focusedTag: "terminal-list" } },
180
+ { key: "k", command: { id: "list.prev" }, scope: "list", when: { focusedTag: "terminal-list" } },
181
+ { key: "K", command: { id: "list.prev" }, scope: "list", when: { focusedTag: "terminal-list" } },
182
+ { key: "SHIFT_UP", command: { id: "app.priorityUp" }, scope: "list", when: { focusedTag: "terminal-list" } },
183
+ { key: "SHIFT_DOWN", command: { id: "app.priorityDown" }, scope: "list", when: { focusedTag: "terminal-list" } },
184
+ { key: "CTRL_C", command: { id: "quit" }, scope: "global" }
185
+ ],
186
+ onCommand(command, context) {
187
+ if (command.id === "quit") {
188
+ quit();
189
+ return true;
190
+ }
191
+ if (command.id === "app.priorityUp" || command.id === "app.priorityDown") {
192
+ const label = command.id === "app.priorityUp" ? "priority up" : "priority down";
193
+ state.lastAction = `${label} for ${activeItem(state).id}`;
194
+ return true;
195
+ }
196
+ return options.keymap?.onCommand?.(command, context);
197
+ }
198
+ }
199
+ });
200
+ session.focus("virtualized-jobs");
201
+
202
+ return {
203
+ session,
204
+ dispatchKey(key: string) {
205
+ return session.dispatchKey(key);
206
+ },
207
+ output() {
208
+ return session.output();
209
+ },
210
+ ansiOutput() {
211
+ return session.ansiOutput();
212
+ },
213
+ isRunning() {
214
+ return state.running;
215
+ },
216
+ destroy() {
217
+ state.running = false;
218
+ session.destroy();
219
+ }
220
+ };
221
+ }
222
+
223
+ if (import.meta.main) {
224
+ if (shouldRunSnapshot()) {
225
+ const demo = createVirtualizedListWorkbenchDemo({ runtime: "headless", cols: 104, rows: 18 });
226
+ process.stdout.write(demo.output());
227
+ process.stdout.write("\n");
228
+ demo.destroy();
229
+ } else {
230
+ createVirtualizedListWorkbenchDemo();
231
+ }
232
+ }
@@ -0,0 +1,215 @@
1
+ import { Editor, Fixed, List, Overlay, Pane, Screen, ScrollView, Split, Text, mountTerminal } from "../dist/index.js";
2
+ import { createStreamLog } from "../dist/stream-log.js";
3
+
4
+ const TRANSCRIPT_VIEW_ROWS = 5;
5
+ const DOGFOOD_ACTIONS = [
6
+ { label: "Summarize conversation", status: "Summary ready", activity: "Summarized conversation" },
7
+ { label: "Prepare next step", status: "Next step ready", activity: "Prepared next step" },
8
+ { label: "Review updates", status: "Updates ready", activity: "Reviewed updates" }
9
+ ] as const;
10
+
11
+ function sanitizeBoxLine(value: string) {
12
+ let sanitized = "";
13
+ for (const char of value) {
14
+ if (char.charCodeAt(0) !== 27) {
15
+ sanitized += char;
16
+ }
17
+ }
18
+ return sanitized;
19
+ }
20
+
21
+ function physicalBoxLines(lines: string[]) {
22
+ const rows: string[] = [];
23
+ for (const line of lines) {
24
+ const parts = line.split(/\r\n|\r|\n/);
25
+ for (const part of parts) {
26
+ rows.push(sanitizeBoxLine(part));
27
+ }
28
+ }
29
+ return rows;
30
+ }
31
+
32
+ function renderLines(lines: string[]) {
33
+ return physicalBoxLines(lines).map((line, index) => <Text key={index}>{line}</Text>);
34
+ }
35
+
36
+ function tailLines(lines: string[], visibleRows: number) {
37
+ const physical = physicalBoxLines(lines);
38
+ return physical.slice(Math.max(0, physical.length - visibleRows));
39
+ }
40
+
41
+ export function createDogfoodDemoState() {
42
+ return {
43
+ prompt: "",
44
+ submitted: "",
45
+ assistant: "",
46
+ status: "Ready",
47
+ hasToolEvent: false,
48
+ commandPaletteOpen: false,
49
+ selectedActionIndex: 0,
50
+ commandPaletteOpenSerial: 0,
51
+ actionListId: "dogfood-action-list-0",
52
+ paletteActionSerial: 0,
53
+ streamLog: createStreamLog()
54
+ };
55
+ }
56
+
57
+ export type DogfoodDemoState = ReturnType<typeof createDogfoodDemoState>;
58
+
59
+ export function runDogfoodAction(state: DogfoodDemoState, actionIndex = state.selectedActionIndex) {
60
+ const action = DOGFOOD_ACTIONS[actionIndex] || DOGFOOD_ACTIONS[0];
61
+ const serial = state.paletteActionSerial;
62
+ state.paletteActionSerial += 1;
63
+ state.commandPaletteOpen = false;
64
+ state.status = action.status;
65
+ state.hasToolEvent = true;
66
+ state.streamLog.append({ id: `palette-action-${serial}`, type: "log", content: action.activity });
67
+ }
68
+
69
+ export function openDogfoodCommandPalette(state: DogfoodDemoState) {
70
+ state.commandPaletteOpen = true;
71
+ state.selectedActionIndex = 0;
72
+ state.commandPaletteOpenSerial += 1;
73
+ state.actionListId = `dogfood-action-list-${state.commandPaletteOpenSerial}`;
74
+ }
75
+
76
+ export function closeDogfoodCommandPalette(state: DogfoodDemoState) {
77
+ state.commandPaletteOpen = false;
78
+ }
79
+
80
+ export function DogfoodDemoApp(props: { state: DogfoodDemoState }) {
81
+ const { state } = props;
82
+ const snapshot = state.streamLog.snapshot();
83
+ const transcript: string[] = [];
84
+ const activity: string[] = [];
85
+ for (const entry of snapshot.entries) {
86
+ if (entry.id === "activity-review-context" || (entry.type === "log" && entry.id !== "user-prompt")) {
87
+ activity.push(entry.content);
88
+ continue;
89
+ }
90
+ if (entry.id === "user-prompt") {
91
+ transcript.push("You", entry.content);
92
+ continue;
93
+ }
94
+ if (entry.id === "assistant-response") {
95
+ transcript.push("Assistant", entry.content);
96
+ continue;
97
+ }
98
+ if (entry.type === "assistant") {
99
+ transcript.push(entry.content);
100
+ }
101
+ }
102
+ if (transcript.length === 0) {
103
+ if (state.submitted || state.assistant) {
104
+ transcript.push(
105
+ state.submitted ? "You" : "Ready when you are",
106
+ state.submitted || "",
107
+ state.assistant ? "Assistant" : "",
108
+ state.assistant || ""
109
+ );
110
+ } else {
111
+ transcript.push("Ready when you are");
112
+ }
113
+ }
114
+ const visibleTranscript = tailLines(transcript, TRANSCRIPT_VIEW_ROWS);
115
+ const visibleActivity = tailLines(activity, TRANSCRIPT_VIEW_ROWS);
116
+
117
+ function submit(value: string) {
118
+ state.submitted = value;
119
+ state.prompt = "";
120
+ state.streamLog = createStreamLog();
121
+ if (value) {
122
+ state.streamLog.append({ id: "user-prompt", type: "log", content: value });
123
+ state.streamLog.update("assistant-response", "Response complete");
124
+ state.streamLog.complete("assistant-response");
125
+ state.streamLog.append({ id: "activity-review-context", type: "log", content: "Review project context" });
126
+ }
127
+ state.assistant = value ? "Response complete" : "Write a message, then press Enter.";
128
+ state.status = value ? "Response complete" : "Ready";
129
+ state.hasToolEvent = Boolean(value);
130
+ }
131
+
132
+ function cancel() {
133
+ if (!state.submitted || state.status === "Interrupted") {
134
+ return;
135
+ }
136
+ state.status = "Interrupted";
137
+ }
138
+
139
+ return (
140
+ <Screen>
141
+ <Fixed position="top" size={6}>
142
+ <Pane fill style="pane.header" padding={1}>
143
+ <Text>Project Assistant</Text>
144
+ <Text>Status: {state.status}</Text>
145
+ </Pane>
146
+ </Fixed>
147
+ <Split direction="row" fill gap={0}>
148
+ <Pane fill style="pane.transcript" padding={1}>
149
+ <Text>Conversation</Text>
150
+ <ScrollView height={TRANSCRIPT_VIEW_ROWS}>{renderLines(visibleTranscript)}</ScrollView>
151
+ </Pane>
152
+ <Pane fill style="pane.tools" padding={1}>
153
+ <Text>Activity</Text>
154
+ <ScrollView height={TRANSCRIPT_VIEW_ROWS}>
155
+ {state.hasToolEvent ? renderLines(visibleActivity.length > 0 ? visibleActivity : ["Review project context"]) : <Text>No activity yet</Text>}
156
+ </ScrollView>
157
+ </Pane>
158
+ </Split>
159
+ <Fixed position="bottom" size={10}>
160
+ <Pane fill style="pane.prompt" padding={1}>
161
+ <Text>Message</Text>
162
+ <Editor
163
+ id="prompt"
164
+ value={state.prompt}
165
+ height={3}
166
+ focusable
167
+ onchange={(event) => (state.prompt = event.value)}
168
+ onsubmit={(event) => submit(event.value)}
169
+ oncancel={() => cancel()}
170
+ />
171
+ <Text>Enter sends · Esc cancels · Ctrl+C exits</Text>
172
+ <Text>Shift+Enter adds a new line when supported</Text>
173
+ <Text>On some terminals, Shift+Enter sends the prompt.</Text>
174
+ </Pane>
175
+ </Fixed>
176
+ {state.commandPaletteOpen ? (
177
+ <Overlay margin={{ x: "20%", y: "25%" }}>
178
+ <Pane fill style="pane.tools" padding={1}>
179
+ <Text>Actions</Text>
180
+ <List
181
+ id={state.actionListId}
182
+ focusable
183
+ items={DOGFOOD_ACTIONS}
184
+ renderItem={(action) => action.label}
185
+ onchange={(event) => (state.selectedActionIndex = event.index)}
186
+ onpress={(event) => runDogfoodAction(state, event.index)}
187
+ />
188
+ <Text>Enter to run · Esc to close</Text>
189
+ </Pane>
190
+ </Overlay>
191
+ ) : null}
192
+ </Screen>
193
+ );
194
+ }
195
+
196
+ if (import.meta.main) {
197
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
198
+ const session = mountTerminal(<DogfoodDemoApp state={createDogfoodDemoState()} />, {
199
+ runtime: "headless",
200
+ cols: process.stdout.columns ?? 90,
201
+ rows: process.stdout.rows ?? 24
202
+ });
203
+ process.stdout.write(`${session.output()}\n`);
204
+ session.destroy();
205
+ } else {
206
+ const { startDogfoodDemoSession } = await import("./opencode-dogfood-lifecycle.js");
207
+ startDogfoodDemoSession({
208
+ stdin: process.stdin,
209
+ stdout: process.stdout,
210
+ process,
211
+ mountTerminal,
212
+ exit: (code) => process.exit(code)
213
+ });
214
+ }
215
+ }
@@ -0,0 +1,194 @@
1
+ import type { TerminalCommand, TerminalSession } from "../dist/index.js";
2
+ import {
3
+ closeDogfoodCommandPalette,
4
+ DogfoodDemoApp,
5
+ createDogfoodDemoState,
6
+ openDogfoodCommandPalette
7
+ } from "./opencode-dogfood-app.js";
8
+
9
+ const ENHANCED_KEYBOARD_PROTOCOL_ENABLE = "\u001b[>5u";
10
+ const ENHANCED_KEYBOARD_PROTOCOL_DISABLE = "\u001b[<u";
11
+
12
+ type StdinLike = {
13
+ on?: (event: "data", listener: (chunk: string | Uint8Array) => void) => unknown;
14
+ prependListener?: (event: "data", listener: (chunk: string | Uint8Array) => void) => unknown;
15
+ off?: (event: "data", listener: (chunk: string | Uint8Array) => void) => unknown;
16
+ };
17
+
18
+ type StdoutLike = {
19
+ columns?: number;
20
+ rows?: number;
21
+ write: (chunk: string) => unknown;
22
+ };
23
+
24
+ type ProcessSignal = "SIGINT" | "SIGTERM" | "SIGWINCH" | "exit";
25
+
26
+ type ProcessLike = {
27
+ on?: (event: ProcessSignal, listener: () => void) => unknown;
28
+ once?: (event: ProcessSignal, listener: () => void) => unknown;
29
+ off?: (event: ProcessSignal, listener: () => void) => unknown;
30
+ removeListener?: (event: ProcessSignal, listener: () => void) => unknown;
31
+ };
32
+
33
+ function isCtrlC(chunk: string | Uint8Array) {
34
+ const value = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
35
+ return value.length === 1 && value.charCodeAt(0) === 3;
36
+ }
37
+
38
+ function isEnhancedKeyboardCtrlC(value: string) {
39
+ if (value.charCodeAt(0) !== 27 || value[1] !== "[" || !value.startsWith("99;", 2) || !value.endsWith("u")) {
40
+ return false;
41
+ }
42
+ const modifier = Number(value.slice(5, -1));
43
+ return Number.isInteger(modifier) && ((modifier - 1) & 4) === 4;
44
+ }
45
+
46
+ function isEnhancedKeyboardCtrlCPrefix(value: string) {
47
+ if (value.length === 0) {
48
+ return false;
49
+ }
50
+ if (
51
+ value.length <= 5 &&
52
+ (value.length < 1 || value.charCodeAt(0) === 27) &&
53
+ (value.length < 2 || value[1] === "[") &&
54
+ (value.length < 3 || value[2] === "9") &&
55
+ (value.length < 4 || value[3] === "9") &&
56
+ (value.length < 5 || value[4] === ";")
57
+ ) {
58
+ return true;
59
+ }
60
+ return value.charCodeAt(0) === 27 && value[1] === "[" && value.startsWith("99;", 2) && /^\d*$/.test(value.slice(5));
61
+ }
62
+
63
+ function enableEnhancedKeyboardProtocol(stdout: StdoutLike) {
64
+ // Request CSI-u/kitty-style enhanced keyboard reporting so Shift+Enter can
65
+ // arrive as ESC [ 13 ; 2 u instead of the same carriage return as Enter.
66
+ stdout.write(ENHANCED_KEYBOARD_PROTOCOL_ENABLE);
67
+ }
68
+
69
+ function disableEnhancedKeyboardProtocol(stdout: StdoutLike) {
70
+ // Reset enhanced keyboard reporting for the shell when this manual demo exits.
71
+ stdout.write(ENHANCED_KEYBOARD_PROTOCOL_DISABLE);
72
+ }
73
+
74
+ function hasValidTerminalDimension(value: number | undefined): value is number {
75
+ return Number.isInteger(value) && Number(value) >= 1;
76
+ }
77
+
78
+ export function startDogfoodDemoSession(deps: {
79
+ stdin: StdinLike;
80
+ stdout: StdoutLike;
81
+ process?: ProcessLike;
82
+ enhancedKeyboard?: boolean;
83
+ mountTerminal: (node: unknown, options: Record<string, unknown>) => TerminalSession;
84
+ exit: (code: number) => unknown;
85
+ }) {
86
+ const state = createDogfoodDemoState();
87
+ const enhancedKeyboard = deps.enhancedKeyboard !== false;
88
+
89
+ let session: TerminalSession;
90
+ try {
91
+ session = deps.mountTerminal(() => <DogfoodDemoApp state={state} />, {
92
+ alternateScreen: true,
93
+ hideCursor: true,
94
+ stdin: deps.stdin,
95
+ stdout: deps.stdout,
96
+ clipboard: false,
97
+ keymap: {
98
+ bindings: [
99
+ { key: "CTRL_K", command: { id: "palette.open" }, scope: "global" },
100
+ {
101
+ key: "ESCAPE",
102
+ command: { id: "palette.close" },
103
+ scope: "global",
104
+ when: { focusedTag: "terminal-list" }
105
+ }
106
+ ],
107
+ onCommand(command: TerminalCommand) {
108
+ if (command.id === "palette.open") {
109
+ openDogfoodCommandPalette(state);
110
+ session.update();
111
+ session.focus(state.actionListId);
112
+ return true;
113
+ }
114
+ if (command.id === "palette.close") {
115
+ closeDogfoodCommandPalette(state);
116
+ session.update();
117
+ session.focus("prompt");
118
+ return true;
119
+ }
120
+ return false;
121
+ }
122
+ }
123
+ });
124
+ session.focus("prompt");
125
+ if (enhancedKeyboard) {
126
+ enableEnhancedKeyboardProtocol(deps.stdout);
127
+ }
128
+ } catch (error) {
129
+ throw error;
130
+ }
131
+
132
+ let closed = false;
133
+ let pendingCtrlCChunk = "";
134
+ function removeProcessListener(event: ProcessSignal, listener: () => void) {
135
+ if (typeof deps.process?.off === "function") {
136
+ deps.process.off(event, listener);
137
+ return;
138
+ }
139
+ deps.process?.removeListener?.(event, listener);
140
+ }
141
+
142
+ function cleanup() {
143
+ if (closed) {
144
+ return;
145
+ }
146
+ closed = true;
147
+ deps.stdin.off?.("data", onGlobalData);
148
+ removeProcessListener("SIGINT", shutdown);
149
+ removeProcessListener("SIGTERM", shutdown);
150
+ removeProcessListener("SIGWINCH", resizeFromStdout);
151
+ removeProcessListener("exit", cleanup);
152
+ if (enhancedKeyboard) {
153
+ disableEnhancedKeyboardProtocol(deps.stdout);
154
+ }
155
+ session.destroy();
156
+ }
157
+
158
+ function shutdown() {
159
+ cleanup();
160
+ deps.exit(0);
161
+ }
162
+
163
+ function resizeFromStdout() {
164
+ const cols = deps.stdout.columns;
165
+ const rows = deps.stdout.rows;
166
+ if (!hasValidTerminalDimension(cols) || !hasValidTerminalDimension(rows)) {
167
+ return;
168
+ }
169
+ session.resize(cols, rows);
170
+ }
171
+
172
+ function onGlobalData(chunk: string | Uint8Array) {
173
+ const value = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
174
+ const candidate = pendingCtrlCChunk + value;
175
+ if (isCtrlC(chunk) || isEnhancedKeyboardCtrlC(candidate)) {
176
+ shutdown();
177
+ return;
178
+ }
179
+ pendingCtrlCChunk = isEnhancedKeyboardCtrlCPrefix(candidate) ? candidate : "";
180
+ }
181
+
182
+ if (typeof deps.stdin.prependListener === "function") {
183
+ deps.stdin.prependListener("data", onGlobalData);
184
+ } else {
185
+ deps.stdin.on?.("data", onGlobalData);
186
+ }
187
+
188
+ deps.process?.once?.("SIGINT", shutdown);
189
+ deps.process?.once?.("SIGTERM", shutdown);
190
+ deps.process?.on?.("SIGWINCH", resizeFromStdout);
191
+ deps.process?.once?.("exit", cleanup);
192
+
193
+ return { cleanup, session, state };
194
+ }
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env bun
2
+ import { mountTerminal } from "../dist/index.js";
3
+ import { startDogfoodDemoSession } from "./opencode-dogfood-lifecycle.js";
4
+
5
+ startDogfoodDemoSession({
6
+ stdin: process.stdin,
7
+ stdout: process.stdout,
8
+ process,
9
+ mountTerminal,
10
+ exit: (code) => process.exit(code)
11
+ });