@gajae-code/tui 0.11.6 → 0.11.8
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 +11 -0
- package/dist/types/keys.d.ts +17 -4
- package/dist/types/tui.d.ts +9 -0
- package/package.json +3 -3
- package/src/keybindings.ts +10 -1
- package/src/keys.ts +121 -3
- package/src/terminal-capabilities.ts +2 -2
- package/src/terminal.ts +2 -2
- package/src/tui.ts +22 -4
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.11.8] - 2026-07-23
|
|
6
|
+
|
|
7
|
+
## [0.11.7] - 2026-07-22
|
|
8
|
+
### Fixed
|
|
9
|
+
|
|
10
|
+
- Internal transcript PageUp/PageDown now continues through tool-output and other non-semantic rows instead of intermittently becoming a no-op after a semantic viewport anchor was established.
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Spurious resize events with unchanged terminal dimensions (iTerm2 tab switches and window focus changes deliver SIGWINCH without a size change) no longer trigger a forced full redraw. On hosts still using the full clear+replay path (legacy multiplexer opt-in, non-process terminals) that redraw cleared scrollback (`2J`/`H`/`3J`) and replayed the entire transcript, which could park the native viewport at the top of the thread when returning to the tab. `requestResizeRender()` now forces only when the grid size actually changed since the last committed frame; same-size events fall through to a no-op diff render.
|
|
15
|
+
|
|
5
16
|
## [0.11.4] - 2026-07-20
|
|
6
17
|
### Fixed
|
|
7
18
|
|
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/tui.d.ts
CHANGED
|
@@ -281,6 +281,15 @@ export declare class TUI extends Container {
|
|
|
281
281
|
* the transcript top during streaming redraws, so viewport-repaint sessions
|
|
282
282
|
* keep force off and let `#doRender` repaint only the live viewport. Set
|
|
283
283
|
* `PI_TUI_LEGACY_MULTIPLEXER_FULL_RENDER=1` to restore the legacy tmux redraw.
|
|
284
|
+
*
|
|
285
|
+
* Spurious resize events (SIGWINCH with unchanged dimensions — iTerm2 tab
|
|
286
|
+
* switches and window focus changes, the self-sent SIGWINCH after resume)
|
|
287
|
+
* must not force either: on hosts still using the `fullRender` path (legacy
|
|
288
|
+
* multiplexer opt-in, non-process terminals) the forced redraw clears
|
|
289
|
+
* scrollback (`2J`/`H`/`3J`) and replays the whole transcript, which can
|
|
290
|
+
* park the native viewport at the transcript top. Only force when the grid
|
|
291
|
+
* size actually changed since the last committed frame; a plain diff render
|
|
292
|
+
* is a no-op otherwise.
|
|
284
293
|
*/
|
|
285
294
|
requestResizeRender(): void;
|
|
286
295
|
requestRender(force?: boolean, source?: string): void;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@gajae-code/tui",
|
|
4
|
-
"version": "0.11.
|
|
4
|
+
"version": "0.11.8",
|
|
5
5
|
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
|
6
6
|
"homepage": "https://gajae-code.com",
|
|
7
7
|
"author": "Yeachan-Heo and Gajae Code Contributors",
|
|
@@ -36,8 +36,8 @@
|
|
|
36
36
|
"fmt": "biome format --write ."
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@gajae-code/natives": "0.11.
|
|
40
|
-
"@gajae-code/utils": "0.11.
|
|
39
|
+
"@gajae-code/natives": "0.11.8",
|
|
40
|
+
"@gajae-code/utils": "0.11.8",
|
|
41
41
|
"lru-cache": "11.3.6",
|
|
42
42
|
"marked": "18.0.6"
|
|
43
43
|
},
|
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
|
*
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { encodeSixel } from "@gajae-code/natives";
|
|
2
|
-
import { $env } from "@gajae-code/utils";
|
|
2
|
+
import { $env, $pickenv } from "@gajae-code/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("GJC_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;
|
package/src/terminal.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { dlopen, FFIType, ptr } from "bun:ffi";
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
|
-
import { $env, $flag } from "@gajae-code/utils";
|
|
3
|
+
import { $env, $flag, $pickenv } from "@gajae-code/utils";
|
|
4
4
|
import { setKittyProtocolActive } from "./keys";
|
|
5
5
|
import { StdinBuffer } from "./stdin-buffer";
|
|
6
6
|
import { isUnderTerminalMultiplexer } from "./terminal-capabilities";
|
|
@@ -209,7 +209,7 @@ export class ProcessTerminal implements Terminal {
|
|
|
209
209
|
#stdinBuffer?: StdinBuffer;
|
|
210
210
|
#stdinDataHandler?: (data: string | Buffer) => void;
|
|
211
211
|
#dead = false;
|
|
212
|
-
#writeLogPath = $
|
|
212
|
+
#writeLogPath = $pickenv("GJC_TUI_WRITE_LOG", "PI_TUI_WRITE_LOG") || "";
|
|
213
213
|
#detachLogPath = $env.PI_TUI_TERMINAL_DETACH_LOG || "";
|
|
214
214
|
#windowsVTInputRestore?: () => void;
|
|
215
215
|
#stdoutErrorHandler?: (err: Error) => void;
|
package/src/tui.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import * as fs from "node:fs";
|
|
5
5
|
import * as path from "node:path";
|
|
6
6
|
import { performance } from "node:perf_hooks";
|
|
7
|
-
import { $flag, getDebugLogPath, logger, onDefaultTabWidthChange } from "@gajae-code/utils";
|
|
7
|
+
import { $flag, $pickflag, getDebugLogPath, logger, onDefaultTabWidthChange } from "@gajae-code/utils";
|
|
8
8
|
import { getKeybindings } from "./keybindings";
|
|
9
9
|
import { isKeyRelease } from "./keys";
|
|
10
10
|
import { renderMetrics } from "./metrics";
|
|
@@ -654,7 +654,7 @@ export class TUI extends Container {
|
|
|
654
654
|
#sixelProbeBuffer = "";
|
|
655
655
|
#sixelProbeTimeout?: NodeJS.Timeout;
|
|
656
656
|
#sixelProbeUnsubscribe?: () => void;
|
|
657
|
-
#showHardwareCursor = $
|
|
657
|
+
#showHardwareCursor = $pickflag("GJC_HARDWARE_CURSOR", "PI_HARDWARE_CURSOR");
|
|
658
658
|
#debugRedraw = TUI.#readDebugRedrawFlag();
|
|
659
659
|
// macOS: steady-block cursor anchors CJK IME overlays; disable with GJC_TUI_IME_CURSOR=0.
|
|
660
660
|
readonly #useImeBlockCursor = $flag("GJC_TUI_IME_CURSOR", process.platform === "darwin");
|
|
@@ -895,7 +895,14 @@ export class TUI extends Container {
|
|
|
895
895
|
selected = { row, anchor };
|
|
896
896
|
}
|
|
897
897
|
if (selected === undefined) {
|
|
898
|
-
|
|
898
|
+
// A page can consist entirely of non-semantic rows such as tool output,
|
|
899
|
+
// transient panels, or pinned chrome. Fall back to numeric viewport
|
|
900
|
+
// ownership so PageUp/PageDown can continue through those rows instead
|
|
901
|
+
// of becoming an intermittent no-op. A later page with an eligible row
|
|
902
|
+
// will establish a fresh semantic anchor.
|
|
903
|
+
this.#manualViewportAnchor = null;
|
|
904
|
+
this.#manualViewportFallbackAnchors = [];
|
|
905
|
+
this.#reconcileMissingViewportAnchor = false;
|
|
899
906
|
} else {
|
|
900
907
|
this.#manualViewportAnchor = {
|
|
901
908
|
id: selected.anchor.id,
|
|
@@ -1339,9 +1346,20 @@ export class TUI extends Container {
|
|
|
1339
1346
|
* the transcript top during streaming redraws, so viewport-repaint sessions
|
|
1340
1347
|
* keep force off and let `#doRender` repaint only the live viewport. Set
|
|
1341
1348
|
* `PI_TUI_LEGACY_MULTIPLEXER_FULL_RENDER=1` to restore the legacy tmux redraw.
|
|
1349
|
+
*
|
|
1350
|
+
* Spurious resize events (SIGWINCH with unchanged dimensions — iTerm2 tab
|
|
1351
|
+
* switches and window focus changes, the self-sent SIGWINCH after resume)
|
|
1352
|
+
* must not force either: on hosts still using the `fullRender` path (legacy
|
|
1353
|
+
* multiplexer opt-in, non-process terminals) the forced redraw clears
|
|
1354
|
+
* scrollback (`2J`/`H`/`3J`) and replays the whole transcript, which can
|
|
1355
|
+
* park the native viewport at the transcript top. Only force when the grid
|
|
1356
|
+
* size actually changed since the last committed frame; a plain diff render
|
|
1357
|
+
* is a no-op otherwise.
|
|
1342
1358
|
*/
|
|
1343
1359
|
requestResizeRender(): void {
|
|
1344
|
-
|
|
1360
|
+
const dimensionsChanged =
|
|
1361
|
+
this.#previousWidth !== this.terminal.columns || this.#previousHeight !== this.terminal.rows;
|
|
1362
|
+
this.requestRender(dimensionsChanged && !useViewportRepaintPath(this.terminal), "resize");
|
|
1345
1363
|
}
|
|
1346
1364
|
|
|
1347
1365
|
requestRender(force = false, source = "unknown"): void {
|