@nghyane/arcane-tui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/keys.ts ADDED
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Keyboard input handling for terminal applications.
3
+ *
4
+ * Supports both legacy terminal sequences and Kitty keyboard protocol.
5
+ * See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/
6
+ * Reference: https://github.com/sst/opentui/blob/7da92b4088aebfe27b9f691c04163a48821e49fd/packages/core/src/lib/parse.keypress.ts
7
+ *
8
+ * Symbol keys are also supported, however some ctrl+symbol combos
9
+ * overlap with ASCII codes, e.g. ctrl+[ = ESC.
10
+ * See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/#legacy-ctrl-mapping-of-ascii-keys
11
+ * Those can still be * used for ctrl+shift combos
12
+ *
13
+ * API:
14
+ * - matchesKey(data, keyId) - Check if input matches a key identifier
15
+ * - parseKey(data) - Parse input and return the key identifier
16
+ * - Key - Helper object for creating typed key identifiers
17
+ * - setKittyProtocolActive(active) - Set global Kitty protocol state
18
+ * - isKittyProtocolActive() - Query global Kitty protocol state
19
+ */
20
+
21
+ import {
22
+ type KeyEventType,
23
+ matchesKey as matchesKeyNative,
24
+ parseKey as parseKeyNative,
25
+ parseKittySequence as parseKittySequenceNative,
26
+ } from "@nghyane/arcane-natives";
27
+
28
+ // =============================================================================
29
+ // Global Kitty Protocol State
30
+ // =============================================================================
31
+
32
+ let kittyProtocolActive = false;
33
+
34
+ /**
35
+ * Set the global Kitty keyboard protocol state.
36
+ * Called by ProcessTerminal after detecting protocol support.
37
+ */
38
+ export function setKittyProtocolActive(active: boolean): void {
39
+ kittyProtocolActive = active;
40
+ }
41
+
42
+ /**
43
+ * Query whether Kitty keyboard protocol is currently active.
44
+ */
45
+ export function isKittyProtocolActive(): boolean {
46
+ return kittyProtocolActive;
47
+ }
48
+
49
+ // =============================================================================
50
+ // Type-Safe Key Identifiers
51
+ // =============================================================================
52
+
53
+ type Letter =
54
+ | "a"
55
+ | "b"
56
+ | "c"
57
+ | "d"
58
+ | "e"
59
+ | "f"
60
+ | "g"
61
+ | "h"
62
+ | "i"
63
+ | "j"
64
+ | "k"
65
+ | "l"
66
+ | "m"
67
+ | "n"
68
+ | "o"
69
+ | "p"
70
+ | "q"
71
+ | "r"
72
+ | "s"
73
+ | "t"
74
+ | "u"
75
+ | "v"
76
+ | "w"
77
+ | "x"
78
+ | "y"
79
+ | "z";
80
+
81
+ type SymbolKey =
82
+ | "`"
83
+ | "-"
84
+ | "="
85
+ | "["
86
+ | "]"
87
+ | "\\"
88
+ | ";"
89
+ | "'"
90
+ | ","
91
+ | "."
92
+ | "/"
93
+ | "!"
94
+ | "@"
95
+ | "#"
96
+ | "$"
97
+ | "%"
98
+ | "^"
99
+ | "&"
100
+ | "*"
101
+ | "("
102
+ | ")"
103
+ | "_"
104
+ | "+"
105
+ | "|"
106
+ | "~"
107
+ | "{"
108
+ | "}"
109
+ | ":"
110
+ | "<"
111
+ | ">"
112
+ | "?";
113
+
114
+ type SpecialKey =
115
+ | "escape"
116
+ | "esc"
117
+ | "enter"
118
+ | "return"
119
+ | "tab"
120
+ | "space"
121
+ | "backspace"
122
+ | "delete"
123
+ | "insert"
124
+ | "clear"
125
+ | "home"
126
+ | "end"
127
+ | "pageUp"
128
+ | "pageDown"
129
+ | "up"
130
+ | "down"
131
+ | "left"
132
+ | "right"
133
+ | "f1"
134
+ | "f2"
135
+ | "f3"
136
+ | "f4"
137
+ | "f5"
138
+ | "f6"
139
+ | "f7"
140
+ | "f8"
141
+ | "f9"
142
+ | "f10"
143
+ | "f11"
144
+ | "f12";
145
+
146
+ type BaseKey = Letter | SymbolKey | SpecialKey;
147
+
148
+ /**
149
+ * Union type of all valid key identifiers.
150
+ * Provides autocomplete and catches typos at compile time.
151
+ */
152
+ export type KeyId =
153
+ | BaseKey
154
+ | `ctrl+${BaseKey}`
155
+ | `shift+${BaseKey}`
156
+ | `alt+${BaseKey}`
157
+ | `ctrl+shift+${BaseKey}`
158
+ | `shift+ctrl+${BaseKey}`
159
+ | `ctrl+alt+${BaseKey}`
160
+ | `alt+ctrl+${BaseKey}`
161
+ | `shift+alt+${BaseKey}`
162
+ | `alt+shift+${BaseKey}`
163
+ | `ctrl+shift+alt+${BaseKey}`
164
+ | `ctrl+alt+shift+${BaseKey}`
165
+ | `shift+ctrl+alt+${BaseKey}`
166
+ | `shift+alt+ctrl+${BaseKey}`
167
+ | `alt+ctrl+shift+${BaseKey}`
168
+ | `alt+shift+ctrl+${BaseKey}`;
169
+
170
+ // =============================================================================
171
+ // Kitty Protocol Parsing
172
+ // =============================================================================
173
+
174
+ interface ParsedKittySequence {
175
+ codepoint: number;
176
+ shiftedKey?: number; // Shifted version of the key (when shift is pressed)
177
+ baseLayoutKey?: number; // Key in standard PC-101 layout (for non-Latin layouts)
178
+ modifier: number;
179
+ eventType?: KeyEventType;
180
+ }
181
+
182
+ // Regex for Kitty protocol event type detection
183
+ // Matches CSI sequences with :2 (repeat) or :3 (release) event type
184
+ // Format: \x1b[...;modifier:event_type<terminator> where terminator is u, ~, or A-F/H
185
+ const KITTY_RELEASE_PATTERN = /^\x1b\[[\d:;]*:3[u~ABCDHF]$/;
186
+ const KITTY_REPEAT_PATTERN = /^\x1b\[[\d:;]*:2[u~ABCDHF]$/;
187
+
188
+ /**
189
+ * Check if the input is a key release event.
190
+ * Only meaningful when Kitty keyboard protocol with flag 2 is active.
191
+ * Returns false if Kitty protocol is not active.
192
+ */
193
+ export function isKeyRelease(data: string): boolean {
194
+ // Only detect release events when Kitty protocol is active
195
+ if (!kittyProtocolActive) {
196
+ return false;
197
+ }
198
+
199
+ // Don't treat bracketed paste content as key release
200
+ if (data.includes("\x1b[200~")) {
201
+ return false;
202
+ }
203
+
204
+ // Match the full CSI sequence pattern for release events
205
+ return KITTY_RELEASE_PATTERN.test(data);
206
+ }
207
+
208
+ /**
209
+ * Check if the input is a key repeat event.
210
+ * Only meaningful when Kitty keyboard protocol with flag 2 is active.
211
+ * Returns false if Kitty protocol is not active.
212
+ */
213
+ export function isKeyRepeat(data: string): boolean {
214
+ // Only detect repeat events when Kitty protocol is active
215
+ if (!kittyProtocolActive) {
216
+ return false;
217
+ }
218
+
219
+ // Don't treat bracketed paste content as key repeat
220
+ if (data.includes("\x1b[200~")) {
221
+ return false;
222
+ }
223
+
224
+ // Match the full CSI sequence pattern for repeat events
225
+ return KITTY_REPEAT_PATTERN.test(data);
226
+ }
227
+
228
+ export function parseKittySequence(data: string): ParsedKittySequence | null {
229
+ const result = parseKittySequenceNative(data);
230
+ if (!result) return null;
231
+ return {
232
+ codepoint: result.codepoint,
233
+ shiftedKey: result.shiftedKey ?? undefined,
234
+ baseLayoutKey: result.baseLayoutKey ?? undefined,
235
+ modifier: result.modifier,
236
+ eventType: result.eventType,
237
+ };
238
+ }
239
+
240
+ /**
241
+ * Match input data against a key identifier string.
242
+ *
243
+ * Supported key identifiers:
244
+ * - Single keys: "escape", "tab", "enter", "backspace", "delete", "home", "end", "space"
245
+ * - Arrow keys: "up", "down", "left", "right"
246
+ * - Ctrl combinations: "ctrl+c", "ctrl+z", etc.
247
+ * - Shift combinations: "shift+tab", "shift+enter"
248
+ * - Alt combinations: "alt+enter", "alt+backspace"
249
+ * - Combined modifiers: "shift+ctrl+p", "ctrl+alt+x"
250
+ *
251
+ * Use the Key helper for autocomplete: Key.ctrl("c"), Key.escape, Key.ctrlShift("p")
252
+ *
253
+ * @param data - Raw input data from terminal
254
+ * @param keyId - Key identifier (e.g., "ctrl+c", "escape", Key.ctrl("c"))
255
+ */
256
+ export function matchesKey(data: string, keyId: KeyId): boolean {
257
+ return matchesKeyNative(data, keyId, kittyProtocolActive);
258
+ }
259
+
260
+ /**
261
+ * Parse terminal input and return a normalized key identifier.
262
+ *
263
+ * Returns key names like "escape", "ctrl+c", "shift+tab", "alt+enter".
264
+ * Returns undefined if the input is not a recognized key sequence.
265
+ *
266
+ * @param data - Raw input data from terminal
267
+ */
268
+ export function parseKey(data: string): string | undefined {
269
+ return parseKeyNative(data, kittyProtocolActive) ?? undefined;
270
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Ring buffer for Emacs-style kill/yank operations.
3
+ *
4
+ * Tracks killed (deleted) text entries. Consecutive kills can accumulate
5
+ * into a single entry. Supports yank (paste most recent) and yank-pop
6
+ * (cycle through older entries).
7
+ */
8
+ export class KillRing {
9
+ #ring: string[] = [];
10
+
11
+ /**
12
+ * Add text to the kill ring.
13
+ *
14
+ * @param text - The killed text to add
15
+ * @param opts - Push options
16
+ * @param opts.prepend - If accumulating, prepend (backward deletion) or append (forward deletion)
17
+ * @param opts.accumulate - Merge with the most recent entry instead of creating a new one
18
+ */
19
+ push(text: string, opts: { prepend: boolean; accumulate?: boolean }): void {
20
+ if (!text) return;
21
+
22
+ if (opts.accumulate && this.#ring.length > 0) {
23
+ const last = this.#ring.pop()!;
24
+ this.#ring.push(opts.prepend ? text + last : last + text);
25
+ } else {
26
+ this.#ring.push(text);
27
+ }
28
+ }
29
+
30
+ /** Get most recent entry without modifying the ring. */
31
+ peek(): string | undefined {
32
+ return this.#ring.length > 0 ? this.#ring[this.#ring.length - 1] : undefined;
33
+ }
34
+
35
+ /** Move last entry to front (for yank-pop cycling). */
36
+ rotate(): void {
37
+ if (this.#ring.length > 1) {
38
+ const last = this.#ring.pop()!;
39
+ this.#ring.unshift(last);
40
+ }
41
+ }
42
+
43
+ get length(): number {
44
+ return this.#ring.length;
45
+ }
46
+ }
package/src/mermaid.ts ADDED
@@ -0,0 +1,140 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import { $ } from "bun";
5
+
6
+ export interface MermaidImage {
7
+ base64: string;
8
+ widthPx: number;
9
+ heightPx: number;
10
+ }
11
+
12
+ export interface MermaidRenderOptions {
13
+ theme?: "default" | "dark" | "forest" | "neutral";
14
+ backgroundColor?: string;
15
+ width?: number;
16
+ scale?: number;
17
+ }
18
+
19
+ /**
20
+ * Render mermaid diagram source to PNG.
21
+ *
22
+ * Uses `mmdc` (mermaid-cli) which must be installed and in PATH.
23
+ * Returns null if rendering fails or mmdc is unavailable.
24
+ */
25
+ export async function renderMermaidToPng(
26
+ source: string,
27
+ options: MermaidRenderOptions = {},
28
+ ): Promise<MermaidImage | null> {
29
+ const mmdc = Bun.which("mmdc");
30
+ if (!mmdc) {
31
+ return null;
32
+ }
33
+
34
+ const tmpDir = path.join(os.tmpdir(), `mermaid-${Date.now()}-${Math.random().toString(36).slice(2)}`);
35
+ const inputPath = path.join(tmpDir, "input.mmd");
36
+ const outputPath = path.join(tmpDir, "output.png");
37
+
38
+ try {
39
+ await Bun.write(inputPath, source);
40
+
41
+ const args: string[] = ["-i", inputPath, "-o", outputPath, "-q"];
42
+
43
+ if (options.theme) {
44
+ args.push("-t", options.theme);
45
+ }
46
+ if (options.backgroundColor) {
47
+ args.push("-b", options.backgroundColor);
48
+ }
49
+ if (options.width) {
50
+ args.push("-w", String(options.width));
51
+ }
52
+ if (options.scale) {
53
+ args.push("-s", String(options.scale));
54
+ }
55
+
56
+ const result = await $`${mmdc} ${args}`.quiet().nothrow();
57
+ if (result.exitCode !== 0) {
58
+ return null;
59
+ }
60
+
61
+ const outputFile = Bun.file(outputPath);
62
+ if (!(await outputFile.exists())) {
63
+ return null;
64
+ }
65
+
66
+ const buffer = await outputFile.bytes();
67
+ const base64 = buffer.toBase64();
68
+
69
+ const dims = parsePngDimensions(buffer);
70
+ if (!dims) {
71
+ return null;
72
+ }
73
+
74
+ return {
75
+ base64,
76
+ widthPx: dims.width,
77
+ heightPx: dims.height,
78
+ };
79
+ } catch {
80
+ return null;
81
+ } finally {
82
+ await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
83
+ }
84
+ }
85
+
86
+ function parsePngDimensions(buffer: Uint8Array): { width: number; height: number } | null {
87
+ if (buffer.length < 24) return null;
88
+ if (buffer[0] !== 0x89 || buffer[1] !== 0x50 || buffer[2] !== 0x4e || buffer[3] !== 0x47) {
89
+ return null;
90
+ }
91
+ const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength);
92
+ return {
93
+ width: view.getUint32(16, false),
94
+ height: view.getUint32(20, false),
95
+ };
96
+ }
97
+
98
+ /**
99
+ * Extract mermaid code blocks from markdown text.
100
+ * Returns array of { source, startIndex, endIndex } for each block.
101
+ */
102
+ export function extractMermaidBlocks(markdown: string): { source: string; hash: string }[] {
103
+ const blocks: { source: string; hash: string }[] = [];
104
+ const regex = /```mermaid\s*\n([\s\S]*?)```/g;
105
+
106
+ for (let match = regex.exec(markdown); match !== null; match = regex.exec(markdown)) {
107
+ const source = match[1].trim();
108
+ const hash = Bun.hash(source).toString(16);
109
+ blocks.push({ source, hash });
110
+ }
111
+
112
+ return blocks;
113
+ }
114
+
115
+ /**
116
+ * Pre-render all mermaid blocks in markdown text.
117
+ * Returns a cache map: hash → MermaidImage.
118
+ */
119
+ export async function prerenderMermaidBlocks(
120
+ markdown: string,
121
+ options: MermaidRenderOptions = {},
122
+ ): Promise<Map<string, MermaidImage>> {
123
+ const blocks = extractMermaidBlocks(markdown);
124
+ const cache = new Map<string, MermaidImage>();
125
+
126
+ const results = await Promise.all(
127
+ blocks.map(async ({ source, hash }) => {
128
+ const image = await renderMermaidToPng(source, options);
129
+ return { hash, image };
130
+ }),
131
+ );
132
+
133
+ for (const { hash, image } of results) {
134
+ if (image) {
135
+ cache.set(hash, image);
136
+ }
137
+ }
138
+
139
+ return cache;
140
+ }