@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.
Files changed (57) hide show
  1. package/CHANGELOG.md +26 -58
  2. package/dist/types/app-keybindings.d.ts +1 -1
  3. package/dist/types/autocomplete.d.ts +5 -2
  4. package/dist/types/charts/chart-plan.d.ts +92 -0
  5. package/dist/types/charts/chart-svg.d.ts +30 -0
  6. package/dist/types/charts/table-data.d.ts +107 -0
  7. package/dist/types/chat/assistant-message.d.ts +4 -0
  8. package/dist/types/chat/chat-transcript-builder.d.ts +2 -0
  9. package/dist/types/chat/figure-markdown.d.ts +42 -0
  10. package/dist/types/chat/svg-figure.d.ts +33 -0
  11. package/dist/types/chat/svg-source.d.ts +49 -0
  12. package/dist/types/chat/table-chart.d.ts +88 -0
  13. package/dist/types/components/editor.d.ts +1 -1
  14. package/dist/types/components/image.d.ts +9 -0
  15. package/dist/types/components/markdown.d.ts +9 -0
  16. package/dist/types/fuzzy.d.ts +14 -0
  17. package/dist/types/keybindings.d.ts +1 -1
  18. package/dist/types/overlays/agents-hub.d.ts +9 -0
  19. package/dist/types/overlays/annotation-overlay.d.ts +5 -2
  20. package/dist/types/overlays/model-browser.d.ts +25 -3
  21. package/dist/types/overlays/plan-review-overlay.d.ts +3 -2
  22. package/dist/types/prompt/interactive-context-helpers.d.ts +2 -0
  23. package/dist/types/prompt/model-mention-autocomplete.d.ts +1 -1
  24. package/dist/types/render/composite.d.ts +2 -0
  25. package/dist/types/tools/glob.d.ts +4 -0
  26. package/dist/types/utils.d.ts +6 -0
  27. package/package.json +9 -9
  28. package/src/autocomplete.ts +8 -10
  29. package/src/charts/chart-plan.ts +367 -0
  30. package/src/charts/chart-svg.ts +693 -0
  31. package/src/charts/table-data.ts +436 -0
  32. package/src/chat/assistant-message.ts +139 -21
  33. package/src/chat/chat-transcript-builder.ts +4 -0
  34. package/src/chat/figure-markdown.ts +174 -0
  35. package/src/chat/svg-figure.ts +364 -0
  36. package/src/chat/svg-source.ts +188 -0
  37. package/src/chat/table-chart.ts +301 -0
  38. package/src/chrome/transcript-container.ts +7 -1
  39. package/src/components/editor.ts +52 -30
  40. package/src/components/image.ts +27 -1
  41. package/src/components/markdown.ts +6 -3
  42. package/src/fuzzy.ts +59 -19
  43. package/src/keybindings.ts +1 -1
  44. package/src/overlays/agent-transcript-viewer.ts +2 -0
  45. package/src/overlays/agents-hub.ts +14 -1
  46. package/src/overlays/annotation-overlay.ts +179 -80
  47. package/src/overlays/ask-dialog.ts +1 -1
  48. package/src/overlays/model-browser.ts +169 -76
  49. package/src/overlays/plan-review-overlay.ts +41 -4
  50. package/src/prompt/composer.ts +2 -14
  51. package/src/prompt/interactive-context-helpers.ts +3 -0
  52. package/src/prompt/model-mention-autocomplete.ts +14 -14
  53. package/src/render/composite.ts +62 -0
  54. package/src/status-line/segments.ts +3 -1
  55. package/src/tools/glob.ts +23 -6
  56. package/src/tui.ts +118 -105
  57. 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
- const emptyLabel = truncated ? "No matches before timeout (scan incomplete)" : "No files found";
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: truncated ? ["0 files", uiTheme.fg("warning", "timed out")] : ["0 files"],
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: { ...globNativeHead(args, compact(["0 files", scope])), note: truncated ? "timed out" : undefined },
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 ? { ...head, badges: [{ text: "truncated", tone: "warning" }] } : head,
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; // Actual terminal cursor row (may differ due to IME positioning)
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
- if (this.#eraseLiveViewportForResize()) {
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 true
1840
- * when it erased (multiplexers skip it: an immediate erase races the pane
1841
- * re-layout and blanks pulled-back committed rows).
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
- #eraseLiveViewportForResize(): boolean {
1850
+ #liveViewportResizeErase(): string {
1844
1851
  if (!this.#hasEverRendered || this.#providerWindow.length === 0 || isInsideTerminalMultiplexer()) {
1845
- return false;
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
- this.terminal.write(`\x1b[?25l${this.#eraseBelowRow(top, this.terminal.rows)}`);
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
- return true;
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 up front so a reflow-driven scroll can only push
1893
- // committed rows into scrollback. The pre-erase window is stashed for
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
- if (this.#eraseLiveViewportForResize()) {
1904
- // The erase parked the cursor on the viewport's top row, so the
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.terminal.write(`\x1b[?1049h${this.#keyboardEnhancementEnter()}`);
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] = this.#compositeLineAt(result[idx], truncatedOverlayLine, col, width, termWidth);
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
- let buffer = this.#paintBeginSequence + pendingAltExit;
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 (the modal draws its own in-band caret and
4188
- * keeps the hardware cursor hidden), and only the modal is composited over a
4189
- * blank base — the transcript is never touched while the alt buffer is up.
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 prepared = this.#prepareLinesArray(lines, width, this.#altPreparedRows, height, []);
4200
- this.#emitAltFrame(prepared, width, height, true);
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. The hardware cursor stays hidden here.
4185
+ * any native-scrollback byte.
4209
4186
  */
4210
- #emitAltFrame(prepared: PreparedLines, width: number, height: number, notifyPaint: boolean): void {
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) this.terminal.write(encodeKittyDeleteImage(id));
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
- if (rowsBuffer === "") return;
4269
- this.terminal.write(`${this.#paintBeginSequence}${full ? "\x1b[H" : ""}${rowsBuffer}${this.#paintEndSequence}`);
4270
- this.#debugPaint = { lines: prepared.lines, windowTop: 0, altScreen: true };
4271
- if (notifyPaint) {
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,