@linxiraos/pi-tui 1.1.4 → 1.1.6
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/CHANGELOG.md +3 -4
- package/dist/types/components/box.d.ts +2 -0
- package/dist/types/components/cancellable-loader.d.ts +2 -0
- package/dist/types/components/composer/band.d.ts +2 -0
- package/dist/types/components/composer/index.d.ts +1 -0
- package/dist/types/components/composer/types.d.ts +3 -3
- package/dist/types/components/editor.d.ts +4 -0
- package/dist/types/components/image.d.ts +2 -0
- package/dist/types/components/input.d.ts +2 -0
- package/dist/types/components/loader.d.ts +9 -1
- package/dist/types/components/markdown.d.ts +16 -2
- package/dist/types/components/scroll-view.d.ts +2 -0
- package/dist/types/components/select-list.d.ts +2 -0
- package/dist/types/components/settings-list.d.ts +4 -0
- package/dist/types/components/spacer.d.ts +2 -0
- package/dist/types/components/tab-bar.d.ts +2 -0
- package/dist/types/components/text.d.ts +2 -0
- package/dist/types/components/truncated-text.d.ts +2 -0
- package/dist/types/debug-server.d.ts +27 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/latex-to-unicode.d.ts +4 -13
- package/dist/types/terminal-capabilities.d.ts +17 -0
- package/dist/types/terminal.d.ts +11 -0
- package/dist/types/tui.d.ts +42 -3
- package/package.json +3 -3
- package/src/autocomplete.ts +50 -8
- package/src/components/box.ts +10 -0
- package/src/components/cancellable-loader.ts +7 -0
- package/src/components/composer/band.ts +42 -0
- package/src/components/composer/index.ts +1 -0
- package/src/components/composer/registry.ts +2 -0
- package/src/components/composer/types.ts +12 -3
- package/src/components/editor.ts +56 -10
- package/src/components/image.ts +11 -0
- package/src/components/input.ts +13 -0
- package/src/components/loader.ts +50 -10
- package/src/components/markdown.ts +426 -71
- package/src/components/scroll-view.ts +12 -0
- package/src/components/select-list.ts +13 -0
- package/src/components/settings-list.ts +19 -0
- package/src/components/spacer.ts +4 -0
- package/src/components/tab-bar.ts +11 -0
- package/src/components/text.ts +11 -0
- package/src/components/truncated-text.ts +15 -1
- package/src/debug-server.ts +422 -0
- package/src/index.ts +2 -0
- package/src/latex-to-unicode.ts +416 -230
- package/src/terminal-capabilities.ts +54 -9
- package/src/terminal.ts +22 -2
- package/src/tui.ts +461 -60
- package/src/utils.ts +10 -1
|
@@ -12,7 +12,16 @@ import type { SymbolTheme } from "../../symbols";
|
|
|
12
12
|
export type ComposerBox = SymbolTheme["boxRound"];
|
|
13
13
|
|
|
14
14
|
/** Built-in composer shape identifiers shipped by pi-tui. */
|
|
15
|
-
export const BUILTIN_EDITOR_BORDER_STYLES = [
|
|
15
|
+
export const BUILTIN_EDITOR_BORDER_STYLES = [
|
|
16
|
+
"box",
|
|
17
|
+
"band",
|
|
18
|
+
"claude",
|
|
19
|
+
"pi",
|
|
20
|
+
"borderless",
|
|
21
|
+
"rule",
|
|
22
|
+
"field",
|
|
23
|
+
"rail",
|
|
24
|
+
] as const;
|
|
16
25
|
|
|
17
26
|
/** Identifier for a built-in composer shape. */
|
|
18
27
|
export type BuiltinEditorBorderStyle = (typeof BUILTIN_EDITOR_BORDER_STYLES)[number];
|
|
@@ -74,8 +83,8 @@ export interface ComposerStyle {
|
|
|
74
83
|
/** Rows consumed by top+bottom chrome (drives maxHeight budgeting). */
|
|
75
84
|
readonly verticalChrome: 0 | 1 | 2;
|
|
76
85
|
/** Where the host should attach the status bar: embedded in the top border,
|
|
77
|
-
* docked onto a top rule, or detached into a standalone bottom bar. */
|
|
78
|
-
readonly statusAttachment: "top-border" | "top-rule-chip" | "none";
|
|
86
|
+
* rendered as a flush soft-capped band above the input, docked onto a top rule, or detached into a standalone bottom bar. */
|
|
87
|
+
readonly statusAttachment: "top-border" | "top-band" | "top-rule-chip" | "none";
|
|
79
88
|
/** Which segment groups the standalone bottom status bar shows. */
|
|
80
89
|
readonly bottomBar: "none" | "left" | "full";
|
|
81
90
|
/** Insert a blank spacer row between the editor and the standalone bottom
|
package/src/components/editor.ts
CHANGED
|
@@ -595,6 +595,34 @@ export class Editor implements Component, Focusable {
|
|
|
595
595
|
this.#theme = theme;
|
|
596
596
|
this.borderColor = theme.borderColor;
|
|
597
597
|
}
|
|
598
|
+
/** Return bounded editor content, cursor, and transient child state for debug inspection. */
|
|
599
|
+
debugState(): Record<string, unknown> {
|
|
600
|
+
const lines = this.#state.lines;
|
|
601
|
+
let textLength = Math.max(0, lines.length - 1);
|
|
602
|
+
let textPreview = "";
|
|
603
|
+
for (let i = 0; i < lines.length; i++) {
|
|
604
|
+
const line = lines[i] ?? "";
|
|
605
|
+
textLength += line.length;
|
|
606
|
+
if (textPreview.length >= 120) continue;
|
|
607
|
+
if (i > 0) textPreview += "\n";
|
|
608
|
+
textPreview += line.slice(0, 120 - textPreview.length);
|
|
609
|
+
}
|
|
610
|
+
return {
|
|
611
|
+
textPreview,
|
|
612
|
+
textLength,
|
|
613
|
+
previewTruncated: textLength > 120,
|
|
614
|
+
cursorLine: this.#state.cursorLine,
|
|
615
|
+
cursorCol: this.#state.cursorCol,
|
|
616
|
+
lineCount: lines.length,
|
|
617
|
+
selection: null,
|
|
618
|
+
placeholderActive: false,
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/** Expose the active autocomplete list to the debug tree walker. */
|
|
623
|
+
get debugChildren(): readonly Component[] {
|
|
624
|
+
return this.#autocompleteList ? [this.#autocompleteList] : [];
|
|
625
|
+
}
|
|
598
626
|
|
|
599
627
|
setTheme(theme: EditorTheme): void {
|
|
600
628
|
this.#theme = theme;
|
|
@@ -1375,11 +1403,17 @@ export class Editor implements Component, Focusable {
|
|
|
1375
1403
|
this.#cancelAutocomplete(true);
|
|
1376
1404
|
return;
|
|
1377
1405
|
}
|
|
1406
|
+
// Right arrow at end of line accepts the selection like Tab (fish-style).
|
|
1407
|
+
// Mid-line, right arrow keeps its cursor-movement role and falls through.
|
|
1408
|
+
const rightArrowAccepts =
|
|
1409
|
+
kb.matchesCanonical(canonical, "tui.editor.cursorRight") &&
|
|
1410
|
+
this.#state.cursorCol >= (this.#state.lines[this.#state.cursorLine] ?? "").length;
|
|
1378
1411
|
if (
|
|
1379
1412
|
this.#autocompleteState === "assist" &&
|
|
1380
1413
|
(kb.matchesCanonical(canonical, "tui.input.submit") ||
|
|
1381
1414
|
data === "\n" ||
|
|
1382
|
-
kb.matchesCanonical(canonical, "tui.input.tab")
|
|
1415
|
+
kb.matchesCanonical(canonical, "tui.input.tab") ||
|
|
1416
|
+
rightArrowAccepts)
|
|
1383
1417
|
) {
|
|
1384
1418
|
this.#applySpellingSuggestion();
|
|
1385
1419
|
return;
|
|
@@ -1392,7 +1426,8 @@ export class Editor implements Component, Focusable {
|
|
|
1392
1426
|
kb.matchesCanonical(canonical, "tui.select.pageDown") ||
|
|
1393
1427
|
kb.matchesCanonical(canonical, "tui.input.submit") ||
|
|
1394
1428
|
data === "\n" ||
|
|
1395
|
-
kb.matchesCanonical(canonical, "tui.input.tab")
|
|
1429
|
+
kb.matchesCanonical(canonical, "tui.input.tab") ||
|
|
1430
|
+
rightArrowAccepts
|
|
1396
1431
|
) {
|
|
1397
1432
|
// Only pass navigation keys to the list, not Enter/Tab (we handle those directly)
|
|
1398
1433
|
if (
|
|
@@ -1407,7 +1442,7 @@ export class Editor implements Component, Focusable {
|
|
|
1407
1442
|
}
|
|
1408
1443
|
|
|
1409
1444
|
// If Tab was pressed, always apply the selection
|
|
1410
|
-
if (kb.matchesCanonical(canonical, "tui.input.tab")) {
|
|
1445
|
+
if (kb.matchesCanonical(canonical, "tui.input.tab") || rightArrowAccepts) {
|
|
1411
1446
|
const selected = this.#autocompleteList.getSelectedItem();
|
|
1412
1447
|
// Check for stale autocomplete state due to buffer edits since last refresh
|
|
1413
1448
|
// (destructive keys or paste can outrun the debounced update).
|
|
@@ -1703,6 +1738,13 @@ export class Editor implements Component, Focusable {
|
|
|
1703
1738
|
}
|
|
1704
1739
|
} else if (kb.matchesCanonical(canonical, "tui.editor.cursorRight")) {
|
|
1705
1740
|
// Right
|
|
1741
|
+
// At end of line, accept the inline ghost word completion (IME-style) like Tab.
|
|
1742
|
+
if (
|
|
1743
|
+
this.#state.cursorCol >= (this.#state.lines[this.#state.cursorLine] ?? "").length &&
|
|
1744
|
+
this.#acceptWordCompletion()
|
|
1745
|
+
) {
|
|
1746
|
+
return;
|
|
1747
|
+
}
|
|
1706
1748
|
this.#moveCursor(0, 1);
|
|
1707
1749
|
} else if (kb.matchesCanonical(canonical, "tui.editor.cursorLeft")) {
|
|
1708
1750
|
// Left
|
|
@@ -3407,13 +3449,7 @@ export class Editor implements Component, Focusable {
|
|
|
3407
3449
|
}
|
|
3408
3450
|
|
|
3409
3451
|
async #handleTabCompletion(): Promise<void> {
|
|
3410
|
-
|
|
3411
|
-
if (wordCompletion) {
|
|
3412
|
-
const currentLine = this.#state.lines[this.#state.cursorLine] ?? "";
|
|
3413
|
-
const after = currentLine.slice(this.#state.cursorCol);
|
|
3414
|
-
this.#insertTextAtCursor(wordCompletion + (/^[\s.,;:!?"\])}]/.test(after) ? "" : " "));
|
|
3415
|
-
return;
|
|
3416
|
-
}
|
|
3452
|
+
if (this.#acceptWordCompletion()) return;
|
|
3417
3453
|
if (!this.#autocompleteProvider) return;
|
|
3418
3454
|
|
|
3419
3455
|
const currentLine = this.#state.lines[this.#state.cursorLine] || "";
|
|
@@ -3430,6 +3466,16 @@ export class Editor implements Component, Focusable {
|
|
|
3430
3466
|
await this.#forceFileAutocomplete();
|
|
3431
3467
|
}
|
|
3432
3468
|
}
|
|
3469
|
+
/** Insert the inline ghost word completion at the cursor, if any. Shared by Tab and right-arrow accept. */
|
|
3470
|
+
#acceptWordCompletion(): boolean {
|
|
3471
|
+
const wordCompletion = this.#getWordCompletion();
|
|
3472
|
+
if (!wordCompletion) return false;
|
|
3473
|
+
const currentLine = this.#state.lines[this.#state.cursorLine] ?? "";
|
|
3474
|
+
const after = currentLine.slice(this.#state.cursorCol);
|
|
3475
|
+
this.#insertTextAtCursor(wordCompletion + (/^[\s.,;:!?"\])}]/.test(after) ? "" : " "));
|
|
3476
|
+
return true;
|
|
3477
|
+
}
|
|
3478
|
+
|
|
3433
3479
|
async #showSpellingSuggestions(): Promise<void> {
|
|
3434
3480
|
const cursorLine = this.#state.cursorLine;
|
|
3435
3481
|
const cursorCol = this.#state.cursorCol;
|
package/src/components/image.ts
CHANGED
|
@@ -505,6 +505,17 @@ export class Image implements Component {
|
|
|
505
505
|
this.#budget = options.budget;
|
|
506
506
|
this.#imageId = options.budget ? options.budget.acquireId(options.imageKey) : undefined;
|
|
507
507
|
}
|
|
508
|
+
/** Return source metadata without exposing the encoded image buffer. */
|
|
509
|
+
debugState(): Record<string, unknown> {
|
|
510
|
+
return {
|
|
511
|
+
mimeType: this.#mimeType,
|
|
512
|
+
widthPx: this.#dimensions.widthPx,
|
|
513
|
+
heightPx: this.#dimensions.heightPx,
|
|
514
|
+
filename: this.#options.filename ?? null,
|
|
515
|
+
imageId: this.#imageId ?? null,
|
|
516
|
+
suppressed: this.#cachedSuppressed,
|
|
517
|
+
};
|
|
518
|
+
}
|
|
508
519
|
|
|
509
520
|
invalidate(): void {
|
|
510
521
|
this.#cachedLines = undefined;
|
package/src/components/input.ts
CHANGED
|
@@ -51,6 +51,19 @@ export class Input implements Component, Focusable {
|
|
|
51
51
|
getValue(): string {
|
|
52
52
|
return this.#value;
|
|
53
53
|
}
|
|
54
|
+
/** Return bounded input content and cursor state for debug inspection. */
|
|
55
|
+
debugState(): Record<string, unknown> {
|
|
56
|
+
return {
|
|
57
|
+
textPreview: this.#value.slice(0, 120),
|
|
58
|
+
textLength: this.#value.length,
|
|
59
|
+
previewTruncated: this.#value.length > 120,
|
|
60
|
+
cursor: this.#cursor,
|
|
61
|
+
cursorLine: 0,
|
|
62
|
+
selection: null,
|
|
63
|
+
placeholderActive: false,
|
|
64
|
+
masked: this.mask,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
54
67
|
|
|
55
68
|
setValue(value: string): void {
|
|
56
69
|
this.#value = value;
|
package/src/components/loader.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import type { TUI } from "../tui";
|
|
2
|
-
import { getPaddingX, sliceByColumn, visibleWidth } from "../utils";
|
|
2
|
+
import { getPaddingX, padding, sliceByColumn, visibleWidth } from "../utils";
|
|
3
3
|
import { Text } from "./text";
|
|
4
4
|
|
|
5
5
|
const RENDER_INTERVAL_MS = 1000 / 30;
|
|
6
|
-
|
|
6
|
+
/** Milliseconds between spinner-frame advances; exported so time-derived spinners elsewhere tick at the Loader cadence. */
|
|
7
|
+
export const SPINNER_ADVANCE_MS = 80;
|
|
7
8
|
const RENDER_BACKPRESSURE_MULTIPLIER = 9;
|
|
9
|
+
const MAX_BACKPRESSURE_FRAME_COST_MS = 200;
|
|
8
10
|
|
|
9
11
|
type ColorFn = (str: string) => string;
|
|
10
12
|
|
|
@@ -27,12 +29,13 @@ export class Loader extends Text {
|
|
|
27
29
|
#layout?: readonly { leading: string; content: string; trailing: string }[];
|
|
28
30
|
#layoutFrames: readonly string[];
|
|
29
31
|
#layoutFrame: string;
|
|
32
|
+
#trailer?: () => string | undefined;
|
|
30
33
|
|
|
31
34
|
constructor(
|
|
32
35
|
ui: TUI,
|
|
33
36
|
private spinnerColorFn: ColorFn,
|
|
34
37
|
private messageColorFn: LoaderMessageColorFn,
|
|
35
|
-
private message: string = "Loading...",
|
|
38
|
+
private message: string | (() => string) = "Loading...",
|
|
36
39
|
spinnerFrames?: string[],
|
|
37
40
|
) {
|
|
38
41
|
super("", 1, 0);
|
|
@@ -53,6 +56,16 @@ export class Loader extends Text {
|
|
|
53
56
|
this.#layoutFrame = this.#layoutFrames[0];
|
|
54
57
|
this.start();
|
|
55
58
|
}
|
|
59
|
+
/** Return the current message and animation state for debug inspection. */
|
|
60
|
+
override debugState(): Record<string, unknown> {
|
|
61
|
+
const message = this.#resolveMessage();
|
|
62
|
+
return {
|
|
63
|
+
message: message.slice(0, 120),
|
|
64
|
+
messageLength: message.length,
|
|
65
|
+
running: this.#intervalId !== undefined,
|
|
66
|
+
frame: this.#currentFrame,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
56
69
|
|
|
57
70
|
override render(width: number): readonly string[] {
|
|
58
71
|
const source = super.render(width);
|
|
@@ -91,6 +104,15 @@ export class Loader extends Text {
|
|
|
91
104
|
lines.push(`${leading}${content ? this.messageColorFn(content) : ""}${trailing}`);
|
|
92
105
|
}
|
|
93
106
|
}
|
|
107
|
+
if (this.#trailer && lines.length > 1) {
|
|
108
|
+
const trailer = this.#trailer();
|
|
109
|
+
if (trailer) {
|
|
110
|
+
// Text pads rows to full width; drop that pad before docking right.
|
|
111
|
+
const body = lines[1].trimEnd();
|
|
112
|
+
const gap = width - visibleWidth(body) - visibleWidth(trailer);
|
|
113
|
+
if (gap >= 2) lines[1] = body + padding(gap) + trailer;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
94
116
|
return lines;
|
|
95
117
|
}
|
|
96
118
|
|
|
@@ -113,6 +135,12 @@ export class Loader extends Text {
|
|
|
113
135
|
dispose() {
|
|
114
136
|
this.stop();
|
|
115
137
|
}
|
|
138
|
+
/** Install a lazy right-docked suffix for the spinner row (e.g. a styled
|
|
139
|
+
* session title). Re-evaluated every paint; dropped when the row leaves
|
|
140
|
+
* less than a two-cell gap. */
|
|
141
|
+
setTrailer(trailer: (() => string | undefined) | undefined): void {
|
|
142
|
+
this.#trailer = trailer;
|
|
143
|
+
}
|
|
116
144
|
|
|
117
145
|
setMessage(message: string) {
|
|
118
146
|
if (message === this.message) {
|
|
@@ -139,21 +167,33 @@ export class Loader extends Text {
|
|
|
139
167
|
this.#requestPaint();
|
|
140
168
|
}
|
|
141
169
|
|
|
142
|
-
const
|
|
170
|
+
const completedFrameCostMs = this.#ui?.lastFrameCostMs ?? 0;
|
|
171
|
+
const requestCostMs = performance.now() - startedAt;
|
|
143
172
|
if (this.#intervalId !== timer) return;
|
|
144
|
-
const cadenceDelayMs = Math.max(0, intervalMs -
|
|
145
|
-
// Idle for nine times the
|
|
146
|
-
// 10% CPU
|
|
147
|
-
const
|
|
173
|
+
const cadenceDelayMs = Math.max(0, intervalMs - requestCostMs);
|
|
174
|
+
// Idle for nine times the full frame cost to keep animation at or
|
|
175
|
+
// below 10% CPU even though requestComponentRender() only enqueues.
|
|
176
|
+
const boundedFrameCostMs = Math.min(
|
|
177
|
+
MAX_BACKPRESSURE_FRAME_COST_MS,
|
|
178
|
+
Math.max(completedFrameCostMs, requestCostMs),
|
|
179
|
+
);
|
|
180
|
+
const backpressureDelayMs = boundedFrameCostMs * RENDER_BACKPRESSURE_MULTIPLIER;
|
|
148
181
|
this.#scheduleTick(intervalMs, Math.max(cadenceDelayMs, backpressureDelayMs));
|
|
149
182
|
}, delayMs);
|
|
150
183
|
this.#intervalId = timer;
|
|
151
184
|
}
|
|
152
|
-
|
|
185
|
+
#resolveMessage(): string {
|
|
186
|
+
return typeof this.message === "function" ? this.message() : this.message;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Re-wrap the underlying Text only when its message or frame width changes.
|
|
190
|
+
* When {@link message} is a function it is re-evaluated on every spinner
|
|
191
|
+
* tick, so a dynamic label (e.g. a live countdown) advances in sync with
|
|
192
|
+
* the glyph instead of freezing on the initial value. */
|
|
153
193
|
#syncText(): boolean {
|
|
154
194
|
const layoutFrame = this.#layoutFrames[this.#currentFrame];
|
|
155
195
|
this.#layoutFrame = layoutFrame;
|
|
156
|
-
return this.setText(`${layoutFrame} ${this
|
|
196
|
+
return this.setText(`${layoutFrame} ${this.#resolveMessage()}`);
|
|
157
197
|
}
|
|
158
198
|
|
|
159
199
|
#requestPaint() {
|