@sayknow-cli/tui 0.4.6 → 0.5.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.
- package/README.md +8 -0
- package/dist/types/components/sayknow-pet.d.ts +0 -5
- package/dist/types/components/select-list.d.ts +1 -0
- package/dist/types/keys.d.ts +17 -4
- package/dist/types/metrics.d.ts +3 -0
- package/dist/types/terminal-capabilities.d.ts +3 -0
- package/dist/types/tui.d.ts +49 -6
- package/package.json +3 -3
- package/src/components/sayknow-pet.ts +1 -6
- package/src/components/select-list.ts +16 -0
- package/src/keybindings.ts +10 -1
- package/src/keys.ts +121 -3
- package/src/metrics.ts +15 -0
- package/src/terminal-capabilities.ts +42 -2
- package/src/terminal.ts +12 -7
- package/src/tui.ts +885 -182
package/README.md
CHANGED
|
@@ -56,6 +56,14 @@ tui.requestRender(); // Request a re-render
|
|
|
56
56
|
tui.onDebug = () => console.log("Debug triggered");
|
|
57
57
|
```
|
|
58
58
|
|
|
59
|
+
### Manual viewport and pinned suffix
|
|
60
|
+
|
|
61
|
+
`setBottomPinnedComponent(component)` marks a direct-child boundary. During manual viewport ownership, that child and all later direct children remain fixed at the bottom while rows before it form the scrollable lane; this does not require an output source. `scrollViewportPages()` moves by the lane height minus one; `scrollViewportBy()` supports smaller row steps and rejects non-finite deltas.
|
|
62
|
+
|
|
63
|
+
`setViewportOutputSource({ identity, revision })` reports semantic output changes without coupling the TUI to message types. A same-identity revision advance while manually scrolled displays the exact notice `New output — type to follow`; following live or changing/removing the identity clears it, while a stale same-identity revision rollback does not.
|
|
64
|
+
|
|
65
|
+
Pinned rows and the notice are excluded from transcript mouse-selection coordinates. When the terminal is too short, the focused direct-child suffix component is retained before decorative or lower-priority suffix rows.
|
|
66
|
+
|
|
59
67
|
### Component Interface
|
|
60
68
|
|
|
61
69
|
All components implement:
|
|
@@ -124,10 +124,5 @@ export declare function buildSayknowPixelFrames(options: {
|
|
|
124
124
|
kittyImageId?: number;
|
|
125
125
|
/** Color skin for the sprite palette (default "red"). */
|
|
126
126
|
skin?: PetSkinId;
|
|
127
|
-
/**
|
|
128
|
-
* Optional wrapper applied to each encoded sixel frame (e.g. tmux DCS
|
|
129
|
-
* passthrough). Identity when omitted. Never applied to kitty frames.
|
|
130
|
-
*/
|
|
131
|
-
wrapSixel?: (frame: string) => string;
|
|
132
127
|
}): SayknowPixelFrames;
|
|
133
128
|
export {};
|
|
@@ -45,6 +45,7 @@ export declare class SelectList implements Component {
|
|
|
45
45
|
constructor(items: ReadonlyArray<SelectItem>, maxVisible: number, theme: SelectListTheme, layout?: SelectListLayoutOptions);
|
|
46
46
|
setFilter(filter: string): void;
|
|
47
47
|
setSelectedIndex(index: number): void;
|
|
48
|
+
handleNavigation(action: "tui.select.up" | "tui.select.down" | "tui.select.pageUp" | "tui.select.pageDown"): void;
|
|
48
49
|
invalidate(): void;
|
|
49
50
|
render(width: number): string[];
|
|
50
51
|
handleInput(keyData: string): void;
|
package/dist/types/keys.d.ts
CHANGED
|
@@ -41,11 +41,11 @@ export declare function setKittyProtocolActive(active: boolean): void;
|
|
|
41
41
|
export declare function isKittyProtocolActive(): boolean;
|
|
42
42
|
type Letter = "a" | "b" | "c" | "d" | "e" | "f" | "g" | "h" | "i" | "j" | "k" | "l" | "m" | "n" | "o" | "p" | "q" | "r" | "s" | "t" | "u" | "v" | "w" | "x" | "y" | "z";
|
|
43
43
|
type Digit = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9";
|
|
44
|
-
type SymbolKey = "`" | "-" | "=" | "[" | "]" | "\\" | ";" | "'" | "," | "." | "/" | "!" | "@" | "#" | "$" | "%" | "^" | "&" | "*" | "(" | ")" | "_" | "+" | "|" | "~" | "{" | "}" | ":" | "<" | ">" | "?";
|
|
44
|
+
type SymbolKey = "`" | "-" | "=" | "[" | "]" | "\\" | ";" | "'" | '"' | "," | "." | "/" | "!" | "@" | "#" | "$" | "%" | "^" | "&" | "*" | "(" | ")" | "_" | "+" | "|" | "~" | "{" | "}" | ":" | "<" | ">" | "?";
|
|
45
45
|
type SpecialKey = "escape" | "esc" | "enter" | "return" | "tab" | "space" | "backspace" | "delete" | "insert" | "clear" | "home" | "end" | "pageUp" | "pageDown" | "up" | "down" | "left" | "right" | "f1" | "f2" | "f3" | "f4" | "f5" | "f6" | "f7" | "f8" | "f9" | "f10" | "f11" | "f12";
|
|
46
|
-
type BaseKey = Letter | Digit | SymbolKey | SpecialKey;
|
|
47
|
-
type
|
|
48
|
-
type ModifiedKeyId<Key extends string, RemainingModifiers extends
|
|
46
|
+
export type BaseKey = Letter | Digit | SymbolKey | SpecialKey;
|
|
47
|
+
export type KeyModifier = "ctrl" | "shift" | "alt" | "super";
|
|
48
|
+
type ModifiedKeyId<Key extends string, RemainingModifiers extends KeyModifier = KeyModifier> = {
|
|
49
49
|
[M in RemainingModifiers]: `${M}+${Key}` | `${M}+${ModifiedKeyId<Key, Exclude<RemainingModifiers, M>>}`;
|
|
50
50
|
}[RemainingModifiers];
|
|
51
51
|
/**
|
|
@@ -53,6 +53,19 @@ type ModifiedKeyId<Key extends string, RemainingModifiers extends ModifierName =
|
|
|
53
53
|
* Provides autocomplete and catches typos at compile time.
|
|
54
54
|
*/
|
|
55
55
|
export type KeyId = BaseKey | ModifiedKeyId<BaseKey>;
|
|
56
|
+
export interface ParsedKeyId {
|
|
57
|
+
keyId: KeyId;
|
|
58
|
+
modifiers: KeyModifier[];
|
|
59
|
+
baseKey: BaseKey;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Parse a case-insensitive key identifier into normalized dispatch parts.
|
|
63
|
+
* The legacy `plus` base-key alias normalizes to `+`; the trailing `+` in
|
|
64
|
+
* values such as `ctrl++` is the literal plus base.
|
|
65
|
+
*/
|
|
66
|
+
export declare function parseKeyId(value: string): ParsedKeyId | undefined;
|
|
67
|
+
/** Whether a value is a valid canonical key identifier. */
|
|
68
|
+
export declare function isKeyId(value: string): value is KeyId;
|
|
56
69
|
/**
|
|
57
70
|
* Typed helper for constructing key identifiers with autocomplete.
|
|
58
71
|
*
|
package/dist/types/metrics.d.ts
CHANGED
|
@@ -48,6 +48,7 @@ export interface RenderMetricsSnapshot {
|
|
|
48
48
|
timerGauges: Record<string, number>;
|
|
49
49
|
helperStats: Record<string, HelperStat>;
|
|
50
50
|
lineCounts: Record<string, LineCountGauge>;
|
|
51
|
+
structuralCounters: Record<string, number>;
|
|
51
52
|
}
|
|
52
53
|
export declare class RenderMetrics {
|
|
53
54
|
#private;
|
|
@@ -73,6 +74,8 @@ export declare class RenderMetrics {
|
|
|
73
74
|
recordHelper(name: string, durationMs: number): void;
|
|
74
75
|
/** Record a per-render line-count gauge (e.g. "rendered", "normalized", "diffed"). */
|
|
75
76
|
recordLineCount(name: string, value: number): void;
|
|
77
|
+
/** Accumulate deterministic structural render work without retaining frame data. */
|
|
78
|
+
recordStructuralCounter(name: string, value?: number): void;
|
|
76
79
|
/**
|
|
77
80
|
* Force a GC when the runtime exposes one and sample RSS as the post-run
|
|
78
81
|
* "return" value used by the memory-leak gate. Callers should drop large
|
|
@@ -78,6 +78,9 @@ export declare function isUnderTmux(env?: NodeJS.ProcessEnv): boolean;
|
|
|
78
78
|
* tmux, so non-multiplexed, screen, and zellij paths are untouched.
|
|
79
79
|
*/
|
|
80
80
|
export declare function wrapTmuxPassthrough(payload: string, env?: NodeJS.ProcessEnv): string;
|
|
81
|
+
export declare function tmuxOwnsSixel(env?: NodeJS.ProcessEnv): boolean;
|
|
82
|
+
/** Testing seam: drop the cached tmux sixel-ownership answer. */
|
|
83
|
+
export declare function resetTmuxSixelOwnershipCache(): void;
|
|
81
84
|
/**
|
|
82
85
|
* Returns true when running in Windows Terminal with known SIXEL support.
|
|
83
86
|
*
|
package/dist/types/tui.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { Terminal } from "./terminal";
|
|
2
2
|
import { visibleWidth } from "./utils";
|
|
3
|
+
/** Discrete mouse-wheel notch size in terminal rows (xterm/less-style). */
|
|
4
|
+
export declare const DEFAULT_WHEEL_LINES = 3;
|
|
3
5
|
type InputListenerResult = {
|
|
4
6
|
consume?: boolean;
|
|
5
7
|
data?: string;
|
|
@@ -9,7 +11,7 @@ type InputListener = (data: string) => InputListenerResult;
|
|
|
9
11
|
* Component interface - all components must implement this
|
|
10
12
|
*/
|
|
11
13
|
export type MouseEvent = {
|
|
12
|
-
kind: "wheel" | "click";
|
|
14
|
+
kind: "wheel" | "click" | "drag" | "release";
|
|
13
15
|
direction?: -1 | 1;
|
|
14
16
|
button?: 0;
|
|
15
17
|
/** Terminal cell coordinates, one-based. */
|
|
@@ -27,7 +29,7 @@ type OverlayMouseBounds = {
|
|
|
27
29
|
termWidth: number;
|
|
28
30
|
termHeight: number;
|
|
29
31
|
};
|
|
30
|
-
/** Parse xterm SGR mouse reports
|
|
32
|
+
/** Parse xterm SGR mouse reports for wheel, left-click, drag, and release events. */
|
|
31
33
|
export declare function parseSgrMouseEvent(data: string): MouseEvent | undefined;
|
|
32
34
|
export interface Component {
|
|
33
35
|
/**
|
|
@@ -99,6 +101,11 @@ export interface ViewportAnchorProvider extends Component {
|
|
|
99
101
|
export interface ViewportAnchorSource {
|
|
100
102
|
id: ViewportAnchorId;
|
|
101
103
|
}
|
|
104
|
+
/** Identity and monotonic revision of the logical output producer. */
|
|
105
|
+
export type ViewportOutputSource = {
|
|
106
|
+
identity: string;
|
|
107
|
+
revision: bigint;
|
|
108
|
+
};
|
|
102
109
|
export interface ViewportAnchorSourceRenderer extends Component {
|
|
103
110
|
renderWithViewportAnchorSource(width: number, source: ViewportAnchorSource): ViewportAnchorRender;
|
|
104
111
|
}
|
|
@@ -125,10 +132,11 @@ export type SizeValue = number | `${number}%`;
|
|
|
125
132
|
* Startup sixel capability probe policy (pure; exported for tests):
|
|
126
133
|
* - Never probe when PI_FORCE_IMAGE_PROTOCOL is set — an explicit
|
|
127
134
|
* configuration (including "off") is authoritative.
|
|
128
|
-
* -
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
* evidence.
|
|
135
|
+
* - Never probe inside a terminal multiplexer: tmux advertises DA1 ";4"
|
|
136
|
+
* whenever it was compiled with sixel support, regardless of whether the
|
|
137
|
+
* attached client terminal can render sixel, so a positive reply is not
|
|
138
|
+
* end-to-end evidence. Graphics under a multiplexer are strictly opt-in
|
|
139
|
+
* via PI_FORCE_IMAGE_PROTOCOL=sixel.
|
|
132
140
|
* - Probe Windows Terminal (>=1.22 renders sixel but exposes no env marker).
|
|
133
141
|
*/
|
|
134
142
|
export declare function shouldProbeSixelCapability(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): boolean;
|
|
@@ -229,6 +237,14 @@ export declare class TUI extends Container {
|
|
|
229
237
|
}[];
|
|
230
238
|
constructor(terminal: Terminal, showHardwareCursor?: boolean, options?: {
|
|
231
239
|
enableMouse?: boolean;
|
|
240
|
+
copySelection?: (text: string) => void | Promise<void>;
|
|
241
|
+
/**
|
|
242
|
+
* Trailing debounce for the settled width repair, in ms. `0` disables the
|
|
243
|
+
* settled repair (deterministic harnesses need this — a wall-clock-timed
|
|
244
|
+
* full replay lands at nondeterministic logical positions). Defaults to
|
|
245
|
+
* `SKC_TUI_WIDTH_SETTLE_MS` / `PI_TUI_WIDTH_SETTLE_MS`, then 1000.
|
|
246
|
+
*/
|
|
247
|
+
widthSettleMs?: number;
|
|
232
248
|
});
|
|
233
249
|
dispose(): void;
|
|
234
250
|
get fullRedraws(): number;
|
|
@@ -242,7 +258,11 @@ export declare class TUI extends Container {
|
|
|
242
258
|
*/
|
|
243
259
|
setClearOnShrink(enabled: boolean): void;
|
|
244
260
|
setFocus(component: Component | null): void;
|
|
261
|
+
removeChild(component: Component): void;
|
|
262
|
+
clear(): void;
|
|
245
263
|
setBottomPinnedComponent(component: Component | null): void;
|
|
264
|
+
/** Report the logical output producer revision without coupling TUI to message types. */
|
|
265
|
+
setViewportOutputSource(source: ViewportOutputSource | null): void;
|
|
246
266
|
/** Register the direct child whose rows are eligible for semantic viewport anchoring. */
|
|
247
267
|
setViewportAnchorComponent(component: Component | null): void;
|
|
248
268
|
/** Clear manual viewport ownership before replacing the transcript identity namespace. */
|
|
@@ -251,6 +271,10 @@ export declare class TUI extends Container {
|
|
|
251
271
|
prepareViewportAnchorForTranscriptRebuild(): void;
|
|
252
272
|
/** Reveal a semantic viewport anchor without changing the rendered content width. */
|
|
253
273
|
revealViewportAnchor(id: ViewportAnchorId, alignment: "top" | "center" | "bottom"): boolean;
|
|
274
|
+
scrollViewportBy(deltaRows: number, options?: {
|
|
275
|
+
/** edge: PageUp/PageDown pin; stable: preserve/center pin for fine wheel motion */
|
|
276
|
+
pin?: "edge" | "stable";
|
|
277
|
+
}): boolean;
|
|
254
278
|
scrollViewportPages(direction: -1 | 1): boolean;
|
|
255
279
|
followLiveViewport(): boolean;
|
|
256
280
|
/**
|
|
@@ -264,6 +288,15 @@ export declare class TUI extends Container {
|
|
|
264
288
|
hasOverlay(): boolean;
|
|
265
289
|
invalidate(): void;
|
|
266
290
|
start(): void;
|
|
291
|
+
/**
|
|
292
|
+
* Wait for a specific render request generation to be written successfully.
|
|
293
|
+
*
|
|
294
|
+
* Render requests are coalesced, so committing a newer generation also commits
|
|
295
|
+
* every older generation represented by that frame. A stopped or unavailable
|
|
296
|
+
* terminal resolves waiters false so UI callers can fail open instead of
|
|
297
|
+
* holding a session operation behind a dead renderer.
|
|
298
|
+
*/
|
|
299
|
+
waitForRenderCommit(generation: number, timeoutMs?: number): Promise<boolean>;
|
|
267
300
|
get terminalAvailable(): boolean;
|
|
268
301
|
addInputListener(listener: InputListener): () => void;
|
|
269
302
|
removeInputListener(listener: InputListener): void;
|
|
@@ -280,9 +313,19 @@ export declare class TUI extends Container {
|
|
|
280
313
|
* the transcript top during streaming redraws, so viewport-repaint sessions
|
|
281
314
|
* keep force off and let `#doRender` repaint only the live viewport. Set
|
|
282
315
|
* `PI_TUI_LEGACY_MULTIPLEXER_FULL_RENDER=1` to restore the legacy tmux redraw.
|
|
316
|
+
*
|
|
317
|
+
* Spurious resize events (SIGWINCH with unchanged dimensions — iTerm2 tab
|
|
318
|
+
* switches and window focus changes, the self-sent SIGWINCH after resume)
|
|
319
|
+
* must not force either: on hosts still using the `fullRender` path (legacy
|
|
320
|
+
* multiplexer opt-in, non-process terminals) the forced redraw clears
|
|
321
|
+
* scrollback (`2J`/`H`/`3J`) and replays the whole transcript, which can
|
|
322
|
+
* park the native viewport at the transcript top. Only force when the grid
|
|
323
|
+
* size actually changed since the last committed frame; a plain diff render
|
|
324
|
+
* is a no-op otherwise.
|
|
283
325
|
*/
|
|
284
326
|
requestResizeRender(): void;
|
|
285
327
|
requestRender(force?: boolean, source?: string): void;
|
|
328
|
+
requestRenderWithGeneration(force?: boolean, source?: string): number;
|
|
286
329
|
getLineRenderCacheStats(): {
|
|
287
330
|
normalizationSize: number;
|
|
288
331
|
truncationSize: number;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@sayknow-cli/tui",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.5.0",
|
|
5
5
|
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
|
6
6
|
"homepage": "https://sayknow-cli.com",
|
|
7
7
|
"author": "jaybeyond",
|
|
@@ -38,8 +38,8 @@
|
|
|
38
38
|
"fmt": "biome format --write ."
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"@sayknow-cli/natives": "0.
|
|
42
|
-
"@sayknow-cli/utils": "0.
|
|
41
|
+
"@sayknow-cli/natives": "0.5.0",
|
|
42
|
+
"@sayknow-cli/utils": "0.5.0",
|
|
43
43
|
"lru-cache": "11.3.6",
|
|
44
44
|
"marked": "^18.0.3"
|
|
45
45
|
},
|
|
@@ -392,11 +392,6 @@ export function buildSayknowPixelFrames(options: {
|
|
|
392
392
|
kittyImageId?: number;
|
|
393
393
|
/** Color skin for the sprite palette (default "red"). */
|
|
394
394
|
skin?: PetSkinId;
|
|
395
|
-
/**
|
|
396
|
-
* Optional wrapper applied to each encoded sixel frame (e.g. tmux DCS
|
|
397
|
-
* passthrough). Identity when omitted. Never applied to kitty frames.
|
|
398
|
-
*/
|
|
399
|
-
wrapSixel?: (frame: string) => string;
|
|
400
395
|
}): SayknowPixelFrames {
|
|
401
396
|
const targetRows = options.targetRows ?? 2;
|
|
402
397
|
const gridSize = 16;
|
|
@@ -421,7 +416,7 @@ export function buildSayknowPixelFrames(options: {
|
|
|
421
416
|
for (const name of Object.keys(PIXEL_GRIDS) as SayknowPixelFrameName[]) {
|
|
422
417
|
frames[name] =
|
|
423
418
|
options.protocol === "sixel"
|
|
424
|
-
?
|
|
419
|
+
? encodeGridSixel(PIXEL_GRIDS[name], scale, topPaddingPx, palette)
|
|
425
420
|
: encodeGridKitty(
|
|
426
421
|
PIXEL_GRIDS[name],
|
|
427
422
|
scale,
|
|
@@ -92,6 +92,22 @@ export class SelectList implements Component {
|
|
|
92
92
|
this.#findEnabledIndex(clamped, 1, false) ?? this.#findEnabledIndex(clamped, -1, false) ?? -1;
|
|
93
93
|
this.#syncViewportToIndex(this.#selectedIndex >= 0 ? this.#selectedIndex : clamped);
|
|
94
94
|
}
|
|
95
|
+
handleNavigation(action: "tui.select.up" | "tui.select.down" | "tui.select.pageUp" | "tui.select.pageDown"): void {
|
|
96
|
+
switch (action) {
|
|
97
|
+
case "tui.select.up":
|
|
98
|
+
this.#moveSelection(-1);
|
|
99
|
+
break;
|
|
100
|
+
case "tui.select.down":
|
|
101
|
+
this.#moveSelection(1);
|
|
102
|
+
break;
|
|
103
|
+
case "tui.select.pageUp":
|
|
104
|
+
this.#movePage(-1);
|
|
105
|
+
break;
|
|
106
|
+
case "tui.select.pageDown":
|
|
107
|
+
this.#movePage(1);
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
95
111
|
|
|
96
112
|
invalidate(): void {
|
|
97
113
|
// No cached state to invalidate currently
|
package/src/keybindings.ts
CHANGED
|
@@ -170,7 +170,16 @@ const SHIFTED_SYMBOL_KEYS = new Set<string>([
|
|
|
170
170
|
"~",
|
|
171
171
|
]);
|
|
172
172
|
|
|
173
|
-
const normalizeKeyId = (key: KeyId): KeyId =>
|
|
173
|
+
const normalizeKeyId = (key: KeyId): KeyId => {
|
|
174
|
+
const normalized = key.toLowerCase();
|
|
175
|
+
if (normalized.endsWith("pageup")) {
|
|
176
|
+
return `${normalized.slice(0, -6)}pageUp` as KeyId;
|
|
177
|
+
}
|
|
178
|
+
if (normalized.endsWith("pagedown")) {
|
|
179
|
+
return `${normalized.slice(0, -8)}pageDown` as KeyId;
|
|
180
|
+
}
|
|
181
|
+
return normalized as KeyId;
|
|
182
|
+
};
|
|
174
183
|
|
|
175
184
|
function normalizeKeys(keys: KeyId | KeyId[] | undefined): KeyId[] {
|
|
176
185
|
if (keys === undefined) return [];
|
package/src/keys.ts
CHANGED
|
@@ -117,6 +117,7 @@ type SymbolKey =
|
|
|
117
117
|
| "\\"
|
|
118
118
|
| ";"
|
|
119
119
|
| "'"
|
|
120
|
+
| '"'
|
|
120
121
|
| ","
|
|
121
122
|
| "."
|
|
122
123
|
| "/"
|
|
@@ -173,10 +174,10 @@ type SpecialKey =
|
|
|
173
174
|
| "f11"
|
|
174
175
|
| "f12";
|
|
175
176
|
|
|
176
|
-
type BaseKey = Letter | Digit | SymbolKey | SpecialKey;
|
|
177
|
-
type
|
|
177
|
+
export type BaseKey = Letter | Digit | SymbolKey | SpecialKey;
|
|
178
|
+
export type KeyModifier = "ctrl" | "shift" | "alt" | "super";
|
|
178
179
|
|
|
179
|
-
type ModifiedKeyId<Key extends string, RemainingModifiers extends
|
|
180
|
+
type ModifiedKeyId<Key extends string, RemainingModifiers extends KeyModifier = KeyModifier> = {
|
|
180
181
|
[M in RemainingModifiers]: `${M}+${Key}` | `${M}+${ModifiedKeyId<Key, Exclude<RemainingModifiers, M>>}`;
|
|
181
182
|
}[RemainingModifiers];
|
|
182
183
|
|
|
@@ -186,6 +187,123 @@ type ModifiedKeyId<Key extends string, RemainingModifiers extends ModifierName =
|
|
|
186
187
|
*/
|
|
187
188
|
export type KeyId = BaseKey | ModifiedKeyId<BaseKey>;
|
|
188
189
|
|
|
190
|
+
export interface ParsedKeyId {
|
|
191
|
+
keyId: KeyId;
|
|
192
|
+
modifiers: KeyModifier[];
|
|
193
|
+
baseKey: BaseKey;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const BASE_KEYS = new Set<string>([
|
|
197
|
+
..."abcdefghijklmnopqrstuvwxyz",
|
|
198
|
+
..."0123456789",
|
|
199
|
+
"`",
|
|
200
|
+
"-",
|
|
201
|
+
"=",
|
|
202
|
+
"[",
|
|
203
|
+
"]",
|
|
204
|
+
"\\",
|
|
205
|
+
";",
|
|
206
|
+
"'",
|
|
207
|
+
'"',
|
|
208
|
+
",",
|
|
209
|
+
".",
|
|
210
|
+
"/",
|
|
211
|
+
"!",
|
|
212
|
+
"@",
|
|
213
|
+
"#",
|
|
214
|
+
"$",
|
|
215
|
+
"%",
|
|
216
|
+
"^",
|
|
217
|
+
"&",
|
|
218
|
+
"*",
|
|
219
|
+
"(",
|
|
220
|
+
")",
|
|
221
|
+
"_",
|
|
222
|
+
"+",
|
|
223
|
+
"|",
|
|
224
|
+
"~",
|
|
225
|
+
"{",
|
|
226
|
+
"}",
|
|
227
|
+
":",
|
|
228
|
+
"<",
|
|
229
|
+
">",
|
|
230
|
+
"?",
|
|
231
|
+
"escape",
|
|
232
|
+
"esc",
|
|
233
|
+
"enter",
|
|
234
|
+
"return",
|
|
235
|
+
"tab",
|
|
236
|
+
"space",
|
|
237
|
+
"backspace",
|
|
238
|
+
"delete",
|
|
239
|
+
"insert",
|
|
240
|
+
"clear",
|
|
241
|
+
"home",
|
|
242
|
+
"end",
|
|
243
|
+
"pageup",
|
|
244
|
+
"pagedown",
|
|
245
|
+
"up",
|
|
246
|
+
"down",
|
|
247
|
+
"left",
|
|
248
|
+
"right",
|
|
249
|
+
"f1",
|
|
250
|
+
"f2",
|
|
251
|
+
"f3",
|
|
252
|
+
"f4",
|
|
253
|
+
"f5",
|
|
254
|
+
"f6",
|
|
255
|
+
"f7",
|
|
256
|
+
"f8",
|
|
257
|
+
"f9",
|
|
258
|
+
"f10",
|
|
259
|
+
"f11",
|
|
260
|
+
"f12",
|
|
261
|
+
]);
|
|
262
|
+
|
|
263
|
+
const KEY_MODIFIERS: readonly KeyModifier[] = ["ctrl", "alt", "shift", "super"];
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Parse a case-insensitive key identifier into normalized dispatch parts.
|
|
267
|
+
* The legacy `plus` base-key alias normalizes to `+`; the trailing `+` in
|
|
268
|
+
* values such as `ctrl++` is the literal plus base.
|
|
269
|
+
*/
|
|
270
|
+
export function parseKeyId(value: string): ParsedKeyId | undefined {
|
|
271
|
+
if (hasControlChars(value) || value.length === 0) return undefined;
|
|
272
|
+
|
|
273
|
+
const lowerCaseValue = value
|
|
274
|
+
.trim()
|
|
275
|
+
.toLowerCase()
|
|
276
|
+
.replace(/\s*\+\s*/g, "+");
|
|
277
|
+
const normalized = lowerCaseValue === "plus" ? "+" : lowerCaseValue.replace(/\+plus$/, "++");
|
|
278
|
+
const baseKey = normalized.endsWith("+") ? "+" : normalized.split("+").pop();
|
|
279
|
+
if (!baseKey || !BASE_KEYS.has(baseKey)) return undefined;
|
|
280
|
+
|
|
281
|
+
const modifierSource = normalized.slice(0, normalized.length - baseKey.length);
|
|
282
|
+
const modifierParts =
|
|
283
|
+
modifierSource.length === 0
|
|
284
|
+
? []
|
|
285
|
+
: modifierSource
|
|
286
|
+
.slice(0, -1)
|
|
287
|
+
.split("+")
|
|
288
|
+
.map(part => part.trim());
|
|
289
|
+
if (modifierParts.some(part => !KEY_MODIFIERS.includes(part as KeyModifier))) return undefined;
|
|
290
|
+
|
|
291
|
+
const modifiers = modifierParts as KeyModifier[];
|
|
292
|
+
if (new Set(modifiers).size !== modifiers.length) return undefined;
|
|
293
|
+
|
|
294
|
+
const canonicalBase = baseKey === "pageup" ? "pageUp" : baseKey === "pagedown" ? "pageDown" : baseKey;
|
|
295
|
+
return {
|
|
296
|
+
keyId: [...modifiers, canonicalBase].join("+") as KeyId,
|
|
297
|
+
modifiers,
|
|
298
|
+
baseKey: canonicalBase as BaseKey,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Whether a value is a valid canonical key identifier. */
|
|
303
|
+
export function isKeyId(value: string): value is KeyId {
|
|
304
|
+
return parseKeyId(value)?.keyId === value;
|
|
305
|
+
}
|
|
306
|
+
|
|
189
307
|
/**
|
|
190
308
|
* Typed helper for constructing key identifiers with autocomplete.
|
|
191
309
|
*
|
package/src/metrics.ts
CHANGED
|
@@ -124,6 +124,7 @@ export interface RenderMetricsSnapshot {
|
|
|
124
124
|
timerGauges: Record<string, number>;
|
|
125
125
|
helperStats: Record<string, HelperStat>;
|
|
126
126
|
lineCounts: Record<string, LineCountGauge>;
|
|
127
|
+
structuralCounters: Record<string, number>;
|
|
127
128
|
}
|
|
128
129
|
|
|
129
130
|
function emptyDurationStats(): DurationStats {
|
|
@@ -160,6 +161,7 @@ export class RenderMetrics {
|
|
|
160
161
|
#timerGauges = new Map<string, number>();
|
|
161
162
|
#helpers = new Map<string, { count: number; totalMs: number }>();
|
|
162
163
|
#lineGauges = new Map<string, LineCountGauge>();
|
|
164
|
+
#structuralCounters = new Map<string, number>();
|
|
163
165
|
#rssReturn: number | null = null;
|
|
164
166
|
#heapBaseline: number | null = null;
|
|
165
167
|
#heapReturn: number | null = null;
|
|
@@ -200,6 +202,7 @@ export class RenderMetrics {
|
|
|
200
202
|
this.#timerGauges.clear();
|
|
201
203
|
this.#helpers.clear();
|
|
202
204
|
this.#lineGauges.clear();
|
|
205
|
+
this.#structuralCounters.clear();
|
|
203
206
|
this.#rssReturn = null;
|
|
204
207
|
this.#heapBaseline = null;
|
|
205
208
|
this.#heapReturn = null;
|
|
@@ -296,6 +299,13 @@ export class RenderMetrics {
|
|
|
296
299
|
this.#lineGauges.set(retained, cur);
|
|
297
300
|
}
|
|
298
301
|
|
|
302
|
+
/** Accumulate deterministic structural render work without retaining frame data. */
|
|
303
|
+
recordStructuralCounter(name: string, value = 1): void {
|
|
304
|
+
if (!this.#enabled) return;
|
|
305
|
+
const retained = retainedLabel(this.#structuralCounters, name);
|
|
306
|
+
this.#structuralCounters.set(retained, (this.#structuralCounters.get(retained) ?? 0) + value);
|
|
307
|
+
}
|
|
308
|
+
|
|
299
309
|
/**
|
|
300
310
|
* Force a GC when the runtime exposes one and sample RSS as the post-run
|
|
301
311
|
* "return" value used by the memory-leak gate. Callers should drop large
|
|
@@ -345,6 +355,10 @@ export class RenderMetrics {
|
|
|
345
355
|
return out;
|
|
346
356
|
}
|
|
347
357
|
|
|
358
|
+
#structuralCounterStats(): Record<string, number> {
|
|
359
|
+
return Object.fromEntries(this.#structuralCounters);
|
|
360
|
+
}
|
|
361
|
+
|
|
348
362
|
snapshot(): RenderMetricsSnapshot {
|
|
349
363
|
return {
|
|
350
364
|
enabled: this.#enabled,
|
|
@@ -374,6 +388,7 @@ export class RenderMetrics {
|
|
|
374
388
|
timerGauges: Object.fromEntries(this.#timerGauges),
|
|
375
389
|
helperStats: this.#helperStats(),
|
|
376
390
|
lineCounts: this.#lineCountStats(),
|
|
391
|
+
structuralCounters: this.#structuralCounterStats(),
|
|
377
392
|
};
|
|
378
393
|
}
|
|
379
394
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { encodeSixel } from "@sayknow-cli/natives";
|
|
2
|
-
import { $env } from "@sayknow-cli/utils";
|
|
2
|
+
import { $env, $pickenv } from "@sayknow-cli/utils";
|
|
3
3
|
|
|
4
4
|
export enum ImageProtocol {
|
|
5
5
|
Kitty = "\x1b_G",
|
|
@@ -125,7 +125,7 @@ export function isCursorNeutralImagePermittedInFallback(): boolean {
|
|
|
125
125
|
}
|
|
126
126
|
|
|
127
127
|
function getForcedImageProtocol(): ImageProtocol | null | undefined {
|
|
128
|
-
const raw = $
|
|
128
|
+
const raw = $pickenv("SKC_FORCE_IMAGE_PROTOCOL", "PI_FORCE_IMAGE_PROTOCOL")?.trim().toLowerCase();
|
|
129
129
|
if (!raw) return undefined;
|
|
130
130
|
if (raw === "kitty") return ImageProtocol.Kitty;
|
|
131
131
|
if (raw === "iterm2" || raw === "iterm") return ImageProtocol.Iterm2;
|
|
@@ -182,6 +182,46 @@ export function wrapTmuxPassthrough(payload: string, env: NodeJS.ProcessEnv = Bu
|
|
|
182
182
|
return `\x1bPtmux;${payload.replaceAll("\x1b", "\x1b\x1b")}\x1b\\`;
|
|
183
183
|
}
|
|
184
184
|
|
|
185
|
+
/**
|
|
186
|
+
* Returns whether tmux itself claims the sixel capability for the attached client.
|
|
187
|
+
*
|
|
188
|
+
* This is the difference between tmux *owning* the graphic and tmux *not knowing it
|
|
189
|
+
* exists*. tmux 3.4+ parses sixel into its own screen model (`screen_write_sixelimage`)
|
|
190
|
+
* and re-renders it via `tty_cmd_sixelimage`, so scrolling, clearing, resizing, and
|
|
191
|
+
* copy-mode all behave. It only does that when `terminal-features` advertises `sixel`
|
|
192
|
+
* for the client's terminal.
|
|
193
|
+
*
|
|
194
|
+
* Without the feature the graphic must be smuggled past tmux with DCS passthrough —
|
|
195
|
+
* and then tmux can never erase what it never recorded, which is exactly how the pet
|
|
196
|
+
* leaves sixel residue behind: the erase (`ECH`) reaches tmux's cell buffer while the
|
|
197
|
+
* pixels live in the outer terminal's image plane.
|
|
198
|
+
*
|
|
199
|
+
* The answer is cached: it is a per-client property that cannot change without a
|
|
200
|
+
* re-attach, and the probe shells out to tmux.
|
|
201
|
+
*/
|
|
202
|
+
let tmuxSixelOwnershipCache: boolean | undefined;
|
|
203
|
+
export function tmuxOwnsSixel(env: NodeJS.ProcessEnv = Bun.env): boolean {
|
|
204
|
+
if (!isUnderTmux(env)) return false;
|
|
205
|
+
if (tmuxSixelOwnershipCache !== undefined) return tmuxSixelOwnershipCache;
|
|
206
|
+
try {
|
|
207
|
+
const probe = Bun.spawnSync({
|
|
208
|
+
cmd: [env.SKC_TMUX_COMMAND?.trim() || "tmux", "display", "-p", "#{client_termfeatures}"],
|
|
209
|
+
stdout: "pipe",
|
|
210
|
+
stderr: "ignore",
|
|
211
|
+
});
|
|
212
|
+
const features = probe.success ? new TextDecoder().decode(probe.stdout).trim().split(",") : [];
|
|
213
|
+
tmuxSixelOwnershipCache = features.includes("sixel");
|
|
214
|
+
} catch {
|
|
215
|
+
tmuxSixelOwnershipCache = false;
|
|
216
|
+
}
|
|
217
|
+
return tmuxSixelOwnershipCache;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Testing seam: drop the cached tmux sixel-ownership answer. */
|
|
221
|
+
export function resetTmuxSixelOwnershipCache(): void {
|
|
222
|
+
tmuxSixelOwnershipCache = undefined;
|
|
223
|
+
}
|
|
224
|
+
|
|
185
225
|
function parseMajorMinorVersion(versionRaw?: string): { major: number; minor: number } | null {
|
|
186
226
|
if (!versionRaw) return null;
|
|
187
227
|
const match = /^(\d+)\.(\d+)/u.exec(versionRaw.trim());
|
package/src/terminal.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { dlopen, FFIType, ptr } from "bun:ffi";
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
|
-
import { $env, $flag } from "@sayknow-cli/utils";
|
|
3
|
+
import { $env, $flag, $pickenv } from "@sayknow-cli/utils";
|
|
4
4
|
import { setKittyProtocolActive } from "./keys";
|
|
5
5
|
import { StdinBuffer } from "./stdin-buffer";
|
|
6
|
-
import { isUnderTerminalMultiplexer } from "./terminal-capabilities";
|
|
7
6
|
|
|
8
7
|
const TERMINAL_PROGRESS_KEEPALIVE_MS = 1000;
|
|
9
8
|
const TERMINAL_PROGRESS_ACTIVE_SEQUENCE = "\x1b]9;4;3\x07";
|
|
@@ -92,6 +91,7 @@ export function emergencyTerminalRestore(): void {
|
|
|
92
91
|
process.stdout.write(
|
|
93
92
|
"\x1b[?2004l" + // Disable bracketed paste
|
|
94
93
|
"\x1b[?1000l" + // Disable normal mouse reporting
|
|
94
|
+
"\x1b[?1002l" + // Disable button-event mouse reporting
|
|
95
95
|
"\x1b[?1006l" + // Disable SGR extended mouse reporting
|
|
96
96
|
"\x1b[?2031l" + // Disable Mode 2031 appearance notifications
|
|
97
97
|
"\x1b[<u" + // Pop kitty keyboard protocol
|
|
@@ -249,7 +249,7 @@ export class ProcessTerminal implements Terminal {
|
|
|
249
249
|
#stdinBuffer?: StdinBuffer;
|
|
250
250
|
#stdinDataHandler?: (data: string | Buffer) => void;
|
|
251
251
|
#dead = false;
|
|
252
|
-
#writeLogPath = $
|
|
252
|
+
#writeLogPath = $pickenv("SKC_TUI_WRITE_LOG", "PI_TUI_WRITE_LOG") || "";
|
|
253
253
|
#detachLogPath = $env.PI_TUI_TERMINAL_DETACH_LOG || "";
|
|
254
254
|
#windowsVTInputRestore?: () => void;
|
|
255
255
|
#stdoutErrorHandler?: (err: Error) => void;
|
|
@@ -284,8 +284,11 @@ export class ProcessTerminal implements Terminal {
|
|
|
284
284
|
}
|
|
285
285
|
|
|
286
286
|
setMouseEnabled(enabled: boolean): void {
|
|
287
|
-
this.#mouseEnabled = enabled
|
|
288
|
-
if (this.#started)
|
|
287
|
+
this.#mouseEnabled = enabled;
|
|
288
|
+
if (this.#started)
|
|
289
|
+
this.#safeWrite(
|
|
290
|
+
this.#mouseEnabled ? "\x1b[?1000l\x1b[?1002h\x1b[?1006h" : "\x1b[?1000l\x1b[?1002l\x1b[?1006l",
|
|
291
|
+
);
|
|
289
292
|
}
|
|
290
293
|
|
|
291
294
|
start(onInput: (data: string) => void, onResize: () => void): void {
|
|
@@ -315,8 +318,9 @@ export class ProcessTerminal implements Terminal {
|
|
|
315
318
|
|
|
316
319
|
// Enable bracketed paste mode - terminal will wrap pastes in \x1b[200~ ... \x1b[201~
|
|
317
320
|
this.#safeWrite("\x1b[?2004h");
|
|
318
|
-
//
|
|
319
|
-
|
|
321
|
+
// Button-event reporting preserves wheel input while also letting the TUI implement drag selection.
|
|
322
|
+
// Clear both tracking variants first so stale modes from another application cannot leak across startup.
|
|
323
|
+
this.#safeWrite(this.#mouseEnabled ? "\x1b[?1000l\x1b[?1002h\x1b[?1006h" : "\x1b[?1000l\x1b[?1002l\x1b[?1006l");
|
|
320
324
|
|
|
321
325
|
// Set up resize handler immediately
|
|
322
326
|
process.stdout.on("resize", this.#resizeHandler);
|
|
@@ -752,6 +756,7 @@ export class ProcessTerminal implements Terminal {
|
|
|
752
756
|
this.#mouseEnabled = false;
|
|
753
757
|
this.#safeWrite("\x1b[?2004l");
|
|
754
758
|
this.#safeWrite("\x1b[?1000l");
|
|
759
|
+
this.#safeWrite("\x1b[?1002l");
|
|
755
760
|
this.#safeWrite("\x1b[?1006l");
|
|
756
761
|
|
|
757
762
|
// Disable Mode 2031 appearance change notifications
|