@f5-sales-demo/pi-tui 19.51.2
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 +702 -0
- package/README.md +704 -0
- package/package.json +71 -0
- package/src/autocomplete.ts +780 -0
- package/src/bracketed-paste.ts +47 -0
- package/src/chord-dispatcher.ts +90 -0
- package/src/chord-parser.ts +66 -0
- package/src/components/box.ts +155 -0
- package/src/components/cancellable-loader.ts +40 -0
- package/src/components/editor.ts +2610 -0
- package/src/components/image.ts +90 -0
- package/src/components/input.ts +439 -0
- package/src/components/loader.ts +67 -0
- package/src/components/markdown.ts +940 -0
- package/src/components/select-list.ts +249 -0
- package/src/components/settings-list.ts +195 -0
- package/src/components/spacer.ts +28 -0
- package/src/components/tab-bar.ts +175 -0
- package/src/components/text.ts +110 -0
- package/src/components/truncated-text.ts +61 -0
- package/src/editor-component.ts +71 -0
- package/src/events.ts +32 -0
- package/src/fuzzy.ts +143 -0
- package/src/horizontal-split.ts +186 -0
- package/src/index.ts +64 -0
- package/src/keybindings.ts +340 -0
- package/src/keys.ts +408 -0
- package/src/kill-ring.ts +46 -0
- package/src/stdin-buffer.ts +481 -0
- package/src/symbols.ts +24 -0
- package/src/terminal-capabilities.ts +533 -0
- package/src/terminal.ts +687 -0
- package/src/ttyid.ts +66 -0
- package/src/tui.ts +1341 -0
- package/src/utils.ts +345 -0
|
@@ -0,0 +1,47 @@
|
|
|
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
|
+
/**
|
|
7
|
+
* Handles bracketed paste mode buffering for terminal input components.
|
|
8
|
+
*
|
|
9
|
+
* Bracketed paste mode wraps pasted content between start (\x1b[200~) and
|
|
10
|
+
* end (\x1b[201~) markers, which may arrive split across multiple chunks.
|
|
11
|
+
* This class buffers incoming data and assembles complete paste payloads.
|
|
12
|
+
*/
|
|
13
|
+
export class BracketedPasteHandler {
|
|
14
|
+
#buffer = "";
|
|
15
|
+
#active = false;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Process incoming terminal data for bracketed paste sequences.
|
|
19
|
+
*
|
|
20
|
+
* @returns `{ handled: false }` if the data contains no paste sequence and
|
|
21
|
+
* should be processed normally. `{ handled: true }` if the data was
|
|
22
|
+
* consumed by paste buffering — `pasteContent` is set when a complete
|
|
23
|
+
* paste has been assembled; omitted when still buffering.
|
|
24
|
+
*/
|
|
25
|
+
process(data: string): PasteResult {
|
|
26
|
+
if (data.includes(PASTE_START)) {
|
|
27
|
+
this.#active = true;
|
|
28
|
+
this.#buffer = "";
|
|
29
|
+
data = data.replace(PASTE_START, "");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (!this.#active) return { handled: false };
|
|
33
|
+
|
|
34
|
+
this.#buffer += data;
|
|
35
|
+
|
|
36
|
+
const endIndex = this.#buffer.indexOf(PASTE_END);
|
|
37
|
+
if (endIndex === -1) return { handled: true, remaining: "" };
|
|
38
|
+
|
|
39
|
+
const pasteContent = this.#buffer.substring(0, endIndex);
|
|
40
|
+
const remaining = this.#buffer.substring(endIndex + PASTE_END.length);
|
|
41
|
+
|
|
42
|
+
this.#buffer = "";
|
|
43
|
+
this.#active = false;
|
|
44
|
+
|
|
45
|
+
return { handled: true, pasteContent, remaining };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { ChordBinding } from "./chord-parser";
|
|
2
|
+
import type { KeyId } from "./keys";
|
|
3
|
+
|
|
4
|
+
export type ChordResult =
|
|
5
|
+
| { kind: "dispatched"; action: string }
|
|
6
|
+
| { kind: "pending"; leader: KeyId }
|
|
7
|
+
| { kind: "passthrough" }
|
|
8
|
+
| { kind: "abandoned" };
|
|
9
|
+
|
|
10
|
+
export interface ChordDispatcherCallbacks {
|
|
11
|
+
onPending?: (leader: KeyId) => void;
|
|
12
|
+
onCleared?: () => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface PendingState {
|
|
16
|
+
leader: KeyId;
|
|
17
|
+
timer: ReturnType<typeof setTimeout>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Two-key chord state machine. Stateless across keystrokes except for one
|
|
22
|
+
* optional "pending leader" slot + its timeout timer. Consumers feed KeyIds
|
|
23
|
+
* via feedKey() and act on the returned ChordResult.
|
|
24
|
+
*/
|
|
25
|
+
export class ChordDispatcher {
|
|
26
|
+
readonly #bindings: ChordBinding[];
|
|
27
|
+
readonly #timeoutMs: number;
|
|
28
|
+
readonly #callbacks: ChordDispatcherCallbacks;
|
|
29
|
+
#pending: PendingState | null = null;
|
|
30
|
+
|
|
31
|
+
constructor(bindings: ChordBinding[], timeoutMs: number, callbacks: ChordDispatcherCallbacks = {}) {
|
|
32
|
+
this.#bindings = bindings;
|
|
33
|
+
this.#timeoutMs = timeoutMs;
|
|
34
|
+
this.#callbacks = callbacks;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
feedKey(key: KeyId): ChordResult {
|
|
38
|
+
// Phase 1: pending chord is active — interpret this key as the 2nd stroke.
|
|
39
|
+
if (this.#pending) {
|
|
40
|
+
const match = this.#bindings.find(
|
|
41
|
+
b => b.sequence.length === 2 && b.sequence[0] === this.#pending!.leader && b.sequence[1] === key,
|
|
42
|
+
);
|
|
43
|
+
this.#clearPending();
|
|
44
|
+
if (match) return { kind: "dispatched", action: match.action };
|
|
45
|
+
return { kind: "abandoned" };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Phase 2: no pending chord.
|
|
49
|
+
// Single-stroke match wins first.
|
|
50
|
+
for (const b of this.#bindings) {
|
|
51
|
+
if (b.sequence.length === 1 && b.sequence[0] === key) {
|
|
52
|
+
return { kind: "dispatched", action: b.action };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// Chord leader?
|
|
56
|
+
const isLeader = this.#bindings.some(b => b.sequence.length === 2 && b.sequence[0] === key);
|
|
57
|
+
if (isLeader) {
|
|
58
|
+
this.#setPending(key);
|
|
59
|
+
return { kind: "pending", leader: key };
|
|
60
|
+
}
|
|
61
|
+
return { kind: "passthrough" };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
#setPending(leader: KeyId): void {
|
|
65
|
+
const timer = setTimeout(() => this.#timeoutClear(), this.#timeoutMs);
|
|
66
|
+
this.#pending = { leader, timer };
|
|
67
|
+
this.#callbacks.onPending?.(leader);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
#clearPending(): void {
|
|
71
|
+
if (!this.#pending) return;
|
|
72
|
+
clearTimeout(this.#pending.timer);
|
|
73
|
+
this.#pending = null;
|
|
74
|
+
this.#callbacks.onCleared?.();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
#timeoutClear(): void {
|
|
78
|
+
// Timer fired; timer handle consumed by runtime.
|
|
79
|
+
if (!this.#pending) return;
|
|
80
|
+
this.#pending = null;
|
|
81
|
+
this.#callbacks.onCleared?.();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
dispose(): void {
|
|
85
|
+
if (this.#pending) {
|
|
86
|
+
clearTimeout(this.#pending.timer);
|
|
87
|
+
this.#pending = null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { KeyId } from "./keys";
|
|
2
|
+
|
|
3
|
+
export interface ParsedBinding {
|
|
4
|
+
sequence: KeyId[];
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface BindingParseError {
|
|
8
|
+
readonly message: string;
|
|
9
|
+
readonly input: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type ParseResult = { ok: true; sequence: KeyId[] } | { ok: false; error: BindingParseError };
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Parse a binding string into a keystroke sequence.
|
|
16
|
+
*
|
|
17
|
+
* - Whitespace separates keystrokes in a chord.
|
|
18
|
+
* - One keystroke = single-stroke binding; two keystrokes = chord.
|
|
19
|
+
* - v1 rejects 3+ keystrokes.
|
|
20
|
+
* - Individual keystroke validation (known key names + modifier combos) is
|
|
21
|
+
* deferred to KeyId at type check time and at runtime via parseKey on input.
|
|
22
|
+
*/
|
|
23
|
+
export function parseBinding(input: string): ParseResult {
|
|
24
|
+
const trimmed = input.trim();
|
|
25
|
+
if (trimmed.length === 0) {
|
|
26
|
+
return { ok: false, error: { message: "empty binding", input } };
|
|
27
|
+
}
|
|
28
|
+
const tokens = trimmed.split(/\s+/);
|
|
29
|
+
if (tokens.length > 2) {
|
|
30
|
+
return {
|
|
31
|
+
ok: false,
|
|
32
|
+
error: {
|
|
33
|
+
message: `chord bindings support at most 2 keystrokes (got ${tokens.length})`,
|
|
34
|
+
input,
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
return { ok: true, sequence: tokens as KeyId[] };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ChordBinding {
|
|
42
|
+
action: string;
|
|
43
|
+
sequence: KeyId[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type BindingsInput = Record<string, string | string[]>;
|
|
47
|
+
|
|
48
|
+
export type ParseBindingsResult = { ok: true; bindings: ChordBinding[] } | { ok: false; error: BindingParseError };
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Parse a map of action → binding string(s) into a flat list of ChordBinding.
|
|
52
|
+
* Array values expand into multiple ChordBindings for the same action.
|
|
53
|
+
* Stops at the first parse error.
|
|
54
|
+
*/
|
|
55
|
+
export function parseBindings(input: BindingsInput): ParseBindingsResult {
|
|
56
|
+
const bindings: ChordBinding[] = [];
|
|
57
|
+
for (const [action, value] of Object.entries(input)) {
|
|
58
|
+
const list = Array.isArray(value) ? value : [value];
|
|
59
|
+
for (const str of list) {
|
|
60
|
+
const parsed = parseBinding(str);
|
|
61
|
+
if (!parsed.ok) return parsed;
|
|
62
|
+
bindings.push({ action, sequence: parsed.sequence });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return { ok: true, bindings };
|
|
66
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import type { Component } from "../tui";
|
|
2
|
+
import { applyBackgroundToLine, padding, visibleWidth } from "../utils";
|
|
3
|
+
|
|
4
|
+
type Cache = {
|
|
5
|
+
key: bigint | number;
|
|
6
|
+
result: string[];
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Box component - a container that applies padding and background to all children
|
|
11
|
+
*/
|
|
12
|
+
export class Box implements Component {
|
|
13
|
+
children: Component[] = [];
|
|
14
|
+
#paddingX: number;
|
|
15
|
+
#paddingY: number;
|
|
16
|
+
#bgFn?: (text: string) => string;
|
|
17
|
+
|
|
18
|
+
// Cache for rendered output
|
|
19
|
+
#cached?: Cache;
|
|
20
|
+
|
|
21
|
+
constructor(paddingX = 1, paddingY = 1, bgFn?: (text: string) => string) {
|
|
22
|
+
this.#paddingX = paddingX;
|
|
23
|
+
this.#paddingY = paddingY;
|
|
24
|
+
this.#bgFn = bgFn;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
addChild(component: Component): void {
|
|
28
|
+
this.children.push(component);
|
|
29
|
+
this.#invalidateCache();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
removeChild(component: Component): void {
|
|
33
|
+
const index = this.children.indexOf(component);
|
|
34
|
+
if (index !== -1) {
|
|
35
|
+
this.children.splice(index, 1);
|
|
36
|
+
this.#invalidateCache();
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
clear(): void {
|
|
41
|
+
this.children = [];
|
|
42
|
+
this.#invalidateCache();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
setBgFn(bgFn?: (text: string) => string): void {
|
|
46
|
+
this.#bgFn = bgFn;
|
|
47
|
+
// Don't invalidate here - we'll detect bgFn changes by sampling output
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
setPaddingX(paddingX: number): void {
|
|
51
|
+
if (this.#paddingX !== paddingX) {
|
|
52
|
+
this.#paddingX = paddingX;
|
|
53
|
+
this.#invalidateCache();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
#invalidateCache(): void {
|
|
58
|
+
this.#cached = undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
static #tmp = new Uint32Array(2);
|
|
62
|
+
#computeCacheKey(width: number, childLines: string[], bgSample: string | undefined): bigint | number {
|
|
63
|
+
Box.#tmp[0] = width;
|
|
64
|
+
Box.#tmp[1] = childLines.length;
|
|
65
|
+
let h = Bun.hash(Box.#tmp);
|
|
66
|
+
for (const line of childLines) {
|
|
67
|
+
h = Bun.hash(line, h);
|
|
68
|
+
}
|
|
69
|
+
if (bgSample) {
|
|
70
|
+
h = Bun.hash(bgSample, h);
|
|
71
|
+
}
|
|
72
|
+
return h;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
#matchCache(cacheKey: bigint | number): boolean {
|
|
76
|
+
return this.#cached?.key === cacheKey;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
invalidate(): void {
|
|
80
|
+
this.#invalidateCache();
|
|
81
|
+
for (const child of this.children) {
|
|
82
|
+
child.invalidate?.();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
render(width: number): string[] {
|
|
87
|
+
if (this.children.length === 0) {
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const contentWidth = Math.max(1, width - this.#paddingX * 2);
|
|
92
|
+
const leftPad = padding(this.#paddingX);
|
|
93
|
+
|
|
94
|
+
// Render all children
|
|
95
|
+
const childLines: string[] = [];
|
|
96
|
+
for (const child of this.children) {
|
|
97
|
+
try {
|
|
98
|
+
const lines = child.render(contentWidth);
|
|
99
|
+
for (const line of lines) {
|
|
100
|
+
childLines.push(leftPad + line);
|
|
101
|
+
}
|
|
102
|
+
} catch {
|
|
103
|
+
// Swallow render errors from individual children to prevent process crash
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (childLines.length === 0) {
|
|
108
|
+
return [];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Check if bgFn output changed by sampling
|
|
112
|
+
const bgSample = this.#bgFn ? this.#bgFn("test") : undefined;
|
|
113
|
+
|
|
114
|
+
const cacheKey = this.#computeCacheKey(width, childLines, bgSample);
|
|
115
|
+
|
|
116
|
+
// Check cache validity
|
|
117
|
+
if (this.#matchCache(cacheKey)) {
|
|
118
|
+
return this.#cached!.result;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Apply background and padding
|
|
122
|
+
const result: string[] = [];
|
|
123
|
+
|
|
124
|
+
// Top padding
|
|
125
|
+
for (let i = 0; i < this.#paddingY; i++) {
|
|
126
|
+
result.push(this.#applyBg("", width));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Content
|
|
130
|
+
for (const line of childLines) {
|
|
131
|
+
result.push(this.#applyBg(line, width));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Bottom padding
|
|
135
|
+
for (let i = 0; i < this.#paddingY; i++) {
|
|
136
|
+
result.push(this.#applyBg("", width));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Update cache
|
|
140
|
+
this.#cached = { key: cacheKey, result };
|
|
141
|
+
|
|
142
|
+
return result;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
#applyBg(line: string, width: number): string {
|
|
146
|
+
const visLen = visibleWidth(line);
|
|
147
|
+
const padNeeded = Math.max(0, width - visLen);
|
|
148
|
+
const padded = line + padding(padNeeded);
|
|
149
|
+
|
|
150
|
+
if (this.#bgFn) {
|
|
151
|
+
return applyBackgroundToLine(padded, width, this.#bgFn);
|
|
152
|
+
}
|
|
153
|
+
return padded;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -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
|
+
dispose(): void {
|
|
38
|
+
this.stop();
|
|
39
|
+
}
|
|
40
|
+
}
|