@oh-my-pi/pi-tui 18.12.1 → 18.12.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 +16 -66
- package/dist/types/chrome/status-notice.d.ts +2 -0
- package/dist/types/prompt/composer.d.ts +2 -0
- package/dist/types/tools/web-search-types.d.ts +1 -0
- package/package.json +9 -9
- package/src/charts/table-data.ts +17 -6
- package/src/chrome/status-notice.ts +5 -0
- package/src/components/markdown.ts +21 -1
- package/src/prompt/composer-cache.ts +3 -0
- package/src/prompt/composer.ts +8 -2
- package/src/status-line/component.ts +56 -1
- package/src/stdin-buffer.ts +289 -61
- package/src/terminal.ts +30 -17
- package/src/tools/web-search-types.ts +1 -0
- package/src/tui.ts +20 -7
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [18.12.2] - 2026-10-18
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added `ComposerPreferences.completeScrollback` (default `false`): rows a growing HUD or dialog pushes off screen go straight to scrollback instead of being held back until it shrinks ([#15692](https://github.com/can1357/oh-my-pi/pull/15692) by [@Ilm-Alan](https://github.com/Ilm-Alan))
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Fixed table charts picking the wrong label and measure columns when numbers group thousands with spaces (`1 988`, `17 083`); they now read like `1,988` ([#16175](https://github.com/can1357/oh-my-pi/issues/16175))
|
|
14
|
+
- Fixed terminal capability replies relayed through win32-input-mode appearing as typed input in WSL terminals ([#16137](https://github.com/can1357/oh-my-pi/issues/16137))
|
|
15
|
+
- Fixed the status-bar activity spinner and turn timer freezing until the next keypress when the working-row loader is stopped mid-turn; an open turn window now repaints the status line on its own timer ([#16109](https://github.com/can1357/oh-my-pi/pull/16109) by [@tommymorgan](https://github.com/tommymorgan)).
|
|
16
|
+
- Fixed pasting sometimes inserting a block of base64 text instead of the pasted text in terminals that deliver pastes through the Kitty clipboard protocol (OSC 5522), most often with dictation apps that paste for you; a slow paste now arrives whole, and keys typed while a paste never finishes are given back instead of lost ([#16170](https://github.com/can1357/oh-my-pi/pull/16170) by [@H4vC](https://github.com/H4vC))
|
|
17
|
+
- Fixed a streaming table bouncing everything below it up and down while its last cell was still being written ([#16116](https://github.com/can1357/oh-my-pi/pull/16116) by [@galtamam-cmyk](https://github.com/galtamam-cmyk))
|
|
18
|
+
- Fixed inline images in iTerm2 being sized for the wrong cell shape, which left charts smeared or offset ([#16118](https://github.com/can1357/oh-my-pi/pull/16118) by [@galtamam-cmyk](https://github.com/galtamam-cmyk))
|
|
19
|
+
|
|
5
20
|
## [18.12.1] - 2026-10-17
|
|
6
21
|
|
|
7
22
|
### Breaking Changes
|
|
@@ -2462,69 +2477,4 @@
|
|
|
2462
2477
|
|
|
2463
2478
|
- Increased the minimum required Bun version for the TUI package from >=1.3.7 to >=1.3.14
|
|
2464
2479
|
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
### Added
|
|
2468
|
-
|
|
2469
|
-
- Added `Terminal.setProgress(active)` to emit OSC 9;4 progress sequences with a ~1s keepalive interval so Ghostty does not clear the indicator during long-running work (ports pi-mono `a900d251` + `76bc605a`)
|
|
2470
|
-
- Added optional `argumentHint?: string` to `SlashCommand`; rendered before the description in the autocomplete dropdown (ports pi-mono `aa25726e`)
|
|
2471
|
-
- Added `VirtualTerminal.waitForRender()` test helper for the throttled render pipeline (ports pi-mono `41377ee8`)
|
|
2472
|
-
|
|
2473
|
-
### Changed
|
|
2474
|
-
|
|
2475
|
-
- `ProcessTerminal` `columns`/`rows` getters consult `Bun.env.COLUMNS` / `Bun.env.LINES` before falling back to 80×24, so piped/non-TTY runs honour environment-provided dimensions (ports pi-mono `32f7fc6a`)
|
|
2476
|
-
- `requestRender()` non-force calls are coalesced to a ~16ms frame budget; `requestRender(true)` still flushes immediately via `process.nextTick` (ports pi-mono `6f5f37f8`)
|
|
2477
|
-
- `KNOWN_TERMINALS.base` / `KNOWN_TERMINALS.trueColor` default `hyperlinks: false`; tmux and screen (`TMUX` env or `TERM` starts with `tmux`/`screen`) force `hyperlinks: false` even when the outer terminal would advertise OSC 8 (adapts pi-mono `30a8a41f`)
|
|
2478
|
-
- `SlashCommand.getArgumentCompletions()` may return a `Promise`; results are now awaited and non-array returns are ignored (ports pi-mono `a1e10789`)
|
|
2479
|
-
- Fuzzy `@` autocomplete now follows symlinked directories via `ScanOptions.follow_links` plumbed through the native walker (ports pi-mono `780d5367`)
|
|
2480
|
-
- Plain `@<query>` (no slash) fuzzy matches by basename only, so `@plan` no longer surfaces every file whose ancestor directories contain `plan` (ports pi-mono `968430f6`)
|
|
2481
|
-
- Changed slash-command autocomplete list rendering to combine command hint and description in a single displayed suggestion text
|
|
2482
|
-
- Changed render scheduling to throttle `requestRender` calls to roughly 60fps by batching updates
|
|
2483
|
-
- Changed terminal input handling to process complete cell-size responses without buffering partial input
|
|
2484
|
-
- Changed `KeyId` to accept super-modifier combinations and improve typed key-id validation
|
|
2485
|
-
|
|
2486
|
-
### Fixed
|
|
2487
|
-
|
|
2488
|
-
- Fixed editor corruption on Thai Sara Am (U+0E33) and Lao AM (U+0EB3) vowels by normalizing to their compatibility decompositions on the terminal-write path while keeping editor content logically unchanged (ports pi-mono `bc668826` + `338ce3a3` + `20ca45d5`)
|
|
2489
|
-
- Fixed cell-size detection (`CSI 6;h;w t` response) to consume only exact replies, so a bare `Escape` keystroke is no longer swallowed while waiting for terminal image metadata (ports pi-mono `49c0d860`)
|
|
2490
|
-
- Fixed Kitty CSI-u printable input duplicating on layouts (e.g. Italian) where the terminal also emits the raw character: the immediately-following matching codepoint is now suppressed (ports pi-mono `bdb416cb`)
|
|
2491
|
-
- Fixed bracketed-paste CSI-u `Ctrl+<letter>` re-encoding (tmux popup with `extended-keys-format=csi-u`) leaking literal `[<code>;5u` into the editor; control bytes are decoded back to their literal byte before per-char filtering (ports pi-mono `d06db09a`)
|
|
2492
|
-
- Fixed xterm `modifyOtherKeys` shifted printable input so uppercase letters inserted via `CSI 27;mod;codepoint~` reach the editor correctly (ports pi-mono `6b55d685`)
|
|
2493
|
-
- Fixed `super`-modified Kitty shortcuts (`super+k`, `ctrl+super+enter`, …) to parse and match via the new `KITTY_MOD_SUPER` mask (ports pi-mono `ddb8454c` + `5ed46003`)
|
|
2494
|
-
- Fixed `ctrl+alt+<letter>` in tmux falling through to CSI-u / `modifyOtherKeys` when the legacy `ESC<ctrl-char>` form does not match (ports pi-mono `6cf5098f`)
|
|
2495
|
-
- Fixed Markdown strikethrough requiring strict `~~text~~` delimiters with non-whitespace boundaries; single tildes no longer render strikethrough (ports pi-mono `db5274b4`)
|
|
2496
|
-
- Allowed `SlashCommand.getArgumentCompletions` to return asynchronous results by accepting Promise-based completions
|
|
2497
|
-
- Added `argumentHint` support to slash command definitions and displayed it in command suggestion descriptions
|
|
2498
|
-
- Added support for xterm `modifyOtherKeys` printable key sequences by decoding `CSI 27;mod;key~` into text input
|
|
2499
|
-
- Normalized line output during rendering to correct Thai/Lao AM glyph composition for displayed text
|
|
2500
|
-
- Fixed duplicated Kitty key input emissions by dropping the matching unmodified follow-up sequence after a Kitty CSI-u printable-key event
|
|
2501
|
-
|
|
2502
|
-
## [14.9.5] - 2026-05-12
|
|
2503
|
-
|
|
2504
|
-
### Fixed
|
|
2505
|
-
|
|
2506
|
-
- Fixed rapidly blinking cursor artifact during task execution by consolidating cursor control sequences into the synchronized output buffer ([#992](https://github.com/can1357/oh-my-pi/issues/992))
|
|
2507
|
-
|
|
2508
|
-
## [14.5.7] - 2026-04-29
|
|
2509
|
-
|
|
2510
|
-
### Fixed
|
|
2511
|
-
|
|
2512
|
-
- Fixed editor Ctrl+Enter handling to recognize NumLock and keypad Enter variants.
|
|
2513
|
-
|
|
2514
|
-
## [14.3.0] - 2026-04-25
|
|
2515
|
-
|
|
2516
|
-
### Fixed
|
|
2517
|
-
|
|
2518
|
-
- Fixed shared Markdown Mermaid fenced-block rendering to resolve diagrams from fenced source text instead of external prerender state
|
|
2519
|
-
|
|
2520
|
-
## [14.1.1] - 2026-04-14
|
|
2521
|
-
|
|
2522
|
-
### Breaking Changes
|
|
2523
|
-
|
|
2524
|
-
- Removed the `searchDb` constructor argument from `CombinedAutocompleteProvider`, requiring callers to use the built-in search behavior
|
|
2525
|
-
|
|
2526
|
-
### Changed
|
|
2527
|
-
|
|
2528
|
-
- Changed truncation debug logging to run only when `debugRedraw` is enabled
|
|
2529
|
-
|
|
2530
|
-
Older entries are archived in [packages/tui/CHANGELOG.md@200e7d2c6ebb](https://github.com/can1357/oh-my-pi/blob/200e7d2c6ebb2e187b167f807b9f68f779c0bf4b/packages/tui/CHANGELOG.md).
|
|
2480
|
+
Older entries are archived in [packages/tui/CHANGELOG.md@04d4ab14b45f](https://github.com/can1357/oh-my-pi/blob/04d4ab14b45f8dbd4055b675519f8815a1eb1841/packages/tui/CHANGELOG.md).
|
|
@@ -21,6 +21,8 @@ export interface StatusNoticeOptions {
|
|
|
21
21
|
export declare class StatusNotice extends Container {
|
|
22
22
|
#private;
|
|
23
23
|
constructor(message: string, options?: StatusNoticeOptions);
|
|
24
|
+
/** The text the notice currently shows. */
|
|
25
|
+
get message(): string;
|
|
24
26
|
setMessage(message: string, options?: StatusNoticeOptions): void;
|
|
25
27
|
describe(): NativeNode;
|
|
26
28
|
}
|
|
@@ -14,6 +14,8 @@ export interface ComposerPreferences {
|
|
|
14
14
|
readonly showHardwareCursor: boolean;
|
|
15
15
|
readonly maxInlineImages: number;
|
|
16
16
|
readonly resizeScrollback: ResizeScrollbackMode;
|
|
17
|
+
/** Send rows a growing HUD or dialog pushes off screen straight to scrollback instead of holding them back until it shrinks. */
|
|
18
|
+
readonly completeScrollback: boolean;
|
|
17
19
|
readonly imeSafeCursor: boolean;
|
|
18
20
|
readonly autocompleteMaxVisible: number;
|
|
19
21
|
readonly spellingTypoDetection: boolean;
|
|
@@ -25,6 +25,7 @@ export declare const SEARCH_PROVIDER_LABELS: {
|
|
|
25
25
|
readonly tavily: "Tavily";
|
|
26
26
|
readonly firecrawl: "Firecrawl";
|
|
27
27
|
readonly brave: "Brave";
|
|
28
|
+
readonly deepseek: "DeepSeek";
|
|
28
29
|
readonly kimi: "Kimi";
|
|
29
30
|
readonly synthetic: "Synthetic";
|
|
30
31
|
readonly ollama: "Ollama";
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-tui",
|
|
4
|
-
"version": "18.12.
|
|
4
|
+
"version": "18.12.2",
|
|
5
5
|
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": {
|
|
@@ -41,14 +41,14 @@
|
|
|
41
41
|
"gen:glyphs": "bun scripts/gen-glyphs.ts"
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"@oh-my-pi/omptype": "18.12.
|
|
45
|
-
"@oh-my-pi/pi-agent-core": "18.12.
|
|
46
|
-
"@oh-my-pi/pi-ai": "18.12.
|
|
47
|
-
"@oh-my-pi/pi-catalog": "18.12.
|
|
48
|
-
"@oh-my-pi/pi-natives": "18.12.
|
|
49
|
-
"@oh-my-pi/pi-utils": "18.12.
|
|
50
|
-
"@oh-my-pi/pi-wire": "18.12.
|
|
51
|
-
"@oh-my-pi/snapcompact": "18.12.
|
|
44
|
+
"@oh-my-pi/omptype": "18.12.2",
|
|
45
|
+
"@oh-my-pi/pi-agent-core": "18.12.2",
|
|
46
|
+
"@oh-my-pi/pi-ai": "18.12.2",
|
|
47
|
+
"@oh-my-pi/pi-catalog": "18.12.2",
|
|
48
|
+
"@oh-my-pi/pi-natives": "18.12.2",
|
|
49
|
+
"@oh-my-pi/pi-utils": "18.12.2",
|
|
50
|
+
"@oh-my-pi/pi-wire": "18.12.2",
|
|
51
|
+
"@oh-my-pi/snapcompact": "18.12.2"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
54
|
"kitty-vt-wasm": "^0.2.0"
|
package/src/charts/table-data.ts
CHANGED
|
@@ -148,7 +148,17 @@ export interface TableAnalysis {
|
|
|
148
148
|
readonly composition?: Composition;
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
-
|
|
151
|
+
/**
|
|
152
|
+
* A whole number as written: bare, or grouped in thousands by commas (`1,988`)
|
|
153
|
+
* or by spaces as many locales write it (`1 988`, also U+00A0 and U+202F).
|
|
154
|
+
* Space groups take exactly three digits, so `12 5` and `128 2560` stay apart.
|
|
155
|
+
*/
|
|
156
|
+
const INTEGER = String.raw`\d{1,3}(?:,\d{3})+|\d{1,3}(?:[ \u00A0\u202F]\d{3})+(?!\d)|\d+`;
|
|
157
|
+
const NUMBER = String.raw`(?:${INTEGER})(?:\.\d+)?(?:[eE][+-]?\d+)?|\.\d+`;
|
|
158
|
+
/** A cell holding one whole number and nothing else (`38`, `1,980`). */
|
|
159
|
+
const WHOLE = new RegExp(String.raw`^(?:${INTEGER})$`);
|
|
160
|
+
/** The first number of a figure, its whole part and its decimals apart. */
|
|
161
|
+
const WRITTEN = new RegExp(String.raw`(${INTEGER})(?:\.(\d+))?`);
|
|
152
162
|
const CELL = new RegExp(
|
|
153
163
|
String.raw`^(?<approx>~|≈|<=|>=|<|>|≤|≥)?\s*(?<sign>[+\-−±])?\s*(?<cur>[$€£¥])?\s*(?<num>${NUMBER})(?<plus>\+(?=\s*[A-Za-zµμ%]))?\s*(?<unit>%|[A-Za-zµμ×]+(?:\/[A-Za-z]+)?|\/s)?(?<rest>.*)$`,
|
|
154
164
|
);
|
|
@@ -517,8 +527,9 @@ export function parseCell(markdown: string): Cell {
|
|
|
517
527
|
return { ...cell, fit: "prose" };
|
|
518
528
|
}
|
|
519
529
|
|
|
530
|
+
/** The value of a number {@link NUMBER} matched, its comma or space group separators removed. */
|
|
520
531
|
function toNumber(written: string): number {
|
|
521
|
-
return Number(written.
|
|
532
|
+
return Number(written.replace(/[,\s]/g, ""));
|
|
522
533
|
}
|
|
523
534
|
|
|
524
535
|
/** A number of a list or sum: its value as written, its unit (known or a noun) and how it was marked. */
|
|
@@ -632,7 +643,7 @@ function listCell(text: string, base: CellBase): NumberCell | undefined {
|
|
|
632
643
|
// A spaced pair of bare numbers that is no score (`25.0 / 27.8`, `12.6 / 9.4`: the second is no whole, or
|
|
633
644
|
// the first exceeds it) lists two values of the column's quantity; `27 / 30` stays a score.
|
|
634
645
|
const [first, second] = items;
|
|
635
|
-
const whole =
|
|
646
|
+
const whole = WHOLE.test(text.slice(text.indexOf("/") + 1, end).trim());
|
|
636
647
|
const score = whole && first!.value >= 0 && first!.value <= second!.value;
|
|
637
648
|
const pair = items.length === 2 && !marked.some(Boolean) && separators[0] !== "/" && !score;
|
|
638
649
|
if (!pair && (!marked.at(-1) || !(marked.every(Boolean) || carried))) return undefined;
|
|
@@ -876,9 +887,9 @@ const WHOLE_HEADER = /\b(?:total|all|sum|overall)\b/i;
|
|
|
876
887
|
*/
|
|
877
888
|
export function writtenSlack(cell: NumberCell): number {
|
|
878
889
|
if (cell.denominator !== undefined && cell.dim === "percent") return 0;
|
|
879
|
-
const match =
|
|
890
|
+
const match = WRITTEN.exec(cell.figure);
|
|
880
891
|
if (!match) return 0;
|
|
881
|
-
const written =
|
|
892
|
+
const written = toNumber(`${match[1]!}${match[2] ? `.${match[2]}` : ""}`);
|
|
882
893
|
const factor = written ? Math.abs(cell.value) / written : 1;
|
|
883
894
|
const decimals = match[2]?.length ?? 0;
|
|
884
895
|
if (cell.dim === "count" && decimals === 0 && factor === 1 && !cell.approx) return 0;
|
|
@@ -1097,7 +1108,7 @@ const MIN_LISTS = 2;
|
|
|
1097
1108
|
/** A spaced slash between list items. */
|
|
1098
1109
|
const LIST_SLASH = /\s+\/\s+/;
|
|
1099
1110
|
/** A list item that is one number, as written (`12`, `~1,400`, `3.5 ms`, `40%`). */
|
|
1100
|
-
const LISTED_NUMBER =
|
|
1111
|
+
const LISTED_NUMBER = new RegExp(String.raw`^[~≈]?[$€£¥]?(?:${INTEGER})(?:\.\d+)?\s*(?:%|[A-Za-zµμ]+)?$`);
|
|
1101
1112
|
|
|
1102
1113
|
/**
|
|
1103
1114
|
* Rows folding several rows into one (`response anthropic / chat / responses /
|
|
@@ -42,6 +42,11 @@ export class StatusNotice extends Container {
|
|
|
42
42
|
this.addChild(this.#text);
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
/** The text the notice currently shows. */
|
|
46
|
+
get message(): string {
|
|
47
|
+
return this.#message;
|
|
48
|
+
}
|
|
49
|
+
|
|
45
50
|
setMessage(message: string, options: StatusNoticeOptions = {}): void {
|
|
46
51
|
const toast = options.toast ?? true;
|
|
47
52
|
this.#text.setStyleFn(options.styleFn);
|
|
@@ -4611,7 +4611,27 @@ export class Markdown implements Component {
|
|
|
4611
4611
|
const text = this.#renderInlineTokens(cell.tokens || [], styleContext);
|
|
4612
4612
|
return this.#wrapCellText(text, columnWidths[i]);
|
|
4613
4613
|
});
|
|
4614
|
-
|
|
4614
|
+
let rowLineCount = Math.max(...rowCellLines.map(c => c.length));
|
|
4615
|
+
// The open cell is laid out in widths measured without it, so it can
|
|
4616
|
+
// wrap past the lines its row's finished cells need. Those extra lines
|
|
4617
|
+
// come and go as the cell grows and the column widens, moving every row
|
|
4618
|
+
// below the table each frame; hold the row at its finished cells' height
|
|
4619
|
+
// until the cell closes and counts toward the widths. While lines are
|
|
4620
|
+
// held back, only the last kept line makes room for a `…`: it is
|
|
4621
|
+
// re-wrapped one column narrower, since the wrapper closes the line's
|
|
4622
|
+
// links and styles where truncation would cut them off, and dropped when
|
|
4623
|
+
// even that overflows (a wide glyph in a two-cell column).
|
|
4624
|
+
if (rowIndex === lastRow && rowCellLines[openCell]) {
|
|
4625
|
+
const settledLineCount = Math.max(1, ...rowCellLines.filter((_, i) => i !== openCell).map(c => c.length));
|
|
4626
|
+
if (rowCellLines[openCell].length > settledLineCount) {
|
|
4627
|
+
const width = columnWidths[openCell];
|
|
4628
|
+
const kept = rowCellLines[openCell].slice(0, settledLineCount);
|
|
4629
|
+
const last = width > 1 ? (this.#wrapCellText(kept[settledLineCount - 1], width - 1)[0] ?? "") : "";
|
|
4630
|
+
kept[settledLineCount - 1] = `${visibleWidth(last) < width ? last : ""}…`;
|
|
4631
|
+
rowCellLines[openCell] = kept;
|
|
4632
|
+
}
|
|
4633
|
+
rowLineCount = settledLineCount;
|
|
4634
|
+
}
|
|
4615
4635
|
|
|
4616
4636
|
for (let lineIdx = 0; lineIdx < rowLineCount; lineIdx++) {
|
|
4617
4637
|
const rowParts = rowCellLines.map((cellLines, colIdx) => {
|
|
@@ -94,6 +94,7 @@ function parseUiState(
|
|
|
94
94
|
showHardwareCursor,
|
|
95
95
|
maxInlineImages,
|
|
96
96
|
resizeScrollback,
|
|
97
|
+
completeScrollback,
|
|
97
98
|
imeSafeCursor,
|
|
98
99
|
autocompleteMaxVisible,
|
|
99
100
|
spellingTypoDetection,
|
|
@@ -109,6 +110,7 @@ function parseUiState(
|
|
|
109
110
|
resizeScrollback !== "append" &&
|
|
110
111
|
resizeScrollback !== "rebuild" &&
|
|
111
112
|
resizeScrollback !== "preserve") ||
|
|
113
|
+
(completeScrollback !== undefined && typeof completeScrollback !== "boolean") ||
|
|
112
114
|
typeof imeSafeCursor !== "boolean" ||
|
|
113
115
|
typeof autocompleteMaxVisible !== "number" ||
|
|
114
116
|
typeof spellingTypoDetection !== "boolean" ||
|
|
@@ -136,6 +138,7 @@ function parseUiState(
|
|
|
136
138
|
showHardwareCursor,
|
|
137
139
|
maxInlineImages,
|
|
138
140
|
resizeScrollback: resizeScrollback ?? "rebuild",
|
|
141
|
+
completeScrollback: completeScrollback ?? false,
|
|
139
142
|
imeSafeCursor,
|
|
140
143
|
autocompleteMaxVisible,
|
|
141
144
|
spellingTypoDetection,
|
package/src/prompt/composer.ts
CHANGED
|
@@ -37,6 +37,8 @@ export interface ComposerPreferences {
|
|
|
37
37
|
readonly showHardwareCursor: boolean;
|
|
38
38
|
readonly maxInlineImages: number;
|
|
39
39
|
readonly resizeScrollback: ResizeScrollbackMode;
|
|
40
|
+
/** Send rows a growing HUD or dialog pushes off screen straight to scrollback instead of holding them back until it shrinks. */
|
|
41
|
+
readonly completeScrollback: boolean;
|
|
40
42
|
readonly imeSafeCursor: boolean;
|
|
41
43
|
readonly autocompleteMaxVisible: number;
|
|
42
44
|
readonly spellingTypoDetection: boolean;
|
|
@@ -51,6 +53,7 @@ export const COMPOSER_DEFAULTS: ComposerPreferences = {
|
|
|
51
53
|
showHardwareCursor: true,
|
|
52
54
|
maxInlineImages: 8,
|
|
53
55
|
resizeScrollback: "rebuild",
|
|
56
|
+
completeScrollback: false,
|
|
54
57
|
imeSafeCursor: false,
|
|
55
58
|
autocompleteMaxVisible: 10,
|
|
56
59
|
spellingTypoDetection: true,
|
|
@@ -271,6 +274,8 @@ export class Composer implements TerminalFrameProvider, NativeSurfaceProvider {
|
|
|
271
274
|
// displaced rows, so those rows refill the screen when it shrinks (#11007).
|
|
272
275
|
// Displaced rows reach history when newer output pushes them past the floor
|
|
273
276
|
// or persistent idle chrome settles and raises it.
|
|
277
|
+
// With `completeScrollback`, rows the chrome pushes off screen go straight
|
|
278
|
+
// to scrollback, and when it shrinks the freed rows stay blank.
|
|
274
279
|
// The floor is terminal-height independent — chrome heights do not scale
|
|
275
280
|
// with rows — so it is retained across resizes rather than rediscovered
|
|
276
281
|
// from whatever chrome is expanded when the height changes.
|
|
@@ -429,7 +434,7 @@ export class Composer implements TerminalFrameProvider, NativeSurfaceProvider {
|
|
|
429
434
|
const promotedChromeContracted = this.#chromeFloorPromoted && eligibleChrome < (this.#chromeFloor ?? 0);
|
|
430
435
|
this.#chromeFloor = Math.min(this.#chromeFloor ?? eligibleChrome, eligibleChrome);
|
|
431
436
|
this.#rebaselineChromeFloor(this.#runActive || transcript.hasActiveBlock(), eligibleChrome);
|
|
432
|
-
const belowFloor = this.#chromeFloor;
|
|
437
|
+
const belowFloor = this.#preferences.completeScrollback ? after.length : this.#chromeFloor;
|
|
433
438
|
// Offer history under capacity pressure only: blocks stay live (and keep
|
|
434
439
|
// reflowing to the current width) while the screen has room. A batch
|
|
435
440
|
// leaves the mutable viewport in the same frame it is appended, so its
|
|
@@ -447,7 +452,8 @@ export class Composer implements TerminalFrameProvider, NativeSurfaceProvider {
|
|
|
447
452
|
// billed against, so its compaction allocator (one row per block, no
|
|
448
453
|
// inter-block blanks) engages only when a block genuinely cannot retire.
|
|
449
454
|
// Rows the chrome above its floor displaces are clipped from the top by
|
|
450
|
-
// the `drop` slice below; they stay live until the chrome shrinks.
|
|
455
|
+
// the `drop` slice below; they stay live until the chrome shrinks. With
|
|
456
|
+
// `completeScrollback` none are held back.
|
|
451
457
|
const active = transcript.renderViewport(width, Math.max(0, rows - before.length - belowFloor), frame);
|
|
452
458
|
const activeSpans: ViewportClickSpan[] = [];
|
|
453
459
|
for (const span of transcript.getLastViewportSpans()) {
|
|
@@ -43,7 +43,7 @@ import type { TspMeterMark, TspProps } from "@oh-my-pi/pi-wire";
|
|
|
43
43
|
import type { NativeNode, NativeUiEvent } from "../native/node";
|
|
44
44
|
import { col, node, span } from "../native/describe";
|
|
45
45
|
import { getContextMeterThresholds } from "../chrome/context-thresholds";
|
|
46
|
-
import { isNativeRendering } from "../native/state";
|
|
46
|
+
import { isNativeRendering, onNativeRenderingChange } from "../native/state";
|
|
47
47
|
import { getSeparator } from "./separators";
|
|
48
48
|
import type {
|
|
49
49
|
CollabStatus,
|
|
@@ -154,6 +154,10 @@ const WATCHER_FAILURE_POLL_TTL_MS = 5000;
|
|
|
154
154
|
const BRAND_FADE_MS = 450;
|
|
155
155
|
/** Repaint cadence while the brand fade is in flight (rust omp's `FADE_FRAME`). */
|
|
156
156
|
const BRAND_FADE_FRAME_MS = 40;
|
|
157
|
+
/** Repaint cadence while a turn's active-processing window is open: keeps the
|
|
158
|
+
* status bar's clock-driven readouts (pi spinner, turn timer) animating when
|
|
159
|
+
* no other component is requesting renders. */
|
|
160
|
+
const ACTIVE_WINDOW_REPAINT_MS = 250;
|
|
157
161
|
|
|
158
162
|
/**
|
|
159
163
|
* Providers whose subscription quota is a single monthly bucket, so their
|
|
@@ -690,6 +694,8 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
|
|
|
690
694
|
#gitWatcherUnavailable = false;
|
|
691
695
|
#onBranchChange: (() => void) | null = null;
|
|
692
696
|
#disposed = false;
|
|
697
|
+
/** Unsubscribe for the native-rendering feed; re-arms the turn-window ticker when a TSP surface is revoked. */
|
|
698
|
+
#unwatchNativeRendering: (() => void) | undefined;
|
|
693
699
|
#autoCompactEnabled: boolean = true;
|
|
694
700
|
/** Pulse timer for the running-speculation indicator; live only while speculation runs. */
|
|
695
701
|
#speculationBlinkTimer: NodeJS.Timeout | undefined;
|
|
@@ -700,6 +706,8 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
|
|
|
700
706
|
#brandWorking = false;
|
|
701
707
|
/** Frame timer driving repaints while the brand fade is unsettled. */
|
|
702
708
|
#brandFadeTimer: NodeJS.Timeout | undefined;
|
|
709
|
+
/** Repaint ticker while a turn's active-processing window is open (see #syncActiveWindowRepaint). */
|
|
710
|
+
#activeWindowRepaintTimer: NodeJS.Timeout | undefined;
|
|
703
711
|
/** One wall-clock wakeup for the active model's next tariff change, including while idle. */
|
|
704
712
|
#pricingTimer: NodeJS.Timeout | undefined;
|
|
705
713
|
#pricingTimerCost: ModelCost | undefined;
|
|
@@ -825,6 +833,7 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
|
|
|
825
833
|
private readonly host: StatusLineHost<TSession>,
|
|
826
834
|
) {
|
|
827
835
|
this.#settings = host.getSettings();
|
|
836
|
+
this.#unwatchNativeRendering = onNativeRenderingChange(() => this.#syncActiveWindowRepaint());
|
|
828
837
|
}
|
|
829
838
|
|
|
830
839
|
#gitEnabled(): boolean {
|
|
@@ -931,6 +940,12 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
|
|
|
931
940
|
this.#invalidateSessionCaches();
|
|
932
941
|
this.#closeStaleActiveWindow();
|
|
933
942
|
}
|
|
943
|
+
// The ticker self-stops once its tick sees no open window for the
|
|
944
|
+
// newly-attached session; re-pointing focus at a session whose turn
|
|
945
|
+
// is still running must re-arm it, and no other path reliably fires
|
|
946
|
+
// here (closeStaleActiveWindow returns early while streaming, and
|
|
947
|
+
// the synthesized agent_start no-ops on an already-open window).
|
|
948
|
+
this.#syncActiveWindowRepaint();
|
|
934
949
|
this.invalidate();
|
|
935
950
|
}
|
|
936
951
|
|
|
@@ -1029,6 +1044,7 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
|
|
|
1029
1044
|
meter.activeMs = 0;
|
|
1030
1045
|
meter.activeStartedAt = null;
|
|
1031
1046
|
this.#invalidateStatusLineRenderCache();
|
|
1047
|
+
this.#syncActiveWindowRepaint();
|
|
1032
1048
|
}
|
|
1033
1049
|
|
|
1034
1050
|
/**
|
|
@@ -1043,6 +1059,7 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
|
|
|
1043
1059
|
if (meter.activeStartedAt !== null) return;
|
|
1044
1060
|
meter.activeStartedAt = Date.now();
|
|
1045
1061
|
this.#invalidateStatusLineRenderCache();
|
|
1062
|
+
this.#syncActiveWindowRepaint();
|
|
1046
1063
|
}
|
|
1047
1064
|
|
|
1048
1065
|
/**
|
|
@@ -1057,6 +1074,7 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
|
|
|
1057
1074
|
meter.activeMs += Math.max(0, Date.now() - meter.activeStartedAt);
|
|
1058
1075
|
meter.activeStartedAt = null;
|
|
1059
1076
|
this.#invalidateStatusLineRenderCache();
|
|
1077
|
+
this.#syncActiveWindowRepaint();
|
|
1060
1078
|
}
|
|
1061
1079
|
|
|
1062
1080
|
/**
|
|
@@ -1282,8 +1300,11 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
|
|
|
1282
1300
|
this.#onBranchChange = null;
|
|
1283
1301
|
this.#stopSpeculationBlink();
|
|
1284
1302
|
this.#stopBrandFadeTimer();
|
|
1303
|
+
this.#stopActiveWindowRepaint();
|
|
1285
1304
|
this.#stopPricingTimer();
|
|
1286
1305
|
this.#clearUsageStartTimer();
|
|
1306
|
+
this.#unwatchNativeRendering?.();
|
|
1307
|
+
this.#unwatchNativeRendering = undefined;
|
|
1287
1308
|
this.#onCodexResetFireworks = undefined;
|
|
1288
1309
|
this.#onResetExpiryNotice = undefined;
|
|
1289
1310
|
this.#codexResetSnapshots.clear();
|
|
@@ -1387,6 +1408,40 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
|
|
|
1387
1408
|
this.#brandFadeTimer = undefined;
|
|
1388
1409
|
}
|
|
1389
1410
|
|
|
1411
|
+
/**
|
|
1412
|
+
* Keep the status bar repainting while a turn's active-processing window
|
|
1413
|
+
* is open. The `pi` segment's spinner and turn timer are painted from the
|
|
1414
|
+
* wall clock at render time, so they only animate when frames flow; during
|
|
1415
|
+
* a quiet turn the working-row loader's 80 ms tick is normally the sole
|
|
1416
|
+
* repaint source, and paths that stop or bypass that loader mid-turn (an
|
|
1417
|
+
* error banner, a stale retry/compaction loader reference) froze the
|
|
1418
|
+
* spinner until the next keystroke. The status line owns the active
|
|
1419
|
+
* window, so it owns a repaint source for it; a native surface clocks its
|
|
1420
|
+
* own spinner and needs none.
|
|
1421
|
+
*/
|
|
1422
|
+
#syncActiveWindowRepaint(): void {
|
|
1423
|
+
if (this.#disposed || isNativeRendering() || this.getTurnElapsedMs() === null) {
|
|
1424
|
+
this.#stopActiveWindowRepaint();
|
|
1425
|
+
return;
|
|
1426
|
+
}
|
|
1427
|
+
if (this.#activeWindowRepaintTimer) return;
|
|
1428
|
+
const timer = setInterval(() => {
|
|
1429
|
+
if (this.#disposed || isNativeRendering() || this.getTurnElapsedMs() === null) {
|
|
1430
|
+
this.#stopActiveWindowRepaint();
|
|
1431
|
+
return;
|
|
1432
|
+
}
|
|
1433
|
+
this.#onBranchChange?.();
|
|
1434
|
+
}, ACTIVE_WINDOW_REPAINT_MS);
|
|
1435
|
+
timer.unref?.();
|
|
1436
|
+
this.#activeWindowRepaintTimer = timer;
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
#stopActiveWindowRepaint(): void {
|
|
1440
|
+
if (!this.#activeWindowRepaintTimer) return;
|
|
1441
|
+
clearInterval(this.#activeWindowRepaintTimer);
|
|
1442
|
+
this.#activeWindowRepaintTimer = undefined;
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1390
1445
|
#stopPricingTimer(): void {
|
|
1391
1446
|
clearTimeout(this.#pricingTimer);
|
|
1392
1447
|
this.#pricingTimer = undefined;
|
package/src/stdin-buffer.ts
CHANGED
|
@@ -58,7 +58,7 @@ const PARTIAL_HOLD_MAX_MS = 150;
|
|
|
58
58
|
// so the string-terminator cap is generous.
|
|
59
59
|
const MAX_CSI_BYTES = 4096;
|
|
60
60
|
const MAX_STRING_SEQ_BYTES = 16 * 1024 * 1024;
|
|
61
|
-
// Torn-string discard bounds. When a capped
|
|
61
|
+
// Torn-string discard bounds. When a capped OSC/DCS/APC
|
|
62
62
|
// partial is dropped, the bytes that follow are mid-string payload (base64
|
|
63
63
|
// clipboard data, Sixel, …) up to the next ST/BEL. Replaying them as
|
|
64
64
|
// keystrokes types kilobytes of garbage into the focused component (kitty
|
|
@@ -69,6 +69,22 @@ const STRING_DISCARD_MAX_BYTES = 2 * MAX_STRING_SEQ_BYTES;
|
|
|
69
69
|
const STRING_DISCARD_INACTIVITY_MS = 1000;
|
|
70
70
|
// Partial that can only be the head of an OSC/DCS/APC string sequence.
|
|
71
71
|
const STRING_SEQ_PARTIAL = /^\x1b[\]P_]/;
|
|
72
|
+
// A pending string head is abandoned after this long without terminal-shaped
|
|
73
|
+
// progress. Generous: a slow clipboard owner or SSH link may stall a packet
|
|
74
|
+
// for a while, and abandoning a live packet types nothing but loses it.
|
|
75
|
+
const STRING_ABANDON_MS = 5000;
|
|
76
|
+
// How long an abandoned head stays dormant waiting for a late tail. Bounded
|
|
77
|
+
// so a stale packet cannot absorb unrelated multi-byte input later on; a
|
|
78
|
+
// resumed head that is abandoned again is dropped for good.
|
|
79
|
+
const STRING_DORMANT_MS = 10_000;
|
|
80
|
+
// Openings of the string replies omp receives, matched while still partial
|
|
81
|
+
// (each alternative accepts any prefix of its opener) so a legacy Alt chord
|
|
82
|
+
// that diverges is recognized as soon as it does:
|
|
83
|
+
// - OSC: numeric command then `;` (`\x1b]11;`, `\x1b]5522;`).
|
|
84
|
+
// - DCS: DECRQSS/XTGETTCAP `\x1bP1$r` / `\x1bP1+r`, XTVERSION `\x1bP>|`, DA3 `\x1bP!|`.
|
|
85
|
+
// - APC: kitty graphics `\x1b_G`, TSP `\x1b_tsp;`, Glyph Protocol `\x1b_25a1;`.
|
|
86
|
+
const LEGACY_STRING_REPLY_OPENER =
|
|
87
|
+
/^(?:\x1b\]\d+(?:;|$)|\x1bP(?:\d(?:[$+](?:r|$)|$)|[>!](?:\||$))|\x1b_(?:G|t(?:s(?:p(?:;|$)|$)|$)|2(?:5(?:a(?:1(?:;|$)|$)|$)|$)))/;
|
|
72
88
|
|
|
73
89
|
// SGR mouse report bodies live between `<` and the terminating `M`/`m`.
|
|
74
90
|
// Matched only when the trailing byte is a valid terminator, so the regex
|
|
@@ -108,6 +124,74 @@ function isRawMultilineBurst(text: string): boolean {
|
|
|
108
124
|
return false;
|
|
109
125
|
}
|
|
110
126
|
|
|
127
|
+
// `resolveEscapeEnd` result for an abandoned string sequence; see there.
|
|
128
|
+
const STRING_ABORTED = -3;
|
|
129
|
+
const OSC5522_PREFIX = "\x1b]5522;";
|
|
130
|
+
// Kitty clipboard metadata (`type=read:status=DATA:mime=…:pw=…:id=…`) is
|
|
131
|
+
// short; bounding the `;` search keeps a malformed packet O(n).
|
|
132
|
+
const MAX_OSC5522_METADATA = 1024;
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Resolve an OSC/DCS/APC string sequence by its grammar, not by time. A
|
|
136
|
+
* string ends at ST (`ESC \`), or BEL for OSC. Its payload never contains
|
|
137
|
+
* another control byte or escape, and an OSC 5522 payload (after the
|
|
138
|
+
* metadata `;`) is strict base64. So a slow terminal — a clipboard owner
|
|
139
|
+
* serving a paste lazily — just extends the wait, while a keypress that
|
|
140
|
+
* cannot be payload (Enter, Backspace, Esc, arrows, space) proves the
|
|
141
|
+
* terminal abandoned the string: the caller drops the head and parses that
|
|
142
|
+
* key normally. Neither half of a stalled packet is ever typed as text.
|
|
143
|
+
*
|
|
144
|
+
* The scan is bounded to `[searchFrom, scanLimit)`; `resumeSearchFrom - 1`
|
|
145
|
+
* keeps a one-byte overlap so an `ESC \` split across chunks is still found.
|
|
146
|
+
*/
|
|
147
|
+
function resolveStringEnd(
|
|
148
|
+
buffer: string,
|
|
149
|
+
pos: number,
|
|
150
|
+
length: number,
|
|
151
|
+
resumeSearchFrom: number,
|
|
152
|
+
belTerminates: boolean,
|
|
153
|
+
): number {
|
|
154
|
+
const searchFrom = Math.max(pos + 2, resumeSearchFrom - 1);
|
|
155
|
+
const scanLimit = Math.min(length, pos + MAX_STRING_SEQ_BYTES);
|
|
156
|
+
const base64From = belTerminates ? osc5522PayloadStart(buffer, pos, scanLimit) : -1;
|
|
157
|
+
// ECMA-48 §8.3.27: a DCS command string may carry BS..CR (0x08-0x0D).
|
|
158
|
+
const dcs = buffer.charCodeAt(pos + 1) === 0x50; /* P */
|
|
159
|
+
for (let i = searchFrom; i < scanLimit; i++) {
|
|
160
|
+
const code = buffer.charCodeAt(i);
|
|
161
|
+
if (code === 0x1b /* ESC */) {
|
|
162
|
+
// A lone trailing ESC may open ST; re-examined next call.
|
|
163
|
+
if (i + 1 === scanLimit) break;
|
|
164
|
+
if (buffer.charCodeAt(i + 1) === 0x5c /* \ */) return i + 2;
|
|
165
|
+
return STRING_ABORTED - i;
|
|
166
|
+
}
|
|
167
|
+
if (code === 0x07 /* BEL */ && belTerminates) return i + 1;
|
|
168
|
+
if ((code < 0x20 || code === 0x7f) && !(dcs && code >= 0x08 && code <= 0x0d)) return STRING_ABORTED - i;
|
|
169
|
+
if (base64From !== -1 && i >= base64From && !isBase64Code(code)) return STRING_ABORTED - i;
|
|
170
|
+
}
|
|
171
|
+
return length - pos >= MAX_STRING_SEQ_BYTES ? -2 : -1;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Index where an OSC 5522 packet's base64 payload starts, or -1 if not (yet) known. */
|
|
175
|
+
function osc5522PayloadStart(buffer: string, pos: number, scanLimit: number): number {
|
|
176
|
+
if (!buffer.startsWith(OSC5522_PREFIX, pos)) return -1;
|
|
177
|
+
const metadataEnd = Math.min(scanLimit, pos + OSC5522_PREFIX.length + MAX_OSC5522_METADATA);
|
|
178
|
+
for (let i = pos + OSC5522_PREFIX.length; i < metadataEnd; i++) {
|
|
179
|
+
if (buffer.charCodeAt(i) === 0x3b /* ; */) return i + 1;
|
|
180
|
+
}
|
|
181
|
+
return -1;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function isBase64Code(code: number): boolean {
|
|
185
|
+
return (
|
|
186
|
+
(code >= 0x41 && code <= 0x5a) || // A-Z
|
|
187
|
+
(code >= 0x61 && code <= 0x7a) || // a-z
|
|
188
|
+
(code >= 0x30 && code <= 0x39) || // 0-9
|
|
189
|
+
code === 0x2b || // +
|
|
190
|
+
code === 0x2f || // /
|
|
191
|
+
code === 0x3d // =
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
111
195
|
/**
|
|
112
196
|
* Resolve the exclusive-end index of the escape sequence starting at `pos`
|
|
113
197
|
* (`buffer.charCodeAt(pos)` must be ESC). `resumeSearchFrom` is honored only
|
|
@@ -125,6 +209,9 @@ function isRawMultilineBurst(text: string): boolean {
|
|
|
125
209
|
* `-2` — incomplete and the prefix already spans the per-type cap;
|
|
126
210
|
* the caller cap-flushes CSI as raw bytes; string types
|
|
127
211
|
* (OSC/DCS/APC) are dropped and their tail discarded.
|
|
212
|
+
* `<= STRING_ABORTED` — an OSC/DCS/APC interrupted by a byte its payload
|
|
213
|
+
* cannot contain; `STRING_ABORTED - result` is that byte's
|
|
214
|
+
* index.
|
|
128
215
|
*/
|
|
129
216
|
function resolveEscapeEnd(buffer: string, pos: number, length: number, resumeSearchFrom: number): number {
|
|
130
217
|
if (pos + 1 >= length) return -1;
|
|
@@ -174,45 +261,13 @@ function resolveEscapeEnd(buffer: string, pos: number, length: number, resumeSea
|
|
|
174
261
|
}
|
|
175
262
|
return length - pos >= MAX_CSI_BYTES ? -2 : -1;
|
|
176
263
|
}
|
|
177
|
-
case 0x5d /* ] */:
|
|
178
|
-
// OSC: ESC ] ... BEL or ST (ESC \).
|
|
179
|
-
|
|
180
|
-
// an unterminated payload delivered as one huge chunk would
|
|
181
|
-
// otherwise be scanned to the end of the buffer — past the cap
|
|
182
|
-
// this function exists to enforce. `resumeSearchFrom - 1` keeps
|
|
183
|
-
// the one-byte overlap so an `ESC \` split across chunks is
|
|
184
|
-
// still found (the prior call's trailing ESC is re-inspected).
|
|
185
|
-
const searchFrom = Math.max(pos + 2, resumeSearchFrom - 1);
|
|
186
|
-
const scanLimit = Math.min(length, pos + MAX_STRING_SEQ_BYTES);
|
|
187
|
-
for (let i = searchFrom; i < scanLimit; i++) {
|
|
188
|
-
const code = buffer.charCodeAt(i);
|
|
189
|
-
if (code === 0x07 /* BEL */) return i + 1;
|
|
190
|
-
if (code === 0x1b /* ESC */) {
|
|
191
|
-
// `ESC \` (ST) must end within the cap; a lone trailing
|
|
192
|
-
// ESC at the buffer edge stays incomplete and is
|
|
193
|
-
// re-examined next call via the resume overlap.
|
|
194
|
-
if (i + 1 < scanLimit && buffer.charCodeAt(i + 1) === 0x5c /* \ */) return i + 2;
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
return length - pos >= MAX_STRING_SEQ_BYTES ? -2 : -1;
|
|
198
|
-
}
|
|
264
|
+
case 0x5d /* ] */:
|
|
265
|
+
// OSC: ESC ] ... BEL or ST (ESC \).
|
|
266
|
+
return resolveStringEnd(buffer, pos, length, resumeSearchFrom, true);
|
|
199
267
|
case 0x50 /* P */:
|
|
200
|
-
case 0x5f /* _ */:
|
|
201
|
-
// DCS / APC: ESC P/_ ... ST (ESC \).
|
|
202
|
-
|
|
203
|
-
const searchFrom = Math.max(pos + 2, resumeSearchFrom - 1);
|
|
204
|
-
const scanLimit = Math.min(length, pos + MAX_STRING_SEQ_BYTES);
|
|
205
|
-
for (let i = searchFrom; i < scanLimit; i++) {
|
|
206
|
-
if (
|
|
207
|
-
buffer.charCodeAt(i) === 0x1b /* ESC */ &&
|
|
208
|
-
i + 1 < scanLimit &&
|
|
209
|
-
buffer.charCodeAt(i + 1) === 0x5c /* \ */
|
|
210
|
-
) {
|
|
211
|
-
return i + 2;
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
return length - pos >= MAX_STRING_SEQ_BYTES ? -2 : -1;
|
|
215
|
-
}
|
|
268
|
+
case 0x5f /* _ */:
|
|
269
|
+
// DCS / APC: ESC P/_ ... ST (ESC \).
|
|
270
|
+
return resolveStringEnd(buffer, pos, length, resumeSearchFrom, false);
|
|
216
271
|
case 0x4f /* O */:
|
|
217
272
|
// SS3: ESC O + 1 char.
|
|
218
273
|
return pos + 3 <= length ? pos + 3 : -1;
|
|
@@ -236,10 +291,18 @@ function parseUnmodifiedKittyPrintableCodepoint(sequence: string): number | unde
|
|
|
236
291
|
function extractCompleteSequences(
|
|
237
292
|
buffer: string,
|
|
238
293
|
resumeSearchFrom: number,
|
|
239
|
-
): {
|
|
294
|
+
): {
|
|
295
|
+
sequences: string[];
|
|
296
|
+
remainder: string;
|
|
297
|
+
resumeSearchFrom: number;
|
|
298
|
+
discardFrom?: number;
|
|
299
|
+
/** The buffered string head at offset 0 was abandoned (see `resolveStringEnd`). */
|
|
300
|
+
abortedHead?: boolean;
|
|
301
|
+
} {
|
|
240
302
|
const sequences: string[] = [];
|
|
241
303
|
const length = buffer.length;
|
|
242
304
|
let pos = 0;
|
|
305
|
+
let abortedHead = false;
|
|
243
306
|
|
|
244
307
|
// Index-based scanning: this is the input hot path. Slicing the remaining
|
|
245
308
|
// buffer (or Array.from-ing it) per iteration would make plain-text bursts
|
|
@@ -273,7 +336,7 @@ function extractCompleteSequences(
|
|
|
273
336
|
// window so cases 1/2 can still arrive; if no follower
|
|
274
337
|
// arrives, `flush()` splits the held remainder into two ESC
|
|
275
338
|
// events (#3857).
|
|
276
|
-
return { sequences, remainder: buffer.slice(pos), resumeSearchFrom: 0 };
|
|
339
|
+
return { sequences, remainder: buffer.slice(pos), resumeSearchFrom: 0, abortedHead };
|
|
277
340
|
}
|
|
278
341
|
const third = buffer.charCodeAt(pos + 2);
|
|
279
342
|
if (third !== 0x5b && third !== 0x4f) {
|
|
@@ -292,7 +355,7 @@ function extractCompleteSequences(
|
|
|
292
355
|
// text (settings search filling with "[B" or "[<35;22;17M").
|
|
293
356
|
const innerEnd = resolveEscapeEnd(buffer, pos + 1, length, 0);
|
|
294
357
|
if (innerEnd === -1) {
|
|
295
|
-
return { sequences, remainder: buffer.slice(pos), resumeSearchFrom: 0 };
|
|
358
|
+
return { sequences, remainder: buffer.slice(pos), resumeSearchFrom: 0, abortedHead };
|
|
296
359
|
}
|
|
297
360
|
if (innerEnd === -2) {
|
|
298
361
|
// `third` is CSI/SS3 here, never a string type.
|
|
@@ -321,13 +384,21 @@ function extractCompleteSequences(
|
|
|
321
384
|
// Single ESC — resolve directly. Hint carries over from the previous
|
|
322
385
|
// call only when we are still on the buffered escape (pos === 0).
|
|
323
386
|
const end = resolveEscapeEnd(buffer, pos, length, pos === 0 ? hint : 0);
|
|
387
|
+
if (end <= STRING_ABORTED) {
|
|
388
|
+
// The terminal abandoned this string: drop the head (protocol
|
|
389
|
+
// data, never keys) and parse the interrupting byte normally.
|
|
390
|
+
if (pos === 0) abortedHead = true;
|
|
391
|
+
pos = STRING_ABORTED - end;
|
|
392
|
+
hint = 0;
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
324
395
|
if (end === -1) {
|
|
325
396
|
// Buffer for more. When this is the leading OSC/DCS/APC,
|
|
326
397
|
// remember how far we scanned so the next `process()` call
|
|
327
398
|
// resumes from there instead of rescanning the whole buffer.
|
|
328
399
|
const next = pos + 1 < length ? buffer.charCodeAt(pos + 1) : -1;
|
|
329
400
|
const nextHint = pos === 0 && (next === 0x5d || next === 0x50 || next === 0x5f) ? length : 0;
|
|
330
|
-
return { sequences, remainder: buffer.slice(pos), resumeSearchFrom: nextHint };
|
|
401
|
+
return { sequences, remainder: buffer.slice(pos), resumeSearchFrom: nextHint, abortedHead };
|
|
331
402
|
}
|
|
332
403
|
if (end === -2) {
|
|
333
404
|
const next = buffer.charCodeAt(pos + 1);
|
|
@@ -349,7 +420,7 @@ function extractCompleteSequences(
|
|
|
349
420
|
hint = 0;
|
|
350
421
|
}
|
|
351
422
|
|
|
352
|
-
return { sequences, remainder: "", resumeSearchFrom: 0 };
|
|
423
|
+
return { sequences, remainder: "", resumeSearchFrom: 0, abortedHead };
|
|
353
424
|
}
|
|
354
425
|
|
|
355
426
|
export type StdinBufferOptions = {
|
|
@@ -433,6 +504,20 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
433
504
|
#stringDiscardBytes = 0;
|
|
434
505
|
#stringDiscardEscHeld = false;
|
|
435
506
|
#stringDiscardWatchdog?: NodeJS.Timeout;
|
|
507
|
+
// Pending string head bookkeeping (see `#trackStringRead`): when the head
|
|
508
|
+
// last made terminal-shaped progress (0 = no head tracked), keys typed
|
|
509
|
+
// while it stalled, and the abandonment timer.
|
|
510
|
+
#stringProgressMs = 0;
|
|
511
|
+
#stringTypedKeys: string[] = [];
|
|
512
|
+
// Buffer offset of each typed key, parallel to `#stringTypedKeys`.
|
|
513
|
+
#stringTypedAt: number[] = [];
|
|
514
|
+
#stringAbandonTimer?: NodeJS.Timeout;
|
|
515
|
+
// An abandoned string head, kept so a late terminal-shaped tail still
|
|
516
|
+
// completes it instead of being typed (see `#resumeDormantString`).
|
|
517
|
+
#dormantString: string | undefined;
|
|
518
|
+
#dormantUntilMs = 0;
|
|
519
|
+
// The pending head was resumed from dormancy; abandoning it again drops it.
|
|
520
|
+
#stringResumed = false;
|
|
436
521
|
|
|
437
522
|
constructor(options: StdinBufferOptions = {}) {
|
|
438
523
|
super();
|
|
@@ -472,6 +557,11 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
472
557
|
str = this.#consumeStringDiscard(str);
|
|
473
558
|
if (str.length === 0) return;
|
|
474
559
|
}
|
|
560
|
+
if (this.#dormantString !== undefined && this.#buffer.length === 0) {
|
|
561
|
+
if (Date.now() < this.#dormantUntilMs) this.#resumeDormantString(str);
|
|
562
|
+
else this.#dormantString = undefined;
|
|
563
|
+
}
|
|
564
|
+
if (this.#bufferIsStringHead()) this.#trackStringRead(str);
|
|
475
565
|
|
|
476
566
|
if (this.#flushDeferral && this.#isFreshEscapeAfterDeferredFlush(str)) {
|
|
477
567
|
// The buffered partial already hit its flush timeout. A new escape is
|
|
@@ -528,6 +618,8 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
528
618
|
|
|
529
619
|
const startIndex = this.#buffer.indexOf(BRACKETED_PASTE_START);
|
|
530
620
|
if (startIndex !== -1) {
|
|
621
|
+
// Keys typed while a string head stalled precede the paste.
|
|
622
|
+
this.#emitStringTypedKeys();
|
|
531
623
|
if (startIndex > 0) {
|
|
532
624
|
const beforePaste = this.#buffer.slice(0, startIndex);
|
|
533
625
|
const result = extractCompleteSequences(beforePaste, 0);
|
|
@@ -554,6 +646,7 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
554
646
|
const junk = this.#buffer.slice(result.discardFrom);
|
|
555
647
|
this.#buffer = "";
|
|
556
648
|
this.#escapeSearchOffset = 0;
|
|
649
|
+
this.#takeStringTypedKeys();
|
|
557
650
|
for (const sequence of result.sequences) {
|
|
558
651
|
this.#emitDataSequence(sequence);
|
|
559
652
|
}
|
|
@@ -565,14 +658,26 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
565
658
|
this.#buffer = result.remainder;
|
|
566
659
|
this.#escapeSearchOffset = result.resumeSearchFrom;
|
|
567
660
|
|
|
661
|
+
// An abandoned string head hands back the keys typed while it stalled;
|
|
662
|
+
// they precede the byte that aborted it.
|
|
663
|
+
if (result.abortedHead) this.#emitStringTypedKeys();
|
|
568
664
|
for (const sequence of result.sequences) {
|
|
569
665
|
this.#emitDataSequence(sequence);
|
|
570
666
|
}
|
|
571
667
|
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
668
|
+
// A string head waits for its terminator, an aborting byte, or the
|
|
669
|
+
// abandonment bound (`#trackStringRead`), never the key flush window.
|
|
670
|
+
if (this.#bufferIsStringHead()) {
|
|
671
|
+
if (this.#stringProgressMs === 0) {
|
|
672
|
+
this.#dormantString = undefined;
|
|
673
|
+
this.#stringResumed = false;
|
|
674
|
+
this.#noteStringProgress();
|
|
675
|
+
}
|
|
575
676
|
this.#partialHoldStartMs = 0;
|
|
677
|
+
} else {
|
|
678
|
+
this.#takeStringTypedKeys();
|
|
679
|
+
if (this.#buffer.length > 0) this.#armFlushTimer();
|
|
680
|
+
else this.#partialHoldStartMs = 0;
|
|
576
681
|
}
|
|
577
682
|
}
|
|
578
683
|
|
|
@@ -751,7 +856,7 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
751
856
|
this.#timeout = undefined;
|
|
752
857
|
this.#flushDeferral = setTimeout(() => {
|
|
753
858
|
this.#flushDeferral = undefined;
|
|
754
|
-
this.#flushExpired(
|
|
859
|
+
this.#flushExpired();
|
|
755
860
|
});
|
|
756
861
|
}, this.#timeoutMs);
|
|
757
862
|
}
|
|
@@ -798,8 +903,132 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
798
903
|
return SGR_MOUSE_PARTIAL.test(this.#buffer) || isKittyProtocolActive();
|
|
799
904
|
}
|
|
800
905
|
|
|
906
|
+
/**
|
|
907
|
+
* Whether the buffer is the head of an OSC/DCS/APC string: terminal
|
|
908
|
+
* protocol data (OSC 5522 clipboard read, graphics reply), never a key.
|
|
909
|
+
* Without the kitty keyboard protocol `\x1b]`/`\x1bP`/`\x1b_` may be
|
|
910
|
+
* Alt+]/Alt+P/Alt+_, alone or batched with the next keys, so a head counts
|
|
911
|
+
* only while it follows the opening of a reply omp actually receives
|
|
912
|
+
* ({@link LEGACY_STRING_REPLY_OPENER}); the moment it diverges it falls
|
|
913
|
+
* back to the key flush timer.
|
|
914
|
+
*/
|
|
915
|
+
#bufferIsStringHead(): boolean {
|
|
916
|
+
const buffer = this.#buffer;
|
|
917
|
+
if (!STRING_SEQ_PARTIAL.test(buffer)) return false;
|
|
918
|
+
if (isKittyProtocolActive()) return true;
|
|
919
|
+
return buffer.length > 2 && LEGACY_STRING_REPLY_OPENER.test(buffer);
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
/**
|
|
923
|
+
* Classify a read arriving while a string head is pending. Chunk boundaries
|
|
924
|
+
* do not follow terminal writes (SSH, tmux), so no read is ever torn out of
|
|
925
|
+
* the string: every byte stays payload until the terminator completes it or
|
|
926
|
+
* an impossible byte aborts it, and every read refreshes the abandonment
|
|
927
|
+
* bound so a slow transfer is never abandoned while it still streams.
|
|
928
|
+
*
|
|
929
|
+
* Typing and terminal payload are indistinguishable by their bytes; when in
|
|
930
|
+
* doubt the string wins, because typed base64 is the failure this exists
|
|
931
|
+
* to prevent. Only a lone printable key arriving after the flush window
|
|
932
|
+
* passed without a read — the shape of typing — is remembered: a head that
|
|
933
|
+
* completes keeps it as payload, one that is abandoned (aborting byte, or
|
|
934
|
+
* {@link STRING_ABANDON_MS} without reads) replays it. Multi-byte reads,
|
|
935
|
+
* even keys an event-loop stall batched together, stay protocol data.
|
|
936
|
+
*/
|
|
937
|
+
#trackStringRead(str: string): void {
|
|
938
|
+
const stalled = Date.now() - this.#stringProgressMs >= this.#timeoutMs;
|
|
939
|
+
this.#noteStringProgress();
|
|
940
|
+
const code = str.codePointAt(0)!;
|
|
941
|
+
if (!stalled || str.length !== (code > 0xffff ? 2 : 1) || code < 0x20 || code === 0x7f) return;
|
|
942
|
+
// Only a key the string would keep as payload: a terminator completes
|
|
943
|
+
// it, and an impossible byte aborts it and is delivered by the parser.
|
|
944
|
+
const candidate = this.#buffer + str;
|
|
945
|
+
const end = resolveStringEnd(
|
|
946
|
+
candidate,
|
|
947
|
+
0,
|
|
948
|
+
candidate.length,
|
|
949
|
+
this.#buffer.length,
|
|
950
|
+
this.#buffer.charCodeAt(1) === 0x5d,
|
|
951
|
+
);
|
|
952
|
+
if (end !== -1) return;
|
|
953
|
+
this.#stringTypedKeys.push(str);
|
|
954
|
+
this.#stringTypedAt.push(this.#buffer.length);
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
#noteStringProgress(): void {
|
|
958
|
+
this.#stringProgressMs = Date.now();
|
|
959
|
+
clearTimeout(this.#stringAbandonTimer);
|
|
960
|
+
this.#stringAbandonTimer = setTimeout(() => {
|
|
961
|
+
this.#stringAbandonTimer = undefined;
|
|
962
|
+
if (!this.#bufferIsStringHead()) {
|
|
963
|
+
this.#takeStringTypedKeys();
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
966
|
+
// The replayed keys are not payload: cut them from the dormant head
|
|
967
|
+
// so a late tail completes the packet the terminal actually sent.
|
|
968
|
+
let dormant = this.#buffer;
|
|
969
|
+
for (let i = this.#stringTypedAt.length - 1; i >= 0; i--) {
|
|
970
|
+
const at = this.#stringTypedAt[i]!;
|
|
971
|
+
dormant = dormant.slice(0, at) + dormant.slice(at + this.#stringTypedKeys[i]!.length);
|
|
972
|
+
}
|
|
973
|
+
if (this.#stringResumed) {
|
|
974
|
+
this.#dormantString = undefined;
|
|
975
|
+
} else {
|
|
976
|
+
this.#dormantString = dormant;
|
|
977
|
+
this.#dormantUntilMs = Date.now() + STRING_DORMANT_MS;
|
|
978
|
+
}
|
|
979
|
+
this.#buffer = "";
|
|
980
|
+
this.#escapeSearchOffset = 0;
|
|
981
|
+
this.#emitStringTypedKeys();
|
|
982
|
+
}, STRING_ABANDON_MS);
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
/** End string head tracking; returns the keys typed while it stalled. */
|
|
986
|
+
#takeStringTypedKeys(): string[] {
|
|
987
|
+
if (this.#stringProgressMs === 0) return [];
|
|
988
|
+
const keys = this.#stringTypedKeys;
|
|
989
|
+
this.#stringTypedKeys = [];
|
|
990
|
+
this.#stringTypedAt = [];
|
|
991
|
+
this.#stringProgressMs = 0;
|
|
992
|
+
clearTimeout(this.#stringAbandonTimer);
|
|
993
|
+
this.#stringAbandonTimer = undefined;
|
|
994
|
+
return keys;
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
/** End string head tracking and replay the keys typed while it stalled. */
|
|
998
|
+
#emitStringTypedKeys(): void {
|
|
999
|
+
for (const keys of this.#takeStringTypedKeys()) {
|
|
1000
|
+
for (const sequence of extractCompleteSequences(keys, 0).sequences) this.#emitDataSequence(sequence);
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
/**
|
|
1005
|
+
* Re-attach a late tail to an abandoned string head. A read that completes
|
|
1006
|
+
* the head (even a lone BEL, or `\` after a held ESC), a lone ESC under the
|
|
1007
|
+
* kitty keyboard protocol (never a key there, so it opens a split ST), or a
|
|
1008
|
+
* multi-byte read that continues its grammar (strict base64 for OSC 5522)
|
|
1009
|
+
* resumes the head, as protocol data like any multi-byte read. Other lone
|
|
1010
|
+
* printable keys pass through as typing and keep the head dormant;
|
|
1011
|
+
* anything else means input moved on.
|
|
1012
|
+
*/
|
|
1013
|
+
#resumeDormantString(str: string): void {
|
|
1014
|
+
const head = this.#dormantString!;
|
|
1015
|
+
const candidate = head + str;
|
|
1016
|
+
const end = resolveStringEnd(candidate, 0, candidate.length, head.length, head.charCodeAt(1) === 0x5d);
|
|
1017
|
+
const code = str.codePointAt(0)!;
|
|
1018
|
+
const heldSt = str === ESC && isKittyProtocolActive();
|
|
1019
|
+
if (end <= 0 && !heldSt && str.length === (code > 0xffff ? 2 : 1)) {
|
|
1020
|
+
if (code < 0x20 || code === 0x7f) this.#dormantString = undefined;
|
|
1021
|
+
return;
|
|
1022
|
+
}
|
|
1023
|
+
this.#dormantString = undefined;
|
|
1024
|
+
if (end <= STRING_ABORTED) return;
|
|
1025
|
+
this.#buffer = head;
|
|
1026
|
+
this.#escapeSearchOffset = head.length;
|
|
1027
|
+
this.#stringResumed = true;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
801
1030
|
/** Timeout-driven flush: hold unambiguous partials (bounded), else deliver. */
|
|
802
|
-
#flushExpired(
|
|
1031
|
+
#flushExpired(): void {
|
|
803
1032
|
if (this.#buffer.length === 0) {
|
|
804
1033
|
this.#partialHoldStartMs = 0;
|
|
805
1034
|
return;
|
|
@@ -812,16 +1041,16 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
812
1041
|
}
|
|
813
1042
|
}
|
|
814
1043
|
this.#partialHoldStartMs = 0;
|
|
815
|
-
for (const sequence of this.#drainBuffered(
|
|
1044
|
+
for (const sequence of this.#drainBuffered()) {
|
|
816
1045
|
this.#emitDataSequence(sequence);
|
|
817
1046
|
}
|
|
818
1047
|
}
|
|
819
1048
|
|
|
820
1049
|
flush(): string[] {
|
|
821
|
-
return this.#drainBuffered(
|
|
1050
|
+
return this.#drainBuffered();
|
|
822
1051
|
}
|
|
823
1052
|
|
|
824
|
-
#drainBuffered(
|
|
1053
|
+
#drainBuffered(): string[] {
|
|
825
1054
|
this.#clearFlushTimer();
|
|
826
1055
|
|
|
827
1056
|
const rawCandidate = this.#takeRawPasteCandidate();
|
|
@@ -832,6 +1061,7 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
832
1061
|
return sequences;
|
|
833
1062
|
}
|
|
834
1063
|
|
|
1064
|
+
const stringHead = this.#bufferIsStringHead();
|
|
835
1065
|
const buffered = this.#buffer;
|
|
836
1066
|
this.#buffer = "";
|
|
837
1067
|
this.#escapeSearchOffset = 0;
|
|
@@ -843,15 +1073,10 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
843
1073
|
// split in `extractCompleteSequences` and deliver two ESC events.
|
|
844
1074
|
if (buffered === `${ESC}${ESC}`) {
|
|
845
1075
|
sequences.push(ESC, ESC);
|
|
846
|
-
} else if (
|
|
847
|
-
//
|
|
848
|
-
//
|
|
849
|
-
|
|
850
|
-
// reply) whose tail is still in flight. Never deliver the head as a
|
|
851
|
-
// key; on a timeout flush also swallow the tail up to its
|
|
852
|
-
// terminator so kilobytes of base64 are not typed into the focused
|
|
853
|
-
// component (image paste spam).
|
|
854
|
-
if (discardTornString) this.#enterStringDiscard();
|
|
1076
|
+
} else if (stringHead) {
|
|
1077
|
+
// Unterminated OSC/DCS/APC head: protocol data, never delivered as a
|
|
1078
|
+
// key. Its tracking ends with it so the next head starts fresh.
|
|
1079
|
+
this.#takeStringTypedKeys();
|
|
855
1080
|
} else {
|
|
856
1081
|
sequences.push(buffered);
|
|
857
1082
|
}
|
|
@@ -930,6 +1155,9 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
930
1155
|
this.#clearPasteWatchdog();
|
|
931
1156
|
this.#clearRawPasteTimer();
|
|
932
1157
|
this.#exitStringDiscard();
|
|
1158
|
+
this.#takeStringTypedKeys();
|
|
1159
|
+
this.#dormantString = undefined;
|
|
1160
|
+
this.#stringResumed = false;
|
|
933
1161
|
this.#buffer = "";
|
|
934
1162
|
this.#rawPasteCandidate = "";
|
|
935
1163
|
this.#pasteMode = false;
|
package/src/terminal.ts
CHANGED
|
@@ -856,6 +856,8 @@ export class ProcessTerminal implements Terminal {
|
|
|
856
856
|
// Releases a relayed lone ESC the decoder held for a sequence tail that never came.
|
|
857
857
|
#win32PendingFlushTimer?: Timer;
|
|
858
858
|
#stdinBuffer?: StdinBuffer;
|
|
859
|
+
/** Parsed input path shared by direct VT and win32-input-mode relayed text. */
|
|
860
|
+
#stdinSequenceHandler?: (sequence: string) => void;
|
|
859
861
|
#stdinDataHandler?: (data: string) => void;
|
|
860
862
|
#disconnectHandler?: () => void;
|
|
861
863
|
#stdinEndHandler = () => {
|
|
@@ -1387,7 +1389,30 @@ export class ProcessTerminal implements Terminal {
|
|
|
1387
1389
|
// skip the subparameter tail instead of dropping the whole report.
|
|
1388
1390
|
const inBandResizePattern = /^\x1b\[48;(\d+)(?::[\d:]*)?;(\d+)(?::[\d:]*)?;(\d+)(?::[\d:]*)?;(\d+)(?::[\d:]*)?t$/;
|
|
1389
1391
|
|
|
1390
|
-
this.#
|
|
1392
|
+
this.#stdinSequenceHandler = (sequence: string) => {
|
|
1393
|
+
const decoder = this.#win32InputDecoder;
|
|
1394
|
+
const win32Keys = decoder?.decode(sequence);
|
|
1395
|
+
if (decoder && win32Keys !== undefined) {
|
|
1396
|
+
clearTimeout(this.#win32PendingFlushTimer);
|
|
1397
|
+
this.#win32PendingFlushTimer = undefined;
|
|
1398
|
+
// The record is ESC-led, so it interrupts any direct reply still being
|
|
1399
|
+
// reassembled, just as a raw escape does below. Decoded keys carry no ESC
|
|
1400
|
+
// of their own and would otherwise be appended to that fragment.
|
|
1401
|
+
this.#privateCsiResponseBuffer = "";
|
|
1402
|
+
this.#inBandResizeBuffer = "";
|
|
1403
|
+
this.#osc11ResponseBuffer = "";
|
|
1404
|
+
this.#osc99ResponseBuffer = "";
|
|
1405
|
+
this.#glyphProtocolReplyBuffer = "";
|
|
1406
|
+
this.#tspReplyBuffer = "";
|
|
1407
|
+
for (const key of win32Keys) this.#stdinSequenceHandler?.(key);
|
|
1408
|
+
if (decoder.hasPendingSequence) {
|
|
1409
|
+
this.#win32PendingFlushTimer = setTimeout(
|
|
1410
|
+
() => this.#releaseWin32PendingSequence(),
|
|
1411
|
+
WIN32_RELAYED_ESCAPE_TIMEOUT_MS,
|
|
1412
|
+
);
|
|
1413
|
+
}
|
|
1414
|
+
return;
|
|
1415
|
+
}
|
|
1391
1416
|
// Fast path for plain-text bytes: every escape-probe regex below
|
|
1392
1417
|
// anchors on `^\x1b…`, so a byte that is not ESC can never match. A
|
|
1393
1418
|
// non-bracketed paste of N printable chars arrives as N per-scalar
|
|
@@ -1719,20 +1744,6 @@ export class ProcessTerminal implements Terminal {
|
|
|
1719
1744
|
return;
|
|
1720
1745
|
}
|
|
1721
1746
|
if (this.#inputHandler) {
|
|
1722
|
-
const decoder = this.#win32InputDecoder;
|
|
1723
|
-
const win32Keys = decoder?.decode(sequence);
|
|
1724
|
-
if (decoder && win32Keys !== undefined) {
|
|
1725
|
-
clearTimeout(this.#win32PendingFlushTimer);
|
|
1726
|
-
this.#win32PendingFlushTimer = undefined;
|
|
1727
|
-
for (const key of win32Keys) this.#inputHandler(key);
|
|
1728
|
-
if (decoder.hasPendingSequence) {
|
|
1729
|
-
this.#win32PendingFlushTimer = setTimeout(
|
|
1730
|
-
() => this.#releaseWin32PendingSequence(),
|
|
1731
|
-
WIN32_RELAYED_ESCAPE_TIMEOUT_MS,
|
|
1732
|
-
);
|
|
1733
|
-
}
|
|
1734
|
-
return;
|
|
1735
|
-
}
|
|
1736
1747
|
// Windows console hosts drop AltGr text under kitty (AltGr+F → `CSI 102;3u`);
|
|
1737
1748
|
// recover it from the active layout before any keybinding sees an Alt chord.
|
|
1738
1749
|
const altGrText =
|
|
@@ -1741,7 +1752,8 @@ export class ProcessTerminal implements Terminal {
|
|
|
1741
1752
|
: undefined;
|
|
1742
1753
|
this.#inputHandler(altGrText ?? sequence);
|
|
1743
1754
|
}
|
|
1744
|
-
}
|
|
1755
|
+
};
|
|
1756
|
+
this.#stdinBuffer.on("data", this.#stdinSequenceHandler);
|
|
1745
1757
|
|
|
1746
1758
|
// Re-wrap paste content with bracketed paste markers for existing editor
|
|
1747
1759
|
// handling. An Enter that shared the paste's stdin read rides along so
|
|
@@ -2104,7 +2116,7 @@ export class ProcessTerminal implements Terminal {
|
|
|
2104
2116
|
clearTimeout(this.#win32PendingFlushTimer);
|
|
2105
2117
|
this.#win32PendingFlushTimer = undefined;
|
|
2106
2118
|
const pending = this.#win32InputDecoder?.flushPendingSequence() ?? [];
|
|
2107
|
-
for (const key of pending) this.#
|
|
2119
|
+
for (const key of pending) this.#stdinSequenceHandler?.(key);
|
|
2108
2120
|
}
|
|
2109
2121
|
|
|
2110
2122
|
#disableWin32InputMode(): void {
|
|
@@ -2442,6 +2454,7 @@ export class ProcessTerminal implements Terminal {
|
|
|
2442
2454
|
this.#stdinBuffer.destroy();
|
|
2443
2455
|
this.#stdinBuffer = undefined;
|
|
2444
2456
|
}
|
|
2457
|
+
this.#stdinSequenceHandler = undefined;
|
|
2445
2458
|
|
|
2446
2459
|
// Remove event handlers
|
|
2447
2460
|
if (this.#stdinDataHandler) {
|
package/src/tui.ts
CHANGED
|
@@ -107,6 +107,11 @@ const PAINT_END_NO_SYNC = ENABLE_AUTOWRAP;
|
|
|
107
107
|
// native text selection.
|
|
108
108
|
const MOUSE_TRACKING_ON = "\x1b[?1000h\x1b[?1003h\x1b[?1006h";
|
|
109
109
|
const MOUSE_TRACKING_OFF = "\x1b[?1006l\x1b[?1003l\x1b[?1000l";
|
|
110
|
+
// Cell-size replies: CSI 6 ; height ; width t in pixels, and iTerm2's
|
|
111
|
+
// OSC 1337 ; ReportCellSize = height ; width [; scale] ST in points.
|
|
112
|
+
const CELL_SIZE_REPLY = /^\x1b\[6;(\d+);(\d+)t$/;
|
|
113
|
+
const ITERM2_CELL_SIZE_REPLY =
|
|
114
|
+
/^\x1b\]1337;ReportCellSize=(\d+(?:\.\d+)?);(\d+(?:\.\d+)?)(?:;(\d+(?:\.\d+)?))?(?:\x07|\x1b\\)$/;
|
|
110
115
|
|
|
111
116
|
type MouseTrackingState = "off" | "inline" | "full";
|
|
112
117
|
|
|
@@ -2562,6 +2567,9 @@ export class TUI extends Container {
|
|
|
2562
2567
|
// Query terminal for cell size in pixels: CSI 16 t
|
|
2563
2568
|
// Response format: CSI 6 ; height ; width t
|
|
2564
2569
|
this.terminal.write("\x1b[16t");
|
|
2570
|
+
// iTerm2 never answers CSI 16 t. Without a size, rows reserved for an image
|
|
2571
|
+
// and crops of a partly scrolled one assume 9x18 cells and land offset.
|
|
2572
|
+
if (TERMINAL.imageProtocol === ImageProtocol.Iterm2) this.terminal.write("\x1b]1337;ReportCellSize\x1b\\");
|
|
2565
2573
|
}
|
|
2566
2574
|
|
|
2567
2575
|
/**
|
|
@@ -3051,14 +3059,19 @@ export class TUI extends Container {
|
|
|
3051
3059
|
}
|
|
3052
3060
|
|
|
3053
3061
|
#consumeCellSizeResponse(data: string): boolean {
|
|
3054
|
-
|
|
3055
|
-
|
|
3056
|
-
|
|
3057
|
-
|
|
3062
|
+
let heightPx: number;
|
|
3063
|
+
let widthPx: number;
|
|
3064
|
+
const pixels = CELL_SIZE_REPLY.exec(data);
|
|
3065
|
+
if (pixels) {
|
|
3066
|
+
heightPx = parseInt(pixels[1], 10);
|
|
3067
|
+
widthPx = parseInt(pixels[2], 10);
|
|
3068
|
+
} else {
|
|
3069
|
+
const points = ITERM2_CELL_SIZE_REPLY.exec(data);
|
|
3070
|
+
if (!points) return false;
|
|
3071
|
+
const scale = points[3] ? Number(points[3]) : 1;
|
|
3072
|
+
heightPx = Math.round(Number(points[1]) * scale);
|
|
3073
|
+
widthPx = Math.round(Number(points[2]) * scale);
|
|
3058
3074
|
}
|
|
3059
|
-
|
|
3060
|
-
const heightPx = parseInt(match[1], 10);
|
|
3061
|
-
const widthPx = parseInt(match[2], 10);
|
|
3062
3075
|
if (heightPx <= 0 || widthPx <= 0) {
|
|
3063
3076
|
return true;
|
|
3064
3077
|
}
|