@dex-ai/vue-tui 0.1.10

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.
@@ -0,0 +1,609 @@
1
+ import type { KeyEvent } from "./input";
2
+ import { ref, computed, type Ref, type ComputedRef } from "@vue/runtime-core";
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // TextBuffer — multi-line text editing state machine
6
+ // ---------------------------------------------------------------------------
7
+
8
+ /** Standard action names the text buffer understands. */
9
+ export type BufferAction =
10
+ | "insert"
11
+ | "backspace"
12
+ | "delete"
13
+ | "newline"
14
+ | "clear"
15
+ | "moveLeft"
16
+ | "moveRight"
17
+ | "moveUp"
18
+ | "moveDown"
19
+ | "moveHome"
20
+ | "moveEnd";
21
+
22
+ /**
23
+ * Result of a key/action being processed by the text buffer.
24
+ *
25
+ * - `"handled"` — key was consumed; cursor moved or buffer mutated.
26
+ * - `"boundary"` — key would have navigated past the start/end of the
27
+ * buffer (e.g. Up at row 0, Down at last row).
28
+ * Consumers (e.g. history navigation) can use this signal
29
+ * to take over.
30
+ * - `"unhandled"` — key was not recognized by the buffer.
31
+ */
32
+ export type BufferKeyResult = "handled" | "boundary" | "unhandled";
33
+
34
+ /**
35
+ * Convenience helper: treat `"handled"` or `"boundary"` as "key was consumed".
36
+ */
37
+ export function isKeyHandled(result: BufferKeyResult): boolean {
38
+ return result !== "unhandled";
39
+ }
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Paste buffer — unified storage for pasted text and images
43
+ // ---------------------------------------------------------------------------
44
+
45
+ /** A paste entry: either multi-line text or binary image data. */
46
+ export type PasteEntry =
47
+ | { type: "text"; content: string; lineCount: number }
48
+ | { type: "image"; data: Uint8Array; mediaType: string };
49
+
50
+ /** A slot in the paste buffer — tracks an individual paste with display index. */
51
+ export interface PasteSlot {
52
+ /** Unique ID used in sentinel markers. */
53
+ id: number;
54
+ /** Sequential display index (1-based, per-type). Text pastes: "Pasted N lines", images: "Image #N". */
55
+ index: number;
56
+ /** The paste payload. */
57
+ entry: PasteEntry;
58
+ }
59
+
60
+ /** @deprecated Backwards-compatible alias — use PasteSlot instead. */
61
+ export type PasteToken = PasteSlot;
62
+
63
+ /** @deprecated Backwards-compatible alias — use PasteSlot instead. */
64
+ export type ImageToken = PasteSlot;
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Sentinel system — inline markers in buffer text
68
+ // ---------------------------------------------------------------------------
69
+
70
+ /** Unified sentinel pattern: \x00SLOT:id\x00 */
71
+ const SENTINEL_RE = /\x00SLOT:(\d+)\x00/g;
72
+
73
+ /** Create a sentinel string for a slot ID. */
74
+ function slotSentinel(id: number): string {
75
+ return `\x00SLOT:${id}\x00`;
76
+ }
77
+
78
+ /** Info about a sentinel found at a position. */
79
+ interface SentinelInfo {
80
+ start: number;
81
+ end: number;
82
+ id: number;
83
+ }
84
+
85
+ /** Check if a character at a position in a string is inside any sentinel. */
86
+ function findSentinelAt(line: string, col: number): SentinelInfo | null {
87
+ SENTINEL_RE.lastIndex = 0;
88
+ let match: RegExpExecArray | null;
89
+ while ((match = SENTINEL_RE.exec(line)) !== null) {
90
+ const start = match.index;
91
+ const end = start + match[0].length;
92
+ if (col >= start && col < end) {
93
+ return { start, end, id: parseInt(match[1]!, 10) };
94
+ }
95
+ }
96
+ return null;
97
+ }
98
+
99
+ /** Find the sentinel immediately before a cursor position. */
100
+ function findSentinelBefore(line: string, col: number): SentinelInfo | null {
101
+ SENTINEL_RE.lastIndex = 0;
102
+ let match: RegExpExecArray | null;
103
+ while ((match = SENTINEL_RE.exec(line)) !== null) {
104
+ const start = match.index;
105
+ const end = start + match[0].length;
106
+ if (end === col) {
107
+ return { start, end, id: parseInt(match[1]!, 10) };
108
+ }
109
+ }
110
+ return null;
111
+ }
112
+
113
+ /** Find the sentinel immediately after a cursor position. */
114
+ function findSentinelAfter(line: string, col: number): SentinelInfo | null {
115
+ SENTINEL_RE.lastIndex = 0;
116
+ let match: RegExpExecArray | null;
117
+ while ((match = SENTINEL_RE.exec(line)) !== null) {
118
+ const start = match.index;
119
+ const end = start + match[0].length;
120
+ if (start === col) {
121
+ return { start, end, id: parseInt(match[1]!, 10) };
122
+ }
123
+ }
124
+ return null;
125
+ }
126
+
127
+ // ---------------------------------------------------------------------------
128
+ // TextBuffer interface
129
+ // ---------------------------------------------------------------------------
130
+
131
+ export interface TextBuffer {
132
+ /** The lines of text in the buffer. */
133
+ lines: Ref<string[]>;
134
+ /** Current cursor row (0-indexed). */
135
+ cursorRow: Ref<number>;
136
+ /** Current cursor column (0-indexed). */
137
+ cursorCol: Ref<number>;
138
+ /** The full buffer value as a single string (lines joined with \n). */
139
+ value: ComputedRef<string>;
140
+ /** The resolved text value with paste sentinels expanded (images stripped). */
141
+ resolvedValue: ComputedRef<string>;
142
+ /** The paste buffer — ordered list of all paste/image slots. */
143
+ pasteBuffer: Ref<PasteSlot[]>;
144
+
145
+ /** @deprecated Use pasteBuffer instead. Provides a Map view for backward compat. */
146
+ pasteTokens: Ref<Map<number, PasteSlot>>;
147
+ /** @deprecated Use pasteBuffer instead. Provides a Map view for backward compat. */
148
+ imageTokens: Ref<Map<number, PasteSlot>>;
149
+
150
+ // Mutations
151
+ insert(text: string): void;
152
+ /** Insert a text paste at cursor position. Returns the slot ID. */
153
+ insertPaste(text: string): number;
154
+ /** Insert an image paste at cursor position. Returns the slot ID. */
155
+ insertImage(data: Uint8Array, mediaType: string): number;
156
+ backspace(): void;
157
+ delete(): void;
158
+ newline(): void;
159
+ clear(): void;
160
+ /** Set the buffer to the given text, replacing all content. Cursor moves to end. */
161
+ setValue(text: string): void;
162
+
163
+ // Navigation
164
+ moveLeft(): void;
165
+ moveRight(): void;
166
+ moveUp(): void;
167
+ moveDown(): void;
168
+ moveHome(): void;
169
+ moveEnd(): void;
170
+
171
+ /**
172
+ * Handle a KeyEvent — maps keys to mutations/navigation.
173
+ *
174
+ * Returns:
175
+ * - `"handled"` if the key was consumed (cursor moved, text inserted, etc.)
176
+ * - `"boundary"` if the key was a navigation that cannot advance further
177
+ * (Up at row 0, Down at last row). Consumers can use this
178
+ * to trigger external behavior like history recall.
179
+ * - `"unhandled"` if the buffer does not handle this key (caller should).
180
+ */
181
+ handleKey(key: KeyEvent): BufferKeyResult;
182
+
183
+ /**
184
+ * Handle a named action — directly invoke a buffer operation.
185
+ * Returns `"handled"`, `"boundary"`, or `"unhandled"` (same semantics as handleKey).
186
+ */
187
+ handleAction(action: string, key?: KeyEvent): BufferKeyResult;
188
+ }
189
+
190
+ export interface TextBufferOptions {
191
+ /** Allow multi-line input. Default: true */
192
+ multiline?: boolean;
193
+ }
194
+
195
+ // ---------------------------------------------------------------------------
196
+ // Implementation
197
+ // ---------------------------------------------------------------------------
198
+
199
+ export function createTextBuffer(options?: TextBufferOptions): TextBuffer {
200
+ const multiline = options?.multiline ?? true;
201
+
202
+ const lines: Ref<string[]> = ref([""]);
203
+ const cursorRow: Ref<number> = ref(0);
204
+ const cursorCol: Ref<number> = ref(0);
205
+ const pasteBuffer: Ref<PasteSlot[]> = ref([]);
206
+ let nextSlotId = 1;
207
+ let nextIndex = 0;
208
+
209
+ const value: ComputedRef<string> = computed(() => lines.value.join("\n"));
210
+
211
+ /** Resolve paste sentinels → actual text content; image sentinels → empty string. */
212
+ const resolvedValue: ComputedRef<string> = computed(() => {
213
+ const raw = lines.value.join("\n");
214
+ const slots = pasteBuffer.value;
215
+ if (slots.length === 0) return raw;
216
+ const slotMap = new Map(slots.map((s) => [s.id, s]));
217
+ return raw.replace(SENTINEL_RE, (_, idStr: string) => {
218
+ const id = parseInt(idStr, 10);
219
+ const slot = slotMap.get(id);
220
+ if (!slot) return "";
221
+ if (slot.entry.type === "text") return slot.entry.content;
222
+ // Images are stripped from text — they become separate content blocks on submit
223
+ return "";
224
+ });
225
+ });
226
+
227
+ // Backwards-compatible Map views
228
+ const pasteTokens: Ref<Map<number, PasteSlot>> = computed(() => {
229
+ const map = new Map<number, PasteSlot>();
230
+ for (const slot of pasteBuffer.value) {
231
+ if (slot.entry.type === "text") map.set(slot.id, slot);
232
+ }
233
+ return map;
234
+ }) as unknown as Ref<Map<number, PasteSlot>>;
235
+
236
+ const imageTokens: Ref<Map<number, PasteSlot>> = computed(() => {
237
+ const map = new Map<number, PasteSlot>();
238
+ for (const slot of pasteBuffer.value) {
239
+ if (slot.entry.type === "image") map.set(slot.id, slot);
240
+ }
241
+ return map;
242
+ }) as unknown as Ref<Map<number, PasteSlot>>;
243
+
244
+ function currentLine(): string {
245
+ return lines.value[cursorRow.value] ?? "";
246
+ }
247
+
248
+ function setLine(row: number, content: string): void {
249
+ const updated = [...lines.value];
250
+ updated[row] = content;
251
+ lines.value = updated;
252
+ }
253
+
254
+ /** Remove a slot from the paste buffer. */
255
+ function removeSlot(id: number): void {
256
+ pasteBuffer.value = pasteBuffer.value.filter((s) => s.id !== id);
257
+ }
258
+
259
+ // ─── Mutations ──────────────────────────────────────────────
260
+
261
+ function insert(text: string): void {
262
+ // If cursor is inside a sentinel, remove it first
263
+ const line = currentLine();
264
+ const sentinel = findSentinelAt(line, cursorCol.value);
265
+ if (sentinel) {
266
+ removeSlot(sentinel.id);
267
+ setLine(
268
+ cursorRow.value,
269
+ line.slice(0, sentinel.start) + line.slice(sentinel.end),
270
+ );
271
+ cursorCol.value = sentinel.start;
272
+ }
273
+ const updatedLine = currentLine();
274
+ setLine(
275
+ cursorRow.value,
276
+ updatedLine.slice(0, cursorCol.value) +
277
+ text +
278
+ updatedLine.slice(cursorCol.value),
279
+ );
280
+ cursorCol.value += text.length;
281
+ }
282
+
283
+ function insertPaste(text: string): number {
284
+ const id = nextSlotId++;
285
+ const lineCount = text.split("\n").length;
286
+ nextIndex++;
287
+ const slot: PasteSlot = {
288
+ id,
289
+ index: nextIndex,
290
+ entry: { type: "text", content: text, lineCount },
291
+ };
292
+ pasteBuffer.value = [...pasteBuffer.value, slot];
293
+
294
+ const sentinel = slotSentinel(id);
295
+ const line = currentLine();
296
+ setLine(
297
+ cursorRow.value,
298
+ line.slice(0, cursorCol.value) + sentinel + line.slice(cursorCol.value),
299
+ );
300
+ cursorCol.value += sentinel.length;
301
+ return id;
302
+ }
303
+
304
+ function insertImage(data: Uint8Array, mediaType: string): number {
305
+ const id = nextSlotId++;
306
+ nextIndex++;
307
+ const slot: PasteSlot = {
308
+ id,
309
+ index: nextIndex,
310
+ entry: { type: "image", data, mediaType },
311
+ };
312
+ pasteBuffer.value = [...pasteBuffer.value, slot];
313
+
314
+ const sentinel = slotSentinel(id);
315
+ const line = currentLine();
316
+ setLine(
317
+ cursorRow.value,
318
+ line.slice(0, cursorCol.value) + sentinel + line.slice(cursorCol.value),
319
+ );
320
+ cursorCol.value += sentinel.length;
321
+ return id;
322
+ }
323
+
324
+ function backspace(): void {
325
+ if (cursorCol.value > 0) {
326
+ const line = currentLine();
327
+ // Check if we're backspacing into a sentinel
328
+ const sentinel = findSentinelBefore(line, cursorCol.value);
329
+ if (sentinel) {
330
+ // Remove entire sentinel + slot
331
+ removeSlot(sentinel.id);
332
+ setLine(
333
+ cursorRow.value,
334
+ line.slice(0, sentinel.start) + line.slice(sentinel.end),
335
+ );
336
+ cursorCol.value = sentinel.start;
337
+ } else {
338
+ // Check if cursor is inside a sentinel
339
+ const inside = findSentinelAt(line, cursorCol.value - 1);
340
+ if (inside) {
341
+ removeSlot(inside.id);
342
+ setLine(
343
+ cursorRow.value,
344
+ line.slice(0, inside.start) + line.slice(inside.end),
345
+ );
346
+ cursorCol.value = inside.start;
347
+ } else {
348
+ setLine(
349
+ cursorRow.value,
350
+ line.slice(0, cursorCol.value - 1) + line.slice(cursorCol.value),
351
+ );
352
+ cursorCol.value--;
353
+ }
354
+ }
355
+ } else if (cursorRow.value > 0) {
356
+ // Join with previous line
357
+ const updated = [...lines.value];
358
+ const prevLine = updated[cursorRow.value - 1] ?? "";
359
+ const currLine = updated[cursorRow.value] ?? "";
360
+ const newCol = prevLine.length;
361
+ updated[cursorRow.value - 1] = prevLine + currLine;
362
+ updated.splice(cursorRow.value, 1);
363
+ lines.value = updated;
364
+ cursorRow.value--;
365
+ cursorCol.value = newCol;
366
+ }
367
+ }
368
+
369
+ function del(): void {
370
+ const line = currentLine();
371
+ if (cursorCol.value < line.length) {
372
+ // Check if we're deleting into a sentinel
373
+ const sentinel = findSentinelAfter(line, cursorCol.value);
374
+ if (sentinel) {
375
+ // Remove entire sentinel + slot
376
+ removeSlot(sentinel.id);
377
+ setLine(
378
+ cursorRow.value,
379
+ line.slice(0, sentinel.start) + line.slice(sentinel.end),
380
+ );
381
+ } else {
382
+ // Check if cursor is inside a sentinel
383
+ const inside = findSentinelAt(line, cursorCol.value);
384
+ if (inside) {
385
+ removeSlot(inside.id);
386
+ setLine(
387
+ cursorRow.value,
388
+ line.slice(0, inside.start) + line.slice(inside.end),
389
+ );
390
+ cursorCol.value = inside.start;
391
+ } else {
392
+ setLine(
393
+ cursorRow.value,
394
+ line.slice(0, cursorCol.value) + line.slice(cursorCol.value + 1),
395
+ );
396
+ }
397
+ }
398
+ } else if (cursorRow.value < lines.value.length - 1) {
399
+ // Join with next line
400
+ const updated = [...lines.value];
401
+ const nextLine = updated[cursorRow.value + 1] ?? "";
402
+ updated[cursorRow.value] = line + nextLine;
403
+ updated.splice(cursorRow.value + 1, 1);
404
+ lines.value = updated;
405
+ }
406
+ }
407
+
408
+ function newline(): void {
409
+ if (!multiline) return;
410
+ const line = currentLine();
411
+ const before = line.slice(0, cursorCol.value);
412
+ const after = line.slice(cursorCol.value);
413
+ const updated = [...lines.value];
414
+ updated[cursorRow.value] = before;
415
+ updated.splice(cursorRow.value + 1, 0, after);
416
+ lines.value = updated;
417
+ cursorRow.value++;
418
+ cursorCol.value = 0;
419
+ }
420
+
421
+ function clear(): void {
422
+ lines.value = [""];
423
+ cursorRow.value = 0;
424
+ cursorCol.value = 0;
425
+ pasteBuffer.value = [];
426
+ nextIndex = 0;
427
+ }
428
+
429
+ function setValue(text: string): void {
430
+ const newLines = text.split("\n");
431
+ lines.value = newLines;
432
+ cursorRow.value = newLines.length - 1;
433
+ cursorCol.value = (newLines[newLines.length - 1] ?? "").length;
434
+ }
435
+
436
+ // ─── Navigation ─────────────────────────────────────────────
437
+
438
+ function moveLeft(): void {
439
+ if (cursorCol.value > 0) {
440
+ // Check if stepping left enters a sentinel — skip over the whole thing
441
+ const line = currentLine();
442
+ const sentinel = findSentinelAt(line, cursorCol.value - 1);
443
+ if (sentinel) {
444
+ cursorCol.value = sentinel.start;
445
+ } else {
446
+ cursorCol.value--;
447
+ }
448
+ } else if (cursorRow.value > 0) {
449
+ cursorRow.value--;
450
+ cursorCol.value = (lines.value[cursorRow.value] ?? "").length;
451
+ }
452
+ }
453
+
454
+ function moveRight(): void {
455
+ const lineLen = currentLine().length;
456
+ if (cursorCol.value < lineLen) {
457
+ // Check if stepping right enters a sentinel — skip over the whole thing
458
+ const line = currentLine();
459
+ const sentinel = findSentinelAfter(line, cursorCol.value);
460
+ if (sentinel) {
461
+ cursorCol.value = sentinel.end;
462
+ } else {
463
+ cursorCol.value++;
464
+ }
465
+ } else if (cursorRow.value < lines.value.length - 1) {
466
+ cursorRow.value++;
467
+ cursorCol.value = 0;
468
+ }
469
+ }
470
+
471
+ function moveUp(): void {
472
+ if (cursorRow.value > 0) {
473
+ cursorRow.value--;
474
+ const lineLen = (lines.value[cursorRow.value] ?? "").length;
475
+ cursorCol.value = Math.min(cursorCol.value, lineLen);
476
+ }
477
+ }
478
+
479
+ function moveDown(): void {
480
+ if (cursorRow.value < lines.value.length - 1) {
481
+ cursorRow.value++;
482
+ const lineLen = (lines.value[cursorRow.value] ?? "").length;
483
+ cursorCol.value = Math.min(cursorCol.value, lineLen);
484
+ }
485
+ }
486
+
487
+ function moveHome(): void {
488
+ cursorCol.value = 0;
489
+ }
490
+
491
+ function moveEnd(): void {
492
+ cursorCol.value = currentLine().length;
493
+ }
494
+
495
+ // ─── Key handler ────────────────────────────────────────────
496
+
497
+ function handleKey(key: KeyEvent): BufferKeyResult {
498
+ switch (key.name) {
499
+ case "left":
500
+ moveLeft();
501
+ return "handled";
502
+ case "right":
503
+ moveRight();
504
+ return "handled";
505
+ case "up":
506
+ if (cursorRow.value === 0) return "boundary";
507
+ moveUp();
508
+ return "handled";
509
+ case "down":
510
+ if (cursorRow.value === lines.value.length - 1) return "boundary";
511
+ moveDown();
512
+ return "handled";
513
+ case "home":
514
+ moveHome();
515
+ return "handled";
516
+ case "end":
517
+ moveEnd();
518
+ return "handled";
519
+ case "backspace":
520
+ backspace();
521
+ return "handled";
522
+ case "delete":
523
+ del();
524
+ return "handled";
525
+ case "enter":
526
+ return "unhandled"; // let app handle (submit, newline, etc.)
527
+ case "char":
528
+ if (key.char) {
529
+ insert(key.char);
530
+ return "handled";
531
+ }
532
+ return "unhandled";
533
+ default:
534
+ return "unhandled";
535
+ }
536
+ }
537
+
538
+ function handleAction(action: string, key?: KeyEvent): BufferKeyResult {
539
+ switch (action) {
540
+ case "insert":
541
+ if (key?.char) {
542
+ insert(key.char);
543
+ return "handled";
544
+ }
545
+ return "unhandled";
546
+ case "backspace":
547
+ backspace();
548
+ return "handled";
549
+ case "delete":
550
+ del();
551
+ return "handled";
552
+ case "newline":
553
+ newline();
554
+ return "handled";
555
+ case "clear":
556
+ clear();
557
+ return "handled";
558
+ case "moveLeft":
559
+ moveLeft();
560
+ return "handled";
561
+ case "moveRight":
562
+ moveRight();
563
+ return "handled";
564
+ case "moveUp":
565
+ if (cursorRow.value === 0) return "boundary";
566
+ moveUp();
567
+ return "handled";
568
+ case "moveDown":
569
+ if (cursorRow.value === lines.value.length - 1) return "boundary";
570
+ moveDown();
571
+ return "handled";
572
+ case "moveHome":
573
+ moveHome();
574
+ return "handled";
575
+ case "moveEnd":
576
+ moveEnd();
577
+ return "handled";
578
+ default:
579
+ return "unhandled";
580
+ }
581
+ }
582
+
583
+ return {
584
+ lines,
585
+ cursorRow,
586
+ cursorCol,
587
+ value,
588
+ resolvedValue,
589
+ pasteBuffer,
590
+ pasteTokens,
591
+ imageTokens,
592
+ insert,
593
+ insertPaste,
594
+ insertImage,
595
+ backspace,
596
+ delete: del,
597
+ newline,
598
+ clear,
599
+ setValue,
600
+ moveLeft,
601
+ moveRight,
602
+ moveUp,
603
+ moveDown,
604
+ moveHome,
605
+ moveEnd,
606
+ handleKey,
607
+ handleAction,
608
+ };
609
+ }
package/src/theme.ts ADDED
@@ -0,0 +1,44 @@
1
+ import type { Theme } from "./style";
2
+
3
+ export const defaultTheme: Theme = {
4
+ colors: {
5
+ accent: "\x1b[38;5;75m", // soft blue
6
+ success: "\x1b[38;5;78m", // soft green
7
+ warn: "\x1b[38;5;214m", // soft orange
8
+ error: "\x1b[31m", // red
9
+ muted: "\x1b[38;5;245m", // medium gray
10
+ subtle: "\x1b[38;5;242m", // dark gray
11
+ text: "\x1b[37m", // white
12
+
13
+ // Background colors
14
+ "bg-accent": "\x1b[48;5;75m",
15
+ "bg-success": "\x1b[48;5;78m",
16
+ "bg-warn": "\x1b[48;5;214m",
17
+ "bg-error": "\x1b[41m",
18
+ "bg-muted": "\x1b[48;5;245m",
19
+ "bg-subtle": "\x1b[48;5;242m",
20
+ "bg-text": "\x1b[47m",
21
+
22
+ // Diff colors
23
+ "diff-add": "\x1b[38;5;114m", // soft green foreground
24
+ "diff-del": "\x1b[38;5;210m", // soft red/pink foreground
25
+ "bg-diff-add": "\x1b[48;5;22m", // dark green background
26
+ "bg-diff-del": "\x1b[48;5;52m", // dark red background
27
+
28
+ // Special
29
+ reverse: "\x1b[7m",
30
+ },
31
+ symbols: {
32
+ divider: "─",
33
+ prompt: "❯",
34
+ bullet: "●",
35
+ spinner: ["◆", "◇"],
36
+ },
37
+ reset: "\x1b[0m",
38
+ bold: "\x1b[1m",
39
+ dim: "\x1b[2m",
40
+ italic: "\x1b[3m",
41
+ underline: "\x1b[4m",
42
+ strikethrough: "\x1b[9m",
43
+ reverse: "\x1b[7m",
44
+ };