@oh-my-pi/pi-tui 18.12.0 → 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.
@@ -2,7 +2,7 @@ import { diffWords } from "@oh-my-pi/pi-natives";
2
2
  import { DEFAULT_TAB_WIDTH, sanitizeText } from "@oh-my-pi/pi-utils";
3
3
  import type { TspDiffHunk, TspProps } from "@oh-my-pi/pi-wire";
4
4
  import { theme as activeTheme, getLanguageFromPath, highlightCode, type Theme } from "../theme/index";
5
- import { type CodeFrameMarker, formatCodeFrameLine, replaceTabs, shortenPath } from "../render/render-utils";
5
+ import { type CodeFrameMarker, formatCodeFrameLine, replaceTabs } from "../render/render-utils";
6
6
  import { node } from "../native/describe";
7
7
  import type { NativeNode } from "../native/node";
8
8
  /** SGR dim on / normal intensity — additive, preserves fg/bg colors. */
@@ -106,9 +106,9 @@ function renderIntraLineDiff(
106
106
  * becomes one hunk; the terminal draws gutters, word emphasis and highlighting.
107
107
  * Removed rows carry old line numbers, added rows new ones and context rows
108
108
  * old ones, so the other side is derived from the running line delta.
109
+ * Tern picks the grammar from `path`.
109
110
  */
110
- export function nativeDiff(diffText: string, options: { filePath?: string } & TspProps<"diff"> = {}): NativeNode {
111
- const { filePath, ...props } = options;
111
+ export function nativeDiff(diffText: string, props: TspProps<"diff"> = {}): NativeNode {
112
112
  const hunks: TspDiffHunk[] = [];
113
113
  let lines: string[] = [];
114
114
  let oldStart = 0;
@@ -148,12 +148,7 @@ export function nativeDiff(diffText: string, options: { filePath?: string } & Ts
148
148
  lines.push(`${parsed.prefix}${parsed.content}`);
149
149
  }
150
150
  close();
151
- return node("diff", {
152
- ...props,
153
- hunks,
154
- path: props.path ?? (filePath ? shortenPath(filePath) : undefined),
155
- lang: props.lang ?? (filePath ? getLanguageFromPath(filePath) : undefined),
156
- });
151
+ return node("diff", { ...props, hunks });
157
152
  }
158
153
 
159
154
  export interface RenderDiffOptions {
@@ -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
- const rowLineCount = Math.max(...rowCellLines.map(c => c.length));
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,
@@ -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;