@oh-my-pi/pi-tui 18.6.3 → 18.7.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/CHANGELOG.md +26 -58
- package/dist/types/app-keybindings.d.ts +1 -1
- package/dist/types/autocomplete.d.ts +5 -2
- package/dist/types/charts/chart-plan.d.ts +92 -0
- package/dist/types/charts/chart-svg.d.ts +30 -0
- package/dist/types/charts/table-data.d.ts +107 -0
- package/dist/types/chat/assistant-message.d.ts +4 -0
- package/dist/types/chat/chat-transcript-builder.d.ts +2 -0
- package/dist/types/chat/figure-markdown.d.ts +42 -0
- package/dist/types/chat/svg-figure.d.ts +33 -0
- package/dist/types/chat/svg-source.d.ts +49 -0
- package/dist/types/chat/table-chart.d.ts +88 -0
- package/dist/types/components/editor.d.ts +1 -1
- package/dist/types/components/image.d.ts +9 -0
- package/dist/types/components/markdown.d.ts +9 -0
- package/dist/types/fuzzy.d.ts +14 -0
- package/dist/types/keybindings.d.ts +1 -1
- package/dist/types/overlays/agents-hub.d.ts +9 -0
- package/dist/types/overlays/annotation-overlay.d.ts +5 -2
- package/dist/types/overlays/model-browser.d.ts +25 -3
- package/dist/types/overlays/plan-review-overlay.d.ts +3 -2
- package/dist/types/prompt/interactive-context-helpers.d.ts +2 -0
- package/dist/types/prompt/model-mention-autocomplete.d.ts +1 -1
- package/dist/types/render/composite.d.ts +2 -0
- package/dist/types/tools/glob.d.ts +4 -0
- package/dist/types/utils.d.ts +6 -0
- package/package.json +9 -9
- package/src/autocomplete.ts +8 -10
- package/src/charts/chart-plan.ts +367 -0
- package/src/charts/chart-svg.ts +693 -0
- package/src/charts/table-data.ts +436 -0
- package/src/chat/assistant-message.ts +139 -21
- package/src/chat/chat-transcript-builder.ts +4 -0
- package/src/chat/figure-markdown.ts +174 -0
- package/src/chat/svg-figure.ts +364 -0
- package/src/chat/svg-source.ts +188 -0
- package/src/chat/table-chart.ts +301 -0
- package/src/chrome/transcript-container.ts +7 -1
- package/src/components/editor.ts +52 -30
- package/src/components/image.ts +27 -1
- package/src/components/markdown.ts +6 -3
- package/src/fuzzy.ts +59 -19
- package/src/keybindings.ts +1 -1
- package/src/overlays/agent-transcript-viewer.ts +2 -0
- package/src/overlays/agents-hub.ts +14 -1
- package/src/overlays/annotation-overlay.ts +179 -80
- package/src/overlays/ask-dialog.ts +1 -1
- package/src/overlays/model-browser.ts +169 -76
- package/src/overlays/plan-review-overlay.ts +41 -4
- package/src/prompt/composer.ts +2 -14
- package/src/prompt/interactive-context-helpers.ts +3 -0
- package/src/prompt/model-mention-autocomplete.ts +14 -14
- package/src/render/composite.ts +62 -0
- package/src/status-line/segments.ts +3 -1
- package/src/tools/glob.ts +23 -6
- package/src/tui.ts +118 -105
- package/src/utils.ts +19 -0
package/src/tools/glob.ts
CHANGED
|
@@ -29,6 +29,10 @@ export interface GlobToolDetails {
|
|
|
29
29
|
fileCount?: number;
|
|
30
30
|
files?: string[];
|
|
31
31
|
truncated?: boolean;
|
|
32
|
+
/** The scan hit the tool deadline, so the listed files are an incomplete
|
|
33
|
+
* set rather than a complete answer. Distinct from `truncated`, which also
|
|
34
|
+
* covers result-limit and output truncation. */
|
|
35
|
+
timedOut?: boolean;
|
|
32
36
|
error?: string;
|
|
33
37
|
/** Working directory at search time. Used by the renderer to resolve relative
|
|
34
38
|
* file paths to absolute paths for OSC 8 hyperlinks. */
|
|
@@ -184,6 +188,10 @@ export const globToolRenderer = {
|
|
|
184
188
|
const truncation = details?.truncation ?? details?.meta?.truncation;
|
|
185
189
|
const limits = details?.meta?.limits;
|
|
186
190
|
const truncated = Boolean(details?.truncated || truncation || details?.resultLimitReached || limits?.resultLimit);
|
|
191
|
+
// A non-empty timed-out result is the case `truncated` alone cannot
|
|
192
|
+
// explain: the listing is short because the scan died, not because it
|
|
193
|
+
// finished. Say which one it was.
|
|
194
|
+
const timedOut = Boolean(details?.timedOut);
|
|
187
195
|
const files = details?.files ?? [];
|
|
188
196
|
|
|
189
197
|
const missingPaths = details?.missingPaths ?? [];
|
|
@@ -192,15 +200,18 @@ export const globToolRenderer = {
|
|
|
192
200
|
|
|
193
201
|
if (fileCount === 0) {
|
|
194
202
|
// `truncated` on an empty result means the scan timed out mid-walk —
|
|
195
|
-
// render "incomplete", not a definitive "No files found".
|
|
196
|
-
|
|
203
|
+
// render "incomplete", not a definitive "No files found". `timedOut`
|
|
204
|
+
// states it outright; the inference stays for old transcripts that
|
|
205
|
+
// predate the field.
|
|
206
|
+
const emptyTimedOut = timedOut || truncated;
|
|
207
|
+
const emptyLabel = emptyTimedOut ? "No matches before timeout (scan incomplete)" : "No files found";
|
|
197
208
|
const header = renderStatusLine(
|
|
198
209
|
{
|
|
199
210
|
icon: "warning",
|
|
200
211
|
title: "Glob",
|
|
201
212
|
titleColor: "toolTitle",
|
|
202
213
|
description: formatGlobRenderPaths(args),
|
|
203
|
-
meta:
|
|
214
|
+
meta: emptyTimedOut ? ["0 files", uiTheme.fg("warning", "timed out")] : ["0 files"],
|
|
204
215
|
},
|
|
205
216
|
uiTheme,
|
|
206
217
|
);
|
|
@@ -210,7 +221,7 @@ export const globToolRenderer = {
|
|
|
210
221
|
}
|
|
211
222
|
const meta: string[] = [formatCount("file", fileCount)];
|
|
212
223
|
if (details?.scopePath) meta.push(`in ${details.scopePath}`);
|
|
213
|
-
if (truncated) meta.push(uiTheme.fg("warning", "truncated"));
|
|
224
|
+
if (truncated) meta.push(uiTheme.fg("warning", timedOut ? "timed out" : "truncated"));
|
|
214
225
|
const header = renderStatusLine(
|
|
215
226
|
{
|
|
216
227
|
...(truncated ? { icon: "warning" as const } : { iconOverride: globStatusIcon(uiTheme) }),
|
|
@@ -294,6 +305,7 @@ export const globToolRenderer = {
|
|
|
294
305
|
const truncation = details.truncation ?? details.meta?.truncation;
|
|
295
306
|
const limits = details.meta?.limits;
|
|
296
307
|
const truncated = Boolean(details.truncated || truncation || details.resultLimitReached || limits?.resultLimit);
|
|
308
|
+
const timedOut = Boolean(details.timedOut);
|
|
297
309
|
const missingPaths = details.missingPaths ?? [];
|
|
298
310
|
const missingNote = missingPaths.length > 0 ? `skipped missing: ${missingPaths.join(", ")}` : undefined;
|
|
299
311
|
const scope = details.scopePath ? `in ${details.scopePath}` : undefined;
|
|
@@ -301,7 +313,10 @@ export const globToolRenderer = {
|
|
|
301
313
|
const foot = footnoteText(compact([missingNote]));
|
|
302
314
|
return {
|
|
303
315
|
// `truncated` on an empty result means the scan timed out mid-walk.
|
|
304
|
-
tool: {
|
|
316
|
+
tool: {
|
|
317
|
+
...globNativeHead(args, compact(["0 files", scope])),
|
|
318
|
+
note: timedOut || truncated ? "timed out" : undefined,
|
|
319
|
+
},
|
|
305
320
|
tone: "warning",
|
|
306
321
|
inline: true,
|
|
307
322
|
body: foot ? [foot] : undefined,
|
|
@@ -315,7 +330,9 @@ export const globToolRenderer = {
|
|
|
315
330
|
if (artifactId) reasons.push(formatFullOutputReference(artifactId));
|
|
316
331
|
const head = globNativeHead(args, compact([formatCount("file", details.fileCount), scope]));
|
|
317
332
|
return {
|
|
318
|
-
tool: truncated
|
|
333
|
+
tool: truncated
|
|
334
|
+
? { ...head, badges: [{ text: timedOut ? "timed out" : "truncated", tone: "warning" }] }
|
|
335
|
+
: head,
|
|
319
336
|
inline: true,
|
|
320
337
|
body: compact<NativeChild>([
|
|
321
338
|
describeGlobFiles(details.files ?? [], details.cwd),
|
package/src/tui.ts
CHANGED
|
@@ -45,15 +45,14 @@ import {
|
|
|
45
45
|
TERMINAL,
|
|
46
46
|
} from "./terminal-capabilities";
|
|
47
47
|
import { classifyTerminalMultiplexer } from "./terminal-multiplexer";
|
|
48
|
+
import { compositeLineAt } from "./render/composite";
|
|
48
49
|
import {
|
|
49
50
|
Ellipsis,
|
|
50
|
-
extractSegments,
|
|
51
51
|
getWidthConfigEpoch,
|
|
52
52
|
isOsc66Line,
|
|
53
53
|
normalizeTerminalOutput,
|
|
54
54
|
osc66MaxScale,
|
|
55
55
|
sliceByColumn,
|
|
56
|
-
sliceWithWidth,
|
|
57
56
|
truncateToWidth,
|
|
58
57
|
visibleWidth,
|
|
59
58
|
} from "./utils";
|
|
@@ -942,8 +941,8 @@ export class TUI extends Container {
|
|
|
942
941
|
// settle window, leaving only Unicode placeholder cells. Hold the first image
|
|
943
942
|
// paint until that window has passed; later images render normally.
|
|
944
943
|
static readonly #GHOSTTY_INITIAL_IMAGE_DELAY_MS = 100;
|
|
945
|
-
#hardwareCursorRow = 0; //
|
|
946
|
-
#hardwareCursorState: HardwareCursorState | null = null;
|
|
944
|
+
#hardwareCursorRow = 0; // Normal-buffer cursor row, retained while the alternate buffer is active.
|
|
945
|
+
#hardwareCursorState: HardwareCursorState | null = null; // Current buffer's cursor paint/dedupe state.
|
|
947
946
|
#sixelProbePendingGraphics = false;
|
|
948
947
|
#sixelProbeBuffer = "";
|
|
949
948
|
#sixelProbeTimeout?: NodeJS.Timeout;
|
|
@@ -1005,6 +1004,11 @@ export class TUI extends Container {
|
|
|
1005
1004
|
#resizeInPlaceActive = false;
|
|
1006
1005
|
#resizeScrollbackMode: ResizeScrollbackMode = TUI.#initialResizeScrollbackMode();
|
|
1007
1006
|
#resizeReplaySize: string | undefined;
|
|
1007
|
+
// The resize borrow's entry (live-viewport erase, CSI ?1049h, keyboard
|
|
1008
|
+
// push), held for the first resize frame's synchronized update. Written on
|
|
1009
|
+
// its own, the terminal can present the blank alternate screen for a frame
|
|
1010
|
+
// before the resize frame lands.
|
|
1011
|
+
#pendingAltEnter = "";
|
|
1008
1012
|
// Holds an alternate-screen exit until its replacement full paint can emit it
|
|
1009
1013
|
// atomically. It must survive a deferred Ghostty image frame.
|
|
1010
1014
|
#pendingAltExit = "";
|
|
@@ -1650,7 +1654,7 @@ export class TUI extends Container {
|
|
|
1650
1654
|
this.#resizeSettleTimer?.cancel();
|
|
1651
1655
|
this.#resizeSettleTimer = undefined;
|
|
1652
1656
|
if (this.#altActive || this.#resizeAltActive) {
|
|
1653
|
-
this.terminal.write(`${this.#keyboardEnhancementExit()}\x1b[?1049l`);
|
|
1657
|
+
this.terminal.write(`${this.#takePendingAltEnter()}${this.#keyboardEnhancementExit()}\x1b[?1049l`);
|
|
1654
1658
|
setAltScreenActive(false);
|
|
1655
1659
|
this.#altActive = false;
|
|
1656
1660
|
this.#resizeAltActive = false;
|
|
@@ -1801,7 +1805,9 @@ export class TUI extends Container {
|
|
|
1801
1805
|
this.#resizeSettleTimer?.cancel();
|
|
1802
1806
|
this.#forgetHardwareCursorState();
|
|
1803
1807
|
this.#recordHardwareCursorHidden();
|
|
1804
|
-
|
|
1808
|
+
const erase = this.#liveViewportResizeErase();
|
|
1809
|
+
if (erase !== "") {
|
|
1810
|
+
this.terminal.write(erase);
|
|
1805
1811
|
// The erase parked the hardware cursor on the viewport's top row;
|
|
1806
1812
|
// snapshot the parked offset so the settled probe anchors there.
|
|
1807
1813
|
this.#parkedViewportOffset = 0;
|
|
@@ -1836,13 +1842,14 @@ export class TUI extends Container {
|
|
|
1836
1842
|
* cursor-relative addressing would start rows late; fall back to the same
|
|
1837
1843
|
* bottom-preserving bound as resize-anchor recovery.
|
|
1838
1844
|
*
|
|
1839
|
-
* Both erase paths leave the cursor on the viewport's top row. Returns
|
|
1840
|
-
*
|
|
1841
|
-
* re-layout and blanks pulled-back
|
|
1845
|
+
* Both erase paths leave the cursor on the viewport's top row. Returns the
|
|
1846
|
+
* erase sequence, or "" when there is nothing to erase (multiplexers skip
|
|
1847
|
+
* it: an immediate erase races the pane re-layout and blanks pulled-back
|
|
1848
|
+
* committed rows).
|
|
1842
1849
|
*/
|
|
1843
|
-
#
|
|
1850
|
+
#liveViewportResizeErase(): string {
|
|
1844
1851
|
if (!this.#hasEverRendered || this.#providerWindow.length === 0 || isInsideTerminalMultiplexer()) {
|
|
1845
|
-
return
|
|
1852
|
+
return "";
|
|
1846
1853
|
}
|
|
1847
1854
|
if (this.terminal.rows < this.#previousHeight) {
|
|
1848
1855
|
const staleRows = this.#reflowedRowCount(
|
|
@@ -1852,13 +1859,11 @@ export class TUI extends Container {
|
|
|
1852
1859
|
this.terminal.columns,
|
|
1853
1860
|
);
|
|
1854
1861
|
const top = Math.max(0, Math.min(this.#providerViewportTop, this.terminal.rows - staleRows));
|
|
1855
|
-
|
|
1856
|
-
} else {
|
|
1857
|
-
const up = this.#reflowedRowCount(this.#providerWindow, 0, this.#parkedViewportOffset, this.terminal.columns);
|
|
1858
|
-
const eraseBelow = this.#eraseBelowCursorRow(this.terminal.columns, this.terminal.rows);
|
|
1859
|
-
this.terminal.write(`\x1b[?25l${up > 0 ? `\x1b[${up}A` : ""}${eraseBelow}`);
|
|
1862
|
+
return `\x1b[?25l${this.#eraseBelowRow(top, this.terminal.rows)}`;
|
|
1860
1863
|
}
|
|
1861
|
-
|
|
1864
|
+
const up = this.#reflowedRowCount(this.#providerWindow, 0, this.#parkedViewportOffset, this.terminal.columns);
|
|
1865
|
+
const eraseBelow = this.#eraseBelowCursorRow(this.terminal.columns, this.terminal.rows);
|
|
1866
|
+
return `\x1b[?25l${up > 0 ? `\x1b[${up}A` : ""}${eraseBelow}`;
|
|
1862
1867
|
}
|
|
1863
1868
|
|
|
1864
1869
|
/**
|
|
@@ -1889,8 +1894,12 @@ export class TUI extends Container {
|
|
|
1889
1894
|
this.#altPreparedRows = [];
|
|
1890
1895
|
this.#forgetHardwareCursorState();
|
|
1891
1896
|
this.#recordHardwareCursorHidden();
|
|
1892
|
-
// Blank the live region
|
|
1893
|
-
// committed rows into scrollback. The
|
|
1897
|
+
// Blank the live region as the borrow enters so a reflow-driven scroll
|
|
1898
|
+
// can only push committed rows into scrollback. The erase is computed
|
|
1899
|
+
// against this SIGWINCH's geometry but rides in the first resize frame's
|
|
1900
|
+
// synchronized update with the buffer switch (#pendingAltEnter), so
|
|
1901
|
+
// neither the blanked normal screen nor the empty alternate one is ever
|
|
1902
|
+
// presented. The pre-erase window is stashed for
|
|
1894
1903
|
// the settled CPR probe: its reflowed row count bounds the anchor to
|
|
1895
1904
|
// `height - staleRows`, so a mis-parked cursor (a single-step tmux zoom
|
|
1896
1905
|
// re-lays the pane before SIGWINCH delivery, moving the park target
|
|
@@ -1900,8 +1909,9 @@ export class TUI extends Container {
|
|
|
1900
1909
|
this.#resizeProbeWindow = this.#providerWindow;
|
|
1901
1910
|
this.#resizeProbeOffset = this.#parkedViewportOffset;
|
|
1902
1911
|
}
|
|
1903
|
-
|
|
1904
|
-
|
|
1912
|
+
const erase = this.#liveViewportResizeErase();
|
|
1913
|
+
if (erase !== "") {
|
|
1914
|
+
// The erase parks the cursor on the viewport's top row, so the
|
|
1905
1915
|
// parked offset no longer applies; carrying a stale nonzero offset
|
|
1906
1916
|
// into the probe would anchor the settled repaint above the real
|
|
1907
1917
|
// viewport top and overwrite visible committed rows.
|
|
@@ -1924,7 +1934,7 @@ export class TUI extends Container {
|
|
|
1924
1934
|
}
|
|
1925
1935
|
this.#noteAltBufferToggle();
|
|
1926
1936
|
this.#imageBudget.beginAltScreenLifecycle();
|
|
1927
|
-
this
|
|
1937
|
+
this.#pendingAltEnter = `${erase}\x1b[?1049h${this.#keyboardEnhancementEnter()}`;
|
|
1928
1938
|
}
|
|
1929
1939
|
this.#resizeSettleTimer?.cancel();
|
|
1930
1940
|
this.#resizeSettleTimer = this.#renderScheduler.scheduleRender(() => {
|
|
@@ -1963,6 +1973,8 @@ export class TUI extends Container {
|
|
|
1963
1973
|
this.#prepareResizeReplay(this.terminal.columns, this.terminal.rows);
|
|
1964
1974
|
}
|
|
1965
1975
|
if (this.#clearScrollbackOnNextRender) {
|
|
1976
|
+
// An entry no resize frame carried yet rides ahead of this exit in
|
|
1977
|
+
// the rebuild frame (see #renderProviderFrame).
|
|
1966
1978
|
this.#pendingAltExit = exitSequence;
|
|
1967
1979
|
this.#resizeAltExitFused = true;
|
|
1968
1980
|
this.requestRender(true);
|
|
@@ -1970,10 +1982,22 @@ export class TUI extends Container {
|
|
|
1970
1982
|
}
|
|
1971
1983
|
}
|
|
1972
1984
|
this.#noteAltBufferToggle();
|
|
1973
|
-
this.terminal.write(exitSequence);
|
|
1985
|
+
this.terminal.write(this.#takePendingAltEnter() + exitSequence);
|
|
1974
1986
|
setAltScreenActive(false);
|
|
1975
1987
|
this.#beginResizeAnchorProbe();
|
|
1976
1988
|
}
|
|
1989
|
+
/**
|
|
1990
|
+
* Claim the resize borrow's entry for the write that carries it. A borrow
|
|
1991
|
+
* that ends before any frame painted (output backpressure deferred them)
|
|
1992
|
+
* still owes its entry, so the exit write prepends it: one write, never a
|
|
1993
|
+
* bare buffer switch the terminal could present.
|
|
1994
|
+
*/
|
|
1995
|
+
#takePendingAltEnter(): string {
|
|
1996
|
+
const enter = this.#pendingAltEnter;
|
|
1997
|
+
this.#pendingAltEnter = "";
|
|
1998
|
+
return enter;
|
|
1999
|
+
}
|
|
2000
|
+
|
|
1977
2001
|
/**
|
|
1978
2002
|
* Recover the reflowed viewport anchor after the resize settle window ends.
|
|
1979
2003
|
* The terminal reflowed the restored normal buffer during the drag, so
|
|
@@ -2435,7 +2459,7 @@ export class TUI extends Container {
|
|
|
2435
2459
|
this.#cancelResizeProbe();
|
|
2436
2460
|
if (this.#resizeAltActive) {
|
|
2437
2461
|
this.#resizeAltActive = false;
|
|
2438
|
-
this.terminal.write(`${this.#keyboardEnhancementExit()}\x1b[?1049l`);
|
|
2462
|
+
this.terminal.write(`${this.#takePendingAltEnter()}${this.#keyboardEnhancementExit()}\x1b[?1049l`);
|
|
2439
2463
|
setAltScreenActive(false);
|
|
2440
2464
|
}
|
|
2441
2465
|
if (this.#altActive || this.#pendingAltExit) {
|
|
@@ -2446,7 +2470,7 @@ export class TUI extends Container {
|
|
|
2446
2470
|
// so the final OFF goes last or the shell keeps reporting.
|
|
2447
2471
|
const mouseExit = this.#mouseTracking !== "off" ? MOUSE_TRACKING_OFF : "";
|
|
2448
2472
|
const exitSequence = this.#pendingAltExit
|
|
2449
|
-
? `${this.#pendingAltExit}${mouseExit}`
|
|
2473
|
+
? `${this.#takePendingAltEnter()}${this.#pendingAltExit}${mouseExit}`
|
|
2450
2474
|
: `${mouseExit}${this.#keyboardEnhancementExit()}\x1b[?1049l`;
|
|
2451
2475
|
this.terminal.write(exitSequence);
|
|
2452
2476
|
setAltScreenActive(false);
|
|
@@ -3009,70 +3033,12 @@ export class TUI extends Container {
|
|
|
3009
3033
|
if (idx < 0 || idx >= result.length) continue;
|
|
3010
3034
|
const truncatedOverlayLine =
|
|
3011
3035
|
visibleWidth(overlayLines[i]) > width ? sliceByColumn(overlayLines[i], 0, width, true) : overlayLines[i];
|
|
3012
|
-
result[idx] =
|
|
3036
|
+
result[idx] = compositeLineAt(result[idx], truncatedOverlayLine, col, width, termWidth);
|
|
3013
3037
|
}
|
|
3014
3038
|
}
|
|
3015
3039
|
return result;
|
|
3016
3040
|
}
|
|
3017
3041
|
|
|
3018
|
-
/** Splice overlay content into a base line at a specific column. Single-pass optimized. */
|
|
3019
|
-
#compositeLineAt(
|
|
3020
|
-
baseLine: string,
|
|
3021
|
-
overlayLine: string,
|
|
3022
|
-
startCol: number,
|
|
3023
|
-
overlayWidth: number,
|
|
3024
|
-
totalWidth: number,
|
|
3025
|
-
): string {
|
|
3026
|
-
if (TERMINAL.isImageLine(baseLine)) {
|
|
3027
|
-
// Full-width overlays such as /switch are opaque: replace the
|
|
3028
|
-
// Unicode placeholder cells so the image cannot cover the modal.
|
|
3029
|
-
// Partial overlays cannot safely splice placement control sequences.
|
|
3030
|
-
if (startCol !== 0 || overlayWidth < totalWidth) return baseLine;
|
|
3031
|
-
const overlay = sliceWithWidth(overlayLine, 0, totalWidth, true);
|
|
3032
|
-
return SEGMENT_RESET + overlay.text + " ".repeat(Math.max(0, totalWidth - overlay.width));
|
|
3033
|
-
}
|
|
3034
|
-
|
|
3035
|
-
// Single pass through baseLine extracts both before and after segments
|
|
3036
|
-
const afterStart = startCol + overlayWidth;
|
|
3037
|
-
const base = extractSegments(baseLine, startCol, afterStart, totalWidth - afterStart, true);
|
|
3038
|
-
|
|
3039
|
-
// Extract overlay with width tracking (strict=true to exclude wide chars at boundary)
|
|
3040
|
-
const overlay = sliceWithWidth(overlayLine, 0, overlayWidth, true);
|
|
3041
|
-
|
|
3042
|
-
// Pad segments to target widths
|
|
3043
|
-
const beforePad = Math.max(0, startCol - base.beforeWidth);
|
|
3044
|
-
const overlayPad = Math.max(0, overlayWidth - overlay.width);
|
|
3045
|
-
const actualBeforeWidth = Math.max(startCol, base.beforeWidth);
|
|
3046
|
-
const actualOverlayWidth = Math.max(overlayWidth, overlay.width);
|
|
3047
|
-
const afterTarget = Math.max(0, totalWidth - actualBeforeWidth - actualOverlayWidth);
|
|
3048
|
-
const afterPad = Math.max(0, afterTarget - base.afterWidth);
|
|
3049
|
-
|
|
3050
|
-
// Compose result
|
|
3051
|
-
const r = SEGMENT_RESET;
|
|
3052
|
-
const result =
|
|
3053
|
-
base.before +
|
|
3054
|
-
" ".repeat(beforePad) +
|
|
3055
|
-
r +
|
|
3056
|
-
overlay.text +
|
|
3057
|
-
" ".repeat(overlayPad) +
|
|
3058
|
-
r +
|
|
3059
|
-
base.after +
|
|
3060
|
-
" ".repeat(afterPad);
|
|
3061
|
-
|
|
3062
|
-
// CRITICAL: Always verify and truncate to terminal width.
|
|
3063
|
-
// This is the final safeguard against width overflow which would crash the TUI.
|
|
3064
|
-
// Width tracking can drift from actual visible width due to:
|
|
3065
|
-
// - Complex ANSI/OSC sequences (hyperlinks, colors)
|
|
3066
|
-
// - Wide characters at segment boundaries
|
|
3067
|
-
// - Edge cases in segment extraction
|
|
3068
|
-
const resultWidth = visibleWidth(result);
|
|
3069
|
-
if (resultWidth <= totalWidth) {
|
|
3070
|
-
return result;
|
|
3071
|
-
}
|
|
3072
|
-
// Truncate with strict=true to ensure we don't exceed totalWidth
|
|
3073
|
-
return sliceByColumn(result, 0, totalWidth, true);
|
|
3074
|
-
}
|
|
3075
|
-
|
|
3076
3042
|
/**
|
|
3077
3043
|
* Rewrite a Kitty direct-placement line for the viewport row it is written
|
|
3078
3044
|
* at, clipping to the visible slice (see {@link encodeKittyPlacementLine})
|
|
@@ -3348,7 +3314,8 @@ export class TUI extends Container {
|
|
|
3348
3314
|
const startTop = destructiveReset ? 0 : Math.min(this.#providerViewportTop, Math.max(0, height - 1));
|
|
3349
3315
|
const newTop = Math.max(0, Math.min(startTop + historyRows.length, height - rows));
|
|
3350
3316
|
const pendingAltExit = this.#pendingAltExit;
|
|
3351
|
-
|
|
3317
|
+
// A fused resize exit whose borrow never painted still owes its entry.
|
|
3318
|
+
let buffer = this.#paintBeginSequence + (pendingAltExit ? this.#takePendingAltEnter() : "") + pendingAltExit;
|
|
3352
3319
|
const renewSync =
|
|
3353
3320
|
destructiveReset &&
|
|
3354
3321
|
this.#resizeScrollbackMode === "rebuild" &&
|
|
@@ -4184,9 +4151,9 @@ export class TUI extends Container {
|
|
|
4184
4151
|
|
|
4185
4152
|
/**
|
|
4186
4153
|
* Compose and paint a single fullscreen overlay frame on the alt buffer.
|
|
4187
|
-
* Cursor markers are stripped
|
|
4188
|
-
*
|
|
4189
|
-
*
|
|
4154
|
+
* Cursor markers are stripped from the frame, but only a focused cursor-mode
|
|
4155
|
+
* component owned by the fullscreen overlay may position the hardware cursor.
|
|
4156
|
+
* The normal transcript is never composited into the alternate buffer.
|
|
4190
4157
|
*/
|
|
4191
4158
|
#renderAltFrame(width: number, height: number): void {
|
|
4192
4159
|
// oxlint-disable-next-line unicorn/no-new-array -- alt-frame length preallocation
|
|
@@ -4196,8 +4163,18 @@ export class TUI extends Container {
|
|
|
4196
4163
|
this.#imageBudget.beginPass(false, true);
|
|
4197
4164
|
lines = this.#compositeOverlaysIntoWindow(base, width, height);
|
|
4198
4165
|
} while (this.#imageBudget.endPass());
|
|
4199
|
-
const
|
|
4200
|
-
this.#
|
|
4166
|
+
const markers: { row: number; col: number }[] = [];
|
|
4167
|
+
const prepared = this.#prepareLinesArray(lines, width, this.#altPreparedRows, height, markers);
|
|
4168
|
+
const topOverlay = this.#getTopmostVisibleOverlay();
|
|
4169
|
+
const focused = this.#focusedComponent;
|
|
4170
|
+
const acceptsTerminalCursor =
|
|
4171
|
+
topOverlay !== undefined &&
|
|
4172
|
+
focused !== null &&
|
|
4173
|
+
isFocusable(focused) &&
|
|
4174
|
+
focused.focused &&
|
|
4175
|
+
typeof focused.setUseTerminalCursor === "function" &&
|
|
4176
|
+
isOverlayFocusTarget(topOverlay.component, focused);
|
|
4177
|
+
this.#emitAltFrame(prepared, width, height, true, acceptsTerminalCursor ? (markers[0] ?? null) : null);
|
|
4201
4178
|
}
|
|
4202
4179
|
|
|
4203
4180
|
/**
|
|
@@ -4205,9 +4182,15 @@ export class TUI extends Container {
|
|
|
4205
4182
|
* previous frame, or every row when the height changed, a repaint is forced,
|
|
4206
4183
|
* or a changed frame holds OSC 66 text before or after. Emits only
|
|
4207
4184
|
* sync-output brackets, cursor moves, and per-row rewrites — never ED3 or
|
|
4208
|
-
* any native-scrollback byte.
|
|
4185
|
+
* any native-scrollback byte.
|
|
4209
4186
|
*/
|
|
4210
|
-
#emitAltFrame(
|
|
4187
|
+
#emitAltFrame(
|
|
4188
|
+
prepared: PreparedLines,
|
|
4189
|
+
width: number,
|
|
4190
|
+
height: number,
|
|
4191
|
+
notifyPaint: boolean,
|
|
4192
|
+
cursorPosition: { row: number; col: number } | null = null,
|
|
4193
|
+
): void {
|
|
4211
4194
|
// The pass that composed this frame ran with `altScreen`, so the normal
|
|
4212
4195
|
// screen's own placements behind it are not treated as retired.
|
|
4213
4196
|
this.#imageBudget.limitResidentImages();
|
|
@@ -4216,17 +4199,14 @@ export class TUI extends Container {
|
|
|
4216
4199
|
// frame resolve against loaded data. The normal-screen path flushes these
|
|
4217
4200
|
// ahead of its paint; without this, an image first shown inside a
|
|
4218
4201
|
// fullscreen overlay (e.g. the settings shape preview) would render as
|
|
4219
|
-
// blank placeholder cells until the overlay closed.
|
|
4202
|
+
// blank placeholder cells until the overlay closed. A pending resize-borrow
|
|
4203
|
+
// entry leads: the image commands address the alternate buffer's store.
|
|
4204
|
+
let prelude = this.#takePendingAltEnter();
|
|
4220
4205
|
const purgeIds = this.#imageBudget.takePurgeIds();
|
|
4221
4206
|
if (TERMINAL.imageProtocol === ImageProtocol.Kitty) {
|
|
4222
|
-
for (const id of purgeIds)
|
|
4223
|
-
}
|
|
4224
|
-
const imageTransmits = this.#imageBudget.takeTransmits();
|
|
4225
|
-
if (imageTransmits.length > 0) {
|
|
4226
|
-
let transmitBuffer = "";
|
|
4227
|
-
for (const seq of imageTransmits) transmitBuffer += seq;
|
|
4228
|
-
this.terminal.write(transmitBuffer);
|
|
4207
|
+
for (const id of purgeIds) prelude += encodeKittyDeleteImage(id);
|
|
4229
4208
|
}
|
|
4209
|
+
for (const seq of this.#imageBudget.takeTransmits()) prelude += seq;
|
|
4230
4210
|
// A forced repaint (resetDisplay, requestRender(true)) rewrites every row
|
|
4231
4211
|
// even when the cached frame is byte-identical: the redraw gesture must
|
|
4232
4212
|
// repair a corrupted modal. So does a changed frame with OSC 66 text in
|
|
@@ -4265,10 +4245,38 @@ export class TUI extends Container {
|
|
|
4265
4245
|
}
|
|
4266
4246
|
this.#altPreviousLines = prepared.lines;
|
|
4267
4247
|
this.#altPreparedRows = prepared.rows;
|
|
4268
|
-
|
|
4269
|
-
|
|
4270
|
-
|
|
4271
|
-
|
|
4248
|
+
const target = this.#targetHardwareCursorState(cursorPosition, height);
|
|
4249
|
+
const previousCursor = this.#hardwareCursorState;
|
|
4250
|
+
const cursorChanged =
|
|
4251
|
+
target === null
|
|
4252
|
+
? previousCursor?.visible === true
|
|
4253
|
+
: previousCursor === null ||
|
|
4254
|
+
previousCursor.row !== target.row ||
|
|
4255
|
+
previousCursor.col !== target.col ||
|
|
4256
|
+
previousCursor.visible !== target.visible;
|
|
4257
|
+
const placeCursor = target !== null && (rowsBuffer !== "" || cursorChanged);
|
|
4258
|
+
const hideCursor = target === null && previousCursor?.visible === true;
|
|
4259
|
+
if (rowsBuffer === "" && !placeCursor && !hideCursor) {
|
|
4260
|
+
if (prelude !== "") this.terminal.write(prelude);
|
|
4261
|
+
return;
|
|
4262
|
+
}
|
|
4263
|
+
|
|
4264
|
+
let cursorBuffer = "";
|
|
4265
|
+
if (placeCursor && target !== null) {
|
|
4266
|
+
cursorBuffer = `\x1b[${target.row + 1};${target.col + 1}H${target.visible ? "\x1b[?25h" : "\x1b[?25l"}`;
|
|
4267
|
+
} else if (hideCursor) {
|
|
4268
|
+
cursorBuffer = "\x1b[?25l";
|
|
4269
|
+
}
|
|
4270
|
+
this.terminal.write(
|
|
4271
|
+
`${this.#paintBeginSequence}${prelude}${full ? "\x1b[H" : ""}${rowsBuffer}${cursorBuffer}${this.#paintEndSequence}`,
|
|
4272
|
+
);
|
|
4273
|
+
this.#debugPaint = {
|
|
4274
|
+
lines: prepared.lines,
|
|
4275
|
+
windowTop: 0,
|
|
4276
|
+
altScreen: true,
|
|
4277
|
+
...(target === null ? {} : { cursor: { x: target.col, y: target.row, visible: target.visible } }),
|
|
4278
|
+
};
|
|
4279
|
+
if (notifyPaint && rowsBuffer !== "") {
|
|
4272
4280
|
this.#notifyPaint({
|
|
4273
4281
|
history: [],
|
|
4274
4282
|
viewport: prepared.lines,
|
|
@@ -4278,6 +4286,11 @@ export class TUI extends Container {
|
|
|
4278
4286
|
rows: height,
|
|
4279
4287
|
});
|
|
4280
4288
|
}
|
|
4281
|
-
if (full) this.#fullRedrawCount += 1;
|
|
4289
|
+
if (full && rowsBuffer !== "") this.#fullRedrawCount += 1;
|
|
4290
|
+
// DEC 1049 restores the normal-buffer cursor on exit. Track this buffer's
|
|
4291
|
+
// visibility and marker position without replacing that saved normal row,
|
|
4292
|
+
// which stop() and a late native-surface handshake use after the restore.
|
|
4293
|
+
if (target) this.#hardwareCursorState = target;
|
|
4294
|
+
else this.#recordHardwareCursorHidden();
|
|
4282
4295
|
}
|
|
4283
4296
|
}
|
package/src/utils.ts
CHANGED
|
@@ -154,6 +154,25 @@ export function sliceWithWidth(line: string, startCol: number, length: number, s
|
|
|
154
154
|
return nativeSliceWithWidth(line, startCol, length, strict ?? null, DEFAULT_TAB_WIDTH);
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
+
/**
|
|
158
|
+
* Hard-wrap one literal terminal row without trimming spaces or splitting graphemes.
|
|
159
|
+
* An oversized grapheme is emitted intact even when it exceeds the requested width.
|
|
160
|
+
* Zero-cell rows remain one empty row, matching restored-terminal reflow.
|
|
161
|
+
*/
|
|
162
|
+
export function wrapLiteralLine(line: string, width: number): string[] {
|
|
163
|
+
const columns = Math.max(1, width);
|
|
164
|
+
const lineWidth = visibleWidth(line);
|
|
165
|
+
if (lineWidth === 0) return [""];
|
|
166
|
+
const rows: string[] = [];
|
|
167
|
+
for (let column = 0; column < lineWidth;) {
|
|
168
|
+
let slice = sliceWithWidth(line, column, columns, true);
|
|
169
|
+
if (slice.width === 0) slice = sliceWithWidth(line, column, columns);
|
|
170
|
+
rows.push(slice.text);
|
|
171
|
+
column += Math.max(1, slice.width);
|
|
172
|
+
}
|
|
173
|
+
return rows;
|
|
174
|
+
}
|
|
175
|
+
|
|
157
176
|
export function truncateToWidth(
|
|
158
177
|
text: string,
|
|
159
178
|
maxWidth: number,
|