@sayknow-cli/tui 0.3.16 → 0.4.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 (44) hide show
  1. package/README.md +18 -1
  2. package/package.json +6 -7
  3. package/src/autocomplete.ts +31 -1
  4. package/src/bracketed-paste.ts +106 -29
  5. package/src/components/editor.ts +81 -37
  6. package/src/components/input.ts +5 -1
  7. package/src/components/secret-input.ts +489 -0
  8. package/src/components/select-list.ts +138 -33
  9. package/src/index.ts +1 -0
  10. package/src/keybindings.ts +11 -3
  11. package/src/stdin-buffer.ts +153 -20
  12. package/src/terminal.ts +38 -2
  13. package/src/tui.ts +184 -18
  14. package/dist/types/animation-scheduler.d.ts +0 -13
  15. package/dist/types/autocomplete.d.ts +0 -83
  16. package/dist/types/bracketed-paste.d.ts +0 -26
  17. package/dist/types/components/box.d.ts +0 -20
  18. package/dist/types/components/cancellable-loader.d.ts +0 -21
  19. package/dist/types/components/editor.d.ts +0 -126
  20. package/dist/types/components/image.d.ts +0 -18
  21. package/dist/types/components/input.d.ts +0 -16
  22. package/dist/types/components/loader.d.ts +0 -23
  23. package/dist/types/components/markdown.d.ts +0 -87
  24. package/dist/types/components/sayknow-pet.d.ts +0 -128
  25. package/dist/types/components/select-list.d.ts +0 -46
  26. package/dist/types/components/settings-list.d.ts +0 -39
  27. package/dist/types/components/spacer.d.ts +0 -11
  28. package/dist/types/components/tab-bar.d.ts +0 -56
  29. package/dist/types/components/text.d.ts +0 -22
  30. package/dist/types/components/truncated-text.d.ts +0 -10
  31. package/dist/types/editor-component.d.ts +0 -36
  32. package/dist/types/fuzzy.d.ts +0 -15
  33. package/dist/types/index.d.ts +0 -28
  34. package/dist/types/keybindings.d.ts +0 -201
  35. package/dist/types/keys.d.ts +0 -208
  36. package/dist/types/kill-ring.d.ts +0 -27
  37. package/dist/types/metrics.d.ts +0 -85
  38. package/dist/types/stdin-buffer.d.ts +0 -50
  39. package/dist/types/symbols.d.ts +0 -23
  40. package/dist/types/terminal-capabilities.d.ts +0 -187
  41. package/dist/types/terminal.d.ts +0 -90
  42. package/dist/types/ttyid.d.ts +0 -9
  43. package/dist/types/tui.d.ts +0 -269
  44. package/dist/types/utils.d.ts +0 -110
@@ -0,0 +1,489 @@
1
+ import { BracketedPasteHandler } from "../bracketed-paste";
2
+ import { getKeybindings } from "../keybindings";
3
+ import { extractPrintableText } from "../keys";
4
+ import { type Component, CURSOR_MARKER, type Focusable } from "../tui";
5
+ import {
6
+ getSegmenter,
7
+ getWordNavKind,
8
+ moveWordLeft,
9
+ moveWordRight,
10
+ padding,
11
+ replaceTabs,
12
+ sliceWithWidth,
13
+ visibleWidth,
14
+ } from "../utils";
15
+
16
+ const segmenter = getSegmenter();
17
+ const secretValueIssuer = Symbol("SecretValue issuer");
18
+
19
+ interface SecretInputState {
20
+ value: string;
21
+ cursor: number;
22
+ }
23
+
24
+ function insertTextNfcAt(value: string, cursor: number, text: string): { value: string; cursor: number } {
25
+ const before = value.slice(0, cursor);
26
+ const after = value.slice(cursor);
27
+ const beforeWithInsert = (before + text).normalize("NFC");
28
+ return {
29
+ value: (beforeWithInsert + after).normalize("NFC"),
30
+ cursor: beforeWithInsert.length,
31
+ };
32
+ }
33
+
34
+ /**
35
+ * A one-shot secret transfer handle. The contained value is cleared immediately
36
+ * after it is consumed and cannot be read through any other public API.
37
+ */
38
+ export class SecretValue {
39
+ #value: string;
40
+ #consumed = false;
41
+
42
+ /** @internal SecretInput is the sole issuer of usable SecretValue handles. */
43
+ constructor(value: string, issuer: typeof secretValueIssuer) {
44
+ if (issuer !== secretValueIssuer) {
45
+ throw new TypeError("SecretValue handles can only be created by SecretInput");
46
+ }
47
+ this.#value = value;
48
+ }
49
+
50
+ consume(): string {
51
+ if (this.#consumed) {
52
+ return "";
53
+ }
54
+
55
+ this.#consumed = true;
56
+ const value = this.#value;
57
+ this.#value = "";
58
+ return value;
59
+ }
60
+ }
61
+
62
+ /**
63
+ * A single-line masked input for credentials and other write-only secrets.
64
+ *
65
+ * The editing behavior follows Input, but render output is derived only from
66
+ * grapheme counts; the backing characters are never returned or rendered.
67
+ */
68
+ export class SecretInput implements Component, Focusable {
69
+ #value = "";
70
+ #cursor = 0;
71
+ #pasteHandler = new BracketedPasteHandler();
72
+ #killRing: string[] = [];
73
+ #lastAction: "kill" | "yank" | "type-word" | null = null;
74
+ #undoStack: SecretInputState[] = [];
75
+ #disposed = false;
76
+
77
+ readonly placeholder: string;
78
+ onSubmit?: (value: SecretValue) => void;
79
+ onEscape?: () => void;
80
+
81
+ /** Focusable interface - set by TUI when focus changes. */
82
+ focused = false;
83
+
84
+ constructor(options: { placeholder?: string } = {}) {
85
+ this.placeholder = options.placeholder ?? "";
86
+ }
87
+
88
+ handleInput(data: string): void {
89
+ if (this.#disposed) {
90
+ return;
91
+ }
92
+
93
+ const paste =
94
+ data === "\x1b" && !this.#pasteHandler.hasPendingFrame
95
+ ? ({ handled: false } as const)
96
+ : this.#pasteHandler.process(data);
97
+ if (paste.handled) {
98
+ if (paste.leading.length > 0) this.handleInput(paste.leading);
99
+ if (paste.pasteContent !== undefined) {
100
+ this.#handlePaste(paste.pasteContent);
101
+ if (paste.remaining.length > 0) {
102
+ this.handleInput(paste.remaining);
103
+ }
104
+ }
105
+ return;
106
+ }
107
+
108
+ const kb = getKeybindings();
109
+
110
+ if (kb.matches(data, "tui.select.cancel")) {
111
+ this.clear();
112
+ this.onEscape?.();
113
+ return;
114
+ }
115
+
116
+ if (kb.matches(data, "tui.editor.undo")) {
117
+ this.#undo();
118
+ return;
119
+ }
120
+
121
+ if (kb.matches(data, "tui.input.submit") || data === "\n") {
122
+ this.#submit();
123
+ return;
124
+ }
125
+
126
+ if (kb.matches(data, "tui.editor.deleteCharBackward")) {
127
+ this.#handleBackspace();
128
+ return;
129
+ }
130
+
131
+ if (kb.matches(data, "tui.editor.deleteCharForward")) {
132
+ this.#handleForwardDelete();
133
+ return;
134
+ }
135
+
136
+ if (kb.matches(data, "tui.editor.deleteWordBackward")) {
137
+ this.#deleteWordBackwards();
138
+ return;
139
+ }
140
+
141
+ if (kb.matches(data, "tui.editor.deleteWordForward")) {
142
+ this.#deleteWordForward();
143
+ return;
144
+ }
145
+
146
+ if (kb.matches(data, "tui.editor.deleteToLineStart")) {
147
+ this.#deleteToLineStart();
148
+ return;
149
+ }
150
+
151
+ if (kb.matches(data, "tui.editor.deleteToLineEnd")) {
152
+ this.#deleteToLineEnd();
153
+ return;
154
+ }
155
+
156
+ if (kb.matches(data, "tui.editor.yank")) {
157
+ this.#yank();
158
+ return;
159
+ }
160
+
161
+ if (kb.matches(data, "tui.editor.yankPop")) {
162
+ this.#yankPop();
163
+ return;
164
+ }
165
+
166
+ if (kb.matches(data, "tui.editor.cursorLeft")) {
167
+ this.#lastAction = null;
168
+ if (this.#cursor > 0) {
169
+ const lastGrapheme = [...segmenter.segment(this.#value.slice(0, this.#cursor))].at(-1);
170
+ this.#cursor -= lastGrapheme?.segment.length ?? 1;
171
+ }
172
+ return;
173
+ }
174
+
175
+ if (kb.matches(data, "tui.editor.cursorRight")) {
176
+ this.#lastAction = null;
177
+ if (this.#cursor < this.#value.length) {
178
+ const [firstGrapheme] = segmenter.segment(this.#value.slice(this.#cursor));
179
+ this.#cursor += firstGrapheme?.segment.length ?? 1;
180
+ }
181
+ return;
182
+ }
183
+
184
+ if (kb.matches(data, "tui.editor.cursorLineStart")) {
185
+ this.#lastAction = null;
186
+ this.#cursor = 0;
187
+ return;
188
+ }
189
+
190
+ if (kb.matches(data, "tui.editor.cursorLineEnd")) {
191
+ this.#lastAction = null;
192
+ this.#cursor = this.#value.length;
193
+ return;
194
+ }
195
+
196
+ if (kb.matches(data, "tui.editor.cursorWordLeft")) {
197
+ this.#moveWordBackwards();
198
+ return;
199
+ }
200
+
201
+ if (kb.matches(data, "tui.editor.cursorWordRight")) {
202
+ this.#moveWordForwards();
203
+ return;
204
+ }
205
+
206
+ const printableText = extractPrintableText(data);
207
+ if (printableText) {
208
+ this.#insertCharacter(printableText);
209
+ }
210
+ }
211
+
212
+ clear(): void {
213
+ this.#value = "";
214
+ this.#cursor = 0;
215
+ this.#lastAction = null;
216
+ for (const snapshot of this.#undoStack) {
217
+ snapshot.value = "";
218
+ snapshot.cursor = 0;
219
+ }
220
+ this.#undoStack.length = 0;
221
+ this.#killRing.fill("");
222
+ this.#killRing.length = 0;
223
+ // BracketedPasteHandler intentionally keeps its buffer private. Replacing it
224
+ // drops any in-progress secret paste without retaining it in this component.
225
+ this.#pasteHandler = new BracketedPasteHandler();
226
+ }
227
+
228
+ dispose(): void {
229
+ if (this.#disposed) {
230
+ return;
231
+ }
232
+
233
+ this.clear();
234
+ this.focused = false;
235
+ this.onSubmit = undefined;
236
+ this.onEscape = undefined;
237
+ this.#disposed = true;
238
+ }
239
+
240
+ invalidate(): void {
241
+ // No cached state to invalidate currently.
242
+ }
243
+
244
+ render(width: number): string[] {
245
+ if (this.#disposed) {
246
+ return [];
247
+ }
248
+
249
+ const prompt = "> ";
250
+ const availableWidth = width - prompt.length;
251
+ if (availableWidth <= 0) {
252
+ return [prompt];
253
+ }
254
+
255
+ const masked = this.#maskedValueAndCursor();
256
+ const displayValue = masked.value.length === 0 && this.placeholder.length > 0 ? this.placeholder : masked.value;
257
+ const cursorIndex = masked.value.length === 0 && this.placeholder.length > 0 ? 0 : masked.cursor;
258
+ const cursorDisplayValue = cursorIndex >= displayValue.length ? `${displayValue} ` : displayValue;
259
+ const totalCols = visibleWidth(cursorDisplayValue);
260
+ const cursorCols = visibleWidth(cursorDisplayValue.slice(0, cursorIndex));
261
+ const cursorIterator = segmenter.segment(cursorDisplayValue.slice(cursorIndex))[Symbol.iterator]();
262
+ const cursorGrapheme = cursorIterator.next().value?.segment ?? " ";
263
+ const cursorGraphemeWidth = visibleWidth(cursorGrapheme);
264
+
265
+ const maxStart = Math.max(0, totalCols - availableWidth);
266
+ let startCol = 0;
267
+ if (totalCols > availableWidth) {
268
+ const half = Math.floor(availableWidth / 2);
269
+ startCol = Math.max(0, Math.min(maxStart, cursorCols - half));
270
+ const maxCursorRel = Math.max(0, availableWidth - cursorGraphemeWidth);
271
+ if (cursorCols - startCol > maxCursorRel) {
272
+ startCol = Math.max(0, Math.min(maxStart, cursorCols - maxCursorRel));
273
+ }
274
+ }
275
+
276
+ const visibleText = sliceWithWidth(cursorDisplayValue, startCol, availableWidth, true).text;
277
+ const prefixText = sliceWithWidth(cursorDisplayValue, startCol, Math.max(0, cursorCols - startCol), true).text;
278
+ const cursorDisplay = Math.max(0, Math.min(prefixText.length, visibleText.length));
279
+ const [cursorSegment] = segmenter.segment(visibleText.slice(cursorDisplay));
280
+ const atCursor = cursorSegment?.segment ?? " ";
281
+ const beforeCursor = visibleText.slice(0, cursorDisplay);
282
+ const afterCursor = visibleText.slice(cursorDisplay + atCursor.length);
283
+ const marker = this.focused ? CURSOR_MARKER : "";
284
+ const cursorChar = `\x1b[7m${atCursor}\x1b[27m`;
285
+ const remainingAfterWidth = Math.max(0, availableWidth - visibleWidth(beforeCursor) - visibleWidth(atCursor));
286
+ const clampedAfterCursor = sliceWithWidth(afterCursor, 0, remainingAfterWidth, true).text;
287
+ const renderedNoMarker = beforeCursor + cursorChar + clampedAfterCursor;
288
+ const line = prompt + beforeCursor + marker + cursorChar + clampedAfterCursor;
289
+ return [line + padding(Math.max(0, availableWidth - visibleWidth(renderedNoMarker)))];
290
+ }
291
+
292
+ #submit(): void {
293
+ const secret = new SecretValue(this.#value, secretValueIssuer);
294
+ this.clear();
295
+ this.onSubmit?.(secret);
296
+ }
297
+
298
+ #insertCharacter(text: string): void {
299
+ const isWordChunk = [...segmenter.segment(text)].every(seg => getWordNavKind(seg.segment) !== "whitespace");
300
+ if (!isWordChunk || this.#lastAction !== "type-word") {
301
+ this.#pushUndo();
302
+ }
303
+ this.#lastAction = "type-word";
304
+ const inserted = insertTextNfcAt(this.#value, this.#cursor, text);
305
+ this.#value = inserted.value;
306
+ this.#cursor = inserted.cursor;
307
+ }
308
+
309
+ #handleBackspace(): void {
310
+ this.#lastAction = null;
311
+ if (this.#cursor <= 0) {
312
+ return;
313
+ }
314
+
315
+ this.#pushUndo();
316
+ const lastGrapheme = [...segmenter.segment(this.#value.slice(0, this.#cursor))].at(-1);
317
+ const graphemeLength = lastGrapheme?.segment.length ?? 1;
318
+ this.#value = this.#value.slice(0, this.#cursor - graphemeLength) + this.#value.slice(this.#cursor);
319
+ this.#cursor -= graphemeLength;
320
+ }
321
+
322
+ #handleForwardDelete(): void {
323
+ this.#lastAction = null;
324
+ if (this.#cursor >= this.#value.length) {
325
+ return;
326
+ }
327
+
328
+ this.#pushUndo();
329
+ const [firstGrapheme] = segmenter.segment(this.#value.slice(this.#cursor));
330
+ const graphemeLength = firstGrapheme?.segment.length ?? 1;
331
+ this.#value = this.#value.slice(0, this.#cursor) + this.#value.slice(this.#cursor + graphemeLength);
332
+ }
333
+
334
+ #deleteToLineStart(): void {
335
+ if (this.#cursor === 0) {
336
+ return;
337
+ }
338
+
339
+ this.#pushUndo();
340
+ this.#pushKill(this.#value.slice(0, this.#cursor), true, this.#lastAction === "kill");
341
+ this.#lastAction = "kill";
342
+ this.#value = this.#value.slice(this.#cursor);
343
+ this.#cursor = 0;
344
+ }
345
+
346
+ #deleteToLineEnd(): void {
347
+ if (this.#cursor >= this.#value.length) {
348
+ return;
349
+ }
350
+
351
+ this.#pushUndo();
352
+ this.#pushKill(this.#value.slice(this.#cursor), false, this.#lastAction === "kill");
353
+ this.#lastAction = "kill";
354
+ this.#value = this.#value.slice(0, this.#cursor);
355
+ }
356
+
357
+ #deleteWordBackwards(): void {
358
+ if (this.#cursor === 0) {
359
+ return;
360
+ }
361
+
362
+ const wasKill = this.#lastAction === "kill";
363
+ this.#pushUndo();
364
+ const oldCursor = this.#cursor;
365
+ this.#moveWordBackwards();
366
+ const deleteFrom = this.#cursor;
367
+ this.#cursor = oldCursor;
368
+ this.#pushKill(this.#value.slice(deleteFrom, this.#cursor), true, wasKill);
369
+ this.#lastAction = "kill";
370
+ this.#value = this.#value.slice(0, deleteFrom) + this.#value.slice(this.#cursor);
371
+ this.#cursor = deleteFrom;
372
+ }
373
+
374
+ #deleteWordForward(): void {
375
+ if (this.#cursor >= this.#value.length) {
376
+ return;
377
+ }
378
+
379
+ const wasKill = this.#lastAction === "kill";
380
+ this.#pushUndo();
381
+ const oldCursor = this.#cursor;
382
+ this.#moveWordForwards();
383
+ const deleteTo = this.#cursor;
384
+ this.#cursor = oldCursor;
385
+ this.#pushKill(this.#value.slice(this.#cursor, deleteTo), false, wasKill);
386
+ this.#lastAction = "kill";
387
+ this.#value = this.#value.slice(0, this.#cursor) + this.#value.slice(deleteTo);
388
+ }
389
+
390
+ #yank(): void {
391
+ const text = this.#killRing.at(-1);
392
+ if (!text) {
393
+ return;
394
+ }
395
+
396
+ this.#pushUndo();
397
+ const inserted = insertTextNfcAt(this.#value, this.#cursor, text);
398
+ this.#value = inserted.value;
399
+ this.#cursor = inserted.cursor;
400
+ this.#lastAction = "yank";
401
+ }
402
+
403
+ #yankPop(): void {
404
+ if (this.#lastAction !== "yank" || this.#killRing.length <= 1) {
405
+ return;
406
+ }
407
+
408
+ this.#pushUndo();
409
+ const previous = this.#killRing.at(-1) ?? "";
410
+ this.#value = this.#value.slice(0, this.#cursor - previous.length) + this.#value.slice(this.#cursor);
411
+ this.#cursor -= previous.length;
412
+ const last = this.#killRing.pop();
413
+ if (last !== undefined) {
414
+ this.#killRing.unshift(last);
415
+ }
416
+ const text = this.#killRing.at(-1) ?? "";
417
+ const inserted = insertTextNfcAt(this.#value, this.#cursor, text);
418
+ this.#value = inserted.value;
419
+ this.#cursor = inserted.cursor;
420
+ this.#lastAction = "yank";
421
+ }
422
+
423
+ #pushUndo(): void {
424
+ this.#undoStack.push({ value: this.#value, cursor: this.#cursor });
425
+ }
426
+
427
+ #undo(): void {
428
+ const snapshot = this.#undoStack.pop();
429
+ if (!snapshot) {
430
+ return;
431
+ }
432
+
433
+ this.#value = snapshot.value;
434
+ this.#cursor = snapshot.cursor;
435
+ this.#lastAction = null;
436
+ }
437
+
438
+ #moveWordBackwards(): void {
439
+ if (this.#cursor === 0) {
440
+ return;
441
+ }
442
+ this.#lastAction = null;
443
+ this.#cursor = moveWordLeft(this.#value, this.#cursor);
444
+ }
445
+
446
+ #moveWordForwards(): void {
447
+ if (this.#cursor >= this.#value.length) {
448
+ return;
449
+ }
450
+ this.#lastAction = null;
451
+ this.#cursor = moveWordRight(this.#value, this.#cursor);
452
+ }
453
+
454
+ #handlePaste(pastedText: string): void {
455
+ this.#lastAction = null;
456
+ this.#pushUndo();
457
+ const cleanText = replaceTabs(pastedText.replace(/\r\n/g, "").replace(/\r/g, "").replace(/\n/g, "")).normalize(
458
+ "NFC",
459
+ );
460
+ const inserted = insertTextNfcAt(this.#value, this.#cursor, cleanText);
461
+ this.#value = inserted.value;
462
+ this.#cursor = inserted.cursor;
463
+ }
464
+
465
+ #pushKill(text: string, prepend: boolean, accumulate: boolean): void {
466
+ if (!text) {
467
+ return;
468
+ }
469
+
470
+ if (accumulate && this.#killRing.length > 0) {
471
+ const lastIndex = this.#killRing.length - 1;
472
+ const last = this.#killRing[lastIndex];
473
+ this.#killRing[lastIndex] = prepend ? text + last : last + text;
474
+ return;
475
+ }
476
+
477
+ this.#killRing.push(text);
478
+ }
479
+
480
+ #maskedValueAndCursor(): { value: string; cursor: number } {
481
+ const before = this.#value.slice(0, this.#cursor);
482
+ const after = this.#value.slice(this.#cursor);
483
+ const beforeMask = "•".repeat([...segmenter.segment(before)].length);
484
+ return {
485
+ value: beforeMask + "•".repeat([...segmenter.segment(after)].length),
486
+ cursor: beforeMask.length,
487
+ };
488
+ }
489
+ }