@linxiraos/pi-tui 1.0.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 (77) hide show
  1. package/CHANGELOG.md +2219 -0
  2. package/README.md +705 -0
  3. package/dist/types/autocomplete.d.ts +116 -0
  4. package/dist/types/bracketed-paste.d.ts +51 -0
  5. package/dist/types/components/box.d.ts +31 -0
  6. package/dist/types/components/cancellable-loader.d.ts +21 -0
  7. package/dist/types/components/editor.d.ts +162 -0
  8. package/dist/types/components/image.d.ts +112 -0
  9. package/dist/types/components/input.d.ts +25 -0
  10. package/dist/types/components/loader.d.ts +25 -0
  11. package/dist/types/components/markdown.d.ts +88 -0
  12. package/dist/types/components/scroll-view.d.ts +62 -0
  13. package/dist/types/components/select-list.d.ts +69 -0
  14. package/dist/types/components/settings-list.d.ts +123 -0
  15. package/dist/types/components/spacer.d.ts +11 -0
  16. package/dist/types/components/tab-bar.d.ts +89 -0
  17. package/dist/types/components/text.d.ts +27 -0
  18. package/dist/types/components/truncated-text.d.ts +10 -0
  19. package/dist/types/deccara.d.ts +49 -0
  20. package/dist/types/desktop-notify.d.ts +52 -0
  21. package/dist/types/editor-component.d.ts +38 -0
  22. package/dist/types/fuzzy.d.ts +48 -0
  23. package/dist/types/index.d.ts +32 -0
  24. package/dist/types/keybindings.d.ts +197 -0
  25. package/dist/types/keys.d.ts +210 -0
  26. package/dist/types/kill-ring.d.ts +20 -0
  27. package/dist/types/kitty-graphics.d.ts +76 -0
  28. package/dist/types/latex-block.d.ts +8 -0
  29. package/dist/types/latex-to-unicode.d.ts +50 -0
  30. package/dist/types/loop-watchdog.d.ts +44 -0
  31. package/dist/types/mouse.d.ts +67 -0
  32. package/dist/types/stdin-buffer.d.ts +60 -0
  33. package/dist/types/symbols.d.ts +25 -0
  34. package/dist/types/terminal-capabilities.d.ts +285 -0
  35. package/dist/types/terminal.d.ts +175 -0
  36. package/dist/types/tmux.d.ts +6 -0
  37. package/dist/types/ttyid.d.ts +9 -0
  38. package/dist/types/tui.d.ts +457 -0
  39. package/dist/types/utils.d.ts +100 -0
  40. package/package.json +70 -0
  41. package/src/autocomplete.ts +1079 -0
  42. package/src/bracketed-paste.ts +123 -0
  43. package/src/components/box.ts +236 -0
  44. package/src/components/cancellable-loader.ts +40 -0
  45. package/src/components/editor.ts +3301 -0
  46. package/src/components/image.ts +460 -0
  47. package/src/components/input.ts +482 -0
  48. package/src/components/loader.ts +174 -0
  49. package/src/components/markdown.ts +3119 -0
  50. package/src/components/scroll-view.ts +227 -0
  51. package/src/components/select-list.ts +539 -0
  52. package/src/components/settings-list.ts +793 -0
  53. package/src/components/spacer.ts +32 -0
  54. package/src/components/tab-bar.ts +300 -0
  55. package/src/components/text.ts +173 -0
  56. package/src/components/truncated-text.ts +69 -0
  57. package/src/deccara.ts +314 -0
  58. package/src/desktop-notify.ts +192 -0
  59. package/src/editor-component.ts +74 -0
  60. package/src/fuzzy.ts +384 -0
  61. package/src/index.ts +51 -0
  62. package/src/keybindings.ts +346 -0
  63. package/src/keys.ts +566 -0
  64. package/src/kill-ring.ts +51 -0
  65. package/src/kitty-graphics.ts +171 -0
  66. package/src/latex-block.ts +1338 -0
  67. package/src/latex-to-unicode.ts +2017 -0
  68. package/src/loop-watchdog.ts +115 -0
  69. package/src/mouse.ts +105 -0
  70. package/src/stdin-buffer.ts +781 -0
  71. package/src/symbols.ts +26 -0
  72. package/src/terminal-capabilities.ts +1211 -0
  73. package/src/terminal.ts +1854 -0
  74. package/src/tmux.ts +14 -0
  75. package/src/ttyid.ts +84 -0
  76. package/src/tui.ts +4275 -0
  77. package/src/utils.ts +619 -0
@@ -0,0 +1,123 @@
1
+ const PASTE_START = "\x1b[200~";
2
+ const PASTE_END = "\x1b[201~";
3
+
4
+ export type PasteResult = { handled: false } | { handled: true; pasteContent?: string; remaining: string };
5
+
6
+ // Some terminals re-encode the control bytes inside a bracketed paste as key-event
7
+ // escape sequences (observed with tmux extended-keys passthrough under kitty). tmux
8
+ // emits one of two formats depending on `extended-keys-format`:
9
+ // - csi-u: ESC [ <codepoint> ; 5 u (Ctrl+J → ESC [ 106 ; 5 u)
10
+ // - xterm: ESC [ 27 ; 5 ; <codepoint> ~ (Ctrl+J → ESC [ 27 ; 5 ; 106 ~)
11
+ // Callers must decode these back to the literal control byte (Ctrl+J → "\n") before
12
+ // stripping control chars; otherwise ESC is dropped and the printable tail
13
+ // ("[106;5u" / "[27;5;106~") leaks into the editor.
14
+ //
15
+ // Only Ctrl+<letter> is decoded (codepoint a-z/A-Z → 0x01..0x1A). That is the set tmux
16
+ // actually re-encodes from paste content in practice — TAB (Ctrl+I), LF (Ctrl+J), CR
17
+ // (Ctrl+M), VT (Ctrl+K), FF (Ctrl+L), … Non-letter Ctrl combos (NUL, ESC, FS-US, DEL)
18
+ // never appear as re-encoded paste bytes, so they are left untouched rather than
19
+ // synthesized into raw control bytes. Callers still strip leftover control characters
20
+ // after decoding (the editor keeps "\n"; the single-line input strips all of them).
21
+ const REENCODED_CTRL_CSI_U = /\x1b\[(\d+);5u/g;
22
+ const REENCODED_CTRL_XTERM = /\x1b\[27;5;(\d+)~/g;
23
+
24
+ function decodeReencodedCtrlByte(match: string, code: string): string {
25
+ const cp = Number(code);
26
+ if (cp >= 97 && cp <= 122) return String.fromCharCode(cp - 96); // a-z → Ctrl+A..Ctrl+Z
27
+ if (cp >= 65 && cp <= 90) return String.fromCharCode(cp - 64); // A-Z → Ctrl+A..Ctrl+Z
28
+ return match;
29
+ }
30
+
31
+ /**
32
+ * Decode tmux's re-encoded control bytes (both `extended-keys-format` variants) inside a
33
+ * bracketed-paste payload back to their literal byte (e.g. Ctrl+J → "\n"). Leaves the rest of
34
+ * the text untouched. Call before any control-character stripping so newlines/tabs survive
35
+ * instead of leaking the printable escape tail into the buffer.
36
+ */
37
+ export function decodeReencodedPasteControls(text: string): string {
38
+ return text
39
+ .replace(REENCODED_CTRL_CSI_U, decodeReencodedCtrlByte)
40
+ .replace(REENCODED_CTRL_XTERM, decodeReencodedCtrlByte);
41
+ }
42
+
43
+ /**
44
+ * Options for {@link BracketedPasteHandler}.
45
+ */
46
+ export type BracketedPasteHandlerOptions = {
47
+ /**
48
+ * Byte cap for buffered paste content (default: 64 MiB). When exceeded,
49
+ * paste mode is aborted and the accumulated content is delivered as
50
+ * `pasteContent` on the same `process()` call so a lost/corrupted end
51
+ * marker cannot consume unbounded memory. Mirrors `StdinBuffer#abortPaste`
52
+ * — defense in depth for callers that bypass `StdinBuffer` (issue #4073
53
+ * case B). The normal `ProcessTerminal` path re-wraps `StdinBuffer`'s
54
+ * bounded paste with both markers, so this cap only fires on alternate
55
+ * callers.
56
+ */
57
+ byteLimit?: number;
58
+ };
59
+
60
+ const DEFAULT_BYTE_LIMIT = 64 * 1024 * 1024;
61
+
62
+ /**
63
+ * Handles bracketed paste mode buffering for terminal input components.
64
+ *
65
+ * Bracketed paste mode wraps pasted content between start (\x1b[200~) and
66
+ * end (\x1b[201~) markers, which may arrive split across multiple chunks.
67
+ * This class buffers incoming data and assembles complete paste payloads.
68
+ */
69
+ export class BracketedPasteHandler {
70
+ #buffer = "";
71
+ #active = false;
72
+ readonly #byteLimit: number;
73
+
74
+ constructor(options: BracketedPasteHandlerOptions = {}) {
75
+ this.#byteLimit = options.byteLimit ?? DEFAULT_BYTE_LIMIT;
76
+ }
77
+
78
+ /**
79
+ * Process incoming terminal data for bracketed paste sequences.
80
+ *
81
+ * @returns `{ handled: false }` if the data contains no paste sequence and
82
+ * should be processed normally. `{ handled: true }` if the data was
83
+ * consumed by paste buffering — `pasteContent` is set when a complete
84
+ * paste has been assembled (or the byte cap has aborted a runaway
85
+ * buffer); omitted when still buffering.
86
+ */
87
+ process(data: string): PasteResult {
88
+ if (data.includes(PASTE_START)) {
89
+ this.#active = true;
90
+ this.#buffer = "";
91
+ data = data.replace(PASTE_START, "");
92
+ }
93
+
94
+ if (!this.#active) return { handled: false };
95
+
96
+ this.#buffer += data;
97
+
98
+ const endIndex = this.#buffer.indexOf(PASTE_END);
99
+ if (endIndex !== -1) {
100
+ const pasteContent = this.#buffer.substring(0, endIndex);
101
+ const remaining = this.#buffer.substring(endIndex + PASTE_END.length);
102
+
103
+ this.#buffer = "";
104
+ this.#active = false;
105
+
106
+ return { handled: true, pasteContent, remaining };
107
+ }
108
+
109
+ // Byte cap: a lost/corrupted end marker (ssh/tmux truncation) must not
110
+ // consume unbounded memory. Deliver the accumulated bytes so they are
111
+ // neither lost nor held forever, and reset paste mode so subsequent
112
+ // input recovers. See `StdinBuffer#abortPaste` for the sibling recovery
113
+ // semantics inside `StdinBuffer`.
114
+ if (this.#buffer.length > this.#byteLimit) {
115
+ const pasteContent = this.#buffer;
116
+ this.#buffer = "";
117
+ this.#active = false;
118
+ return { handled: true, pasteContent, remaining: "" };
119
+ }
120
+
121
+ return { handled: true, remaining: "" };
122
+ }
123
+ }
@@ -0,0 +1,236 @@
1
+ import type { Component } from "../tui";
2
+ import {
3
+ getPaddingX,
4
+ getPublishedLineWidths,
5
+ getWidthConfigEpoch,
6
+ padding,
7
+ publishLineWidths,
8
+ visibleWidth,
9
+ } from "../utils";
10
+
11
+ type Cache = {
12
+ width: number;
13
+ widthEpoch: number;
14
+ bgSample: string | undefined;
15
+ borderSample: string | undefined;
16
+ childLines: (readonly string[])[];
17
+ childWidths: (readonly number[] | undefined)[];
18
+ childSnapshots: (readonly string[] | undefined)[];
19
+ result: string[];
20
+ };
21
+
22
+ /** Box-drawing glyphs plus an optional colorizer for an outline drawn around a {@link Box}. */
23
+ export interface BoxBorder {
24
+ chars: {
25
+ topLeft: string;
26
+ topRight: string;
27
+ bottomLeft: string;
28
+ bottomRight: string;
29
+ horizontal: string;
30
+ vertical: string;
31
+ };
32
+ color?: (text: string) => string;
33
+ }
34
+
35
+ /**
36
+ * Box component - a container that applies padding and background to all children
37
+ */
38
+ export class Box implements Component {
39
+ children: Component[] = [];
40
+ #paddingX: number;
41
+ #paddingY: number;
42
+ #bgFn?: (text: string) => string;
43
+ #border?: BoxBorder;
44
+
45
+ #ignoreTight = false;
46
+
47
+ setIgnoreTight(ignore: boolean): this {
48
+ this.#ignoreTight = ignore;
49
+ this.#invalidateCache();
50
+ return this;
51
+ }
52
+
53
+ // Cache for rendered output
54
+ #cached?: Cache;
55
+
56
+ constructor(paddingX = 1, paddingY = 1, bgFn?: (text: string) => string, border?: BoxBorder) {
57
+ this.#paddingX = paddingX;
58
+ this.#paddingY = paddingY;
59
+ this.#bgFn = bgFn;
60
+ this.#border = border;
61
+ }
62
+
63
+ addChild(component: Component): void {
64
+ this.children.push(component);
65
+ if (this.#ignoreTight) {
66
+ component.setIgnoreTight?.(true);
67
+ }
68
+ this.#invalidateCache();
69
+ }
70
+
71
+ removeChild(component: Component): void {
72
+ const index = this.children.indexOf(component);
73
+ if (index !== -1) {
74
+ this.children.splice(index, 1);
75
+ this.#invalidateCache();
76
+ }
77
+ }
78
+
79
+ clear(): void {
80
+ this.children = [];
81
+ this.#invalidateCache();
82
+ }
83
+
84
+ setPaddingX(paddingX: number): void {
85
+ if (this.#paddingX === paddingX) return;
86
+ this.#paddingX = paddingX;
87
+ this.#invalidateCache();
88
+ }
89
+
90
+ setPaddingY(paddingY: number): void {
91
+ if (this.#paddingY === paddingY) return;
92
+ this.#paddingY = paddingY;
93
+ this.#invalidateCache();
94
+ }
95
+
96
+ setBgFn(bgFn?: (text: string) => string): void {
97
+ this.#bgFn = bgFn;
98
+ // Don't invalidate here - we'll detect bgFn changes by sampling output
99
+ }
100
+
101
+ setBorder(border?: BoxBorder): void {
102
+ this.#border = border;
103
+ this.#invalidateCache();
104
+ }
105
+
106
+ #invalidateCache(): void {
107
+ this.#cached = undefined;
108
+ }
109
+
110
+ invalidate(): void {
111
+ this.#invalidateCache();
112
+ for (const child of this.children) {
113
+ child.invalidate?.();
114
+ }
115
+ }
116
+
117
+ render(width: number): readonly string[] {
118
+ const children = this.children;
119
+ const count = children.length;
120
+ const paddingX = this.#ignoreTight ? this.#paddingX : getPaddingX(this.#paddingX);
121
+ // A border eats one column on each side; skip it unless the interior can still
122
+ // hold the horizontal padding plus at least one content column, so a bordered
123
+ // Box never overflows the width it was given.
124
+ const border = this.#border && width - 2 >= paddingX * 2 + 1 ? this.#border : undefined;
125
+ const innerWidth = border ? width - 2 : width;
126
+ const contentWidth = Math.max(1, innerWidth - paddingX * 2);
127
+ // bgFn / border output can change without the function reference changing
128
+ // (theme mutation); sample both so a silent palette swap still misses the cache.
129
+ const bgSample = this.#bgFn ? this.#bgFn("test") : undefined;
130
+ const borderSample = border
131
+ ? `${border.color ? border.color("|") : "|"}${border.chars.topLeft}${border.chars.vertical}`
132
+ : undefined;
133
+
134
+ // Render every child every frame (renders may carry side effects); the
135
+ // memo only skips re-deriving the padded/background rows.
136
+ const widthEpoch = getWidthConfigEpoch();
137
+ let contentRows = 0;
138
+ const childLines = children.map(child => {
139
+ const lines = child.render(contentWidth);
140
+ contentRows += lines.length;
141
+ return lines;
142
+ });
143
+ const childWidths = childLines.map(lines => getPublishedLineWidths(lines));
144
+ const cached = this.#cached;
145
+ if (
146
+ cached !== undefined &&
147
+ cached.width === width &&
148
+ cached.widthEpoch === widthEpoch &&
149
+ cached.widthEpoch === getWidthConfigEpoch() &&
150
+ cached.bgSample === bgSample &&
151
+ cached.borderSample === borderSample &&
152
+ cached.childLines.length === count &&
153
+ childLines.every((lines, i) => {
154
+ if (cached.childLines[i] !== lines) return false;
155
+ const published = childWidths[i];
156
+ const cachedPublished = cached.childWidths[i];
157
+ if (published !== undefined || cachedPublished !== undefined) {
158
+ return published === cachedPublished;
159
+ }
160
+ const snapshot = cached.childSnapshots[i];
161
+ return (
162
+ snapshot !== undefined &&
163
+ snapshot.length === lines.length &&
164
+ lines.every((line, j) => snapshot[j] === line)
165
+ );
166
+ })
167
+ ) {
168
+ return cached.result;
169
+ }
170
+
171
+ const result: string[] = [];
172
+ // Exact visible widths of `result` rows, published only when the row
173
+ // bytes are `content + spaces` (no bg/border transform of unknown width).
174
+ const resultWidths: number[] | undefined = !border && !this.#bgFn ? [] : undefined;
175
+ if (contentRows > 0) {
176
+ const leftPad = padding(paddingX);
177
+ const interior: string[] = [];
178
+ const pushRow = (row: string, visLen: number): void => {
179
+ const padNeeded = Math.max(0, innerWidth - visLen);
180
+ const padded = padNeeded > 0 ? row + padding(padNeeded) : row;
181
+ interior.push(this.#bgFn ? this.#bgFn(padded) : padded);
182
+ resultWidths?.push(visLen + padNeeded);
183
+ };
184
+ // Top padding
185
+ for (let i = 0; i < this.#paddingY; i++) {
186
+ pushRow("", 0);
187
+ }
188
+ // Content
189
+ let childIndex = 0;
190
+ for (const lines of childLines) {
191
+ const widths = childWidths[childIndex++];
192
+ for (let j = 0; j < lines.length; j++) {
193
+ const line = lines[j] ?? "";
194
+ const row = paddingX > 0 ? leftPad + line : line;
195
+ const carried = widths?.[j];
196
+ const visLen = carried !== undefined && paddingX === 0 ? carried : visibleWidth(row);
197
+ pushRow(row, visLen);
198
+ }
199
+ }
200
+ // Bottom padding
201
+ for (let i = 0; i < this.#paddingY; i++) {
202
+ pushRow("", 0);
203
+ }
204
+
205
+ if (border) {
206
+ const paint = border.color ?? (s => s);
207
+ const rule = border.chars.horizontal.repeat(Math.max(0, innerWidth));
208
+ const side = paint(border.chars.vertical);
209
+ result.push(paint(border.chars.topLeft + rule + border.chars.topRight));
210
+ for (const row of interior) {
211
+ result.push(side + row + side);
212
+ }
213
+ result.push(paint(border.chars.bottomLeft + rule + border.chars.bottomRight));
214
+ } else {
215
+ for (const row of interior) {
216
+ result.push(row);
217
+ }
218
+ }
219
+ }
220
+
221
+ const finalWidthEpoch = getWidthConfigEpoch();
222
+ if (resultWidths !== undefined) publishLineWidths(result, resultWidths);
223
+ const childSnapshots = childLines.map((lines, i) => (childWidths[i] === undefined ? [...lines] : undefined));
224
+ this.#cached = {
225
+ width,
226
+ widthEpoch: finalWidthEpoch,
227
+ bgSample,
228
+ borderSample,
229
+ childLines,
230
+ childWidths,
231
+ childSnapshots,
232
+ result,
233
+ };
234
+ return result;
235
+ }
236
+ }
@@ -0,0 +1,40 @@
1
+ import { getKeybindings } from "../keybindings";
2
+ import { Loader } from "./loader";
3
+
4
+ /**
5
+ * Loader that can be cancelled with Escape.
6
+ * Extends Loader with an AbortSignal for cancelling async operations.
7
+ *
8
+ * @example
9
+ * const loader = new CancellableLoader(tui, cyan, dim, "Working...");
10
+ * loader.onAbort = () => done(null);
11
+ * doWork(loader.signal).then(done);
12
+ */
13
+ export class CancellableLoader extends Loader {
14
+ #abortController = new AbortController();
15
+
16
+ /** Called when user presses Escape */
17
+ onAbort?: () => void;
18
+
19
+ /** AbortSignal that is aborted when user presses Escape */
20
+ get signal(): AbortSignal {
21
+ return this.#abortController.signal;
22
+ }
23
+
24
+ /** Whether the loader was aborted */
25
+ get aborted(): boolean {
26
+ return this.#abortController.signal.aborted;
27
+ }
28
+
29
+ handleInput(data: string): void {
30
+ const kb = getKeybindings();
31
+ if (kb.matches(data, "tui.select.cancel")) {
32
+ this.#abortController.abort();
33
+ this.onAbort?.();
34
+ }
35
+ }
36
+
37
+ override dispose(): void {
38
+ this.stop();
39
+ }
40
+ }