@linxiraos/pi-tui 1.3.0 → 1.3.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.
@@ -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": "@linxiraos/pi-tui",
4
- "version": "1.3.0",
4
+ "version": "1.3.2",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://linxira-os.github.io/zeta/",
7
7
  "author": "Linxira-OS",
@@ -38,14 +38,14 @@
38
38
  "gen:glyphs": "bun scripts/gen-glyphs.ts"
39
39
  },
40
40
  "dependencies": {
41
- "@linxiraos/pi-omptype": "1.3.0",
42
- "@linxiraos/pi-agent-core": "1.3.0",
43
- "@linxiraos/pi-ai": "1.3.0",
44
- "@linxiraos/pi-catalog": "1.3.0",
45
- "@linxiraos/pi-natives": "1.3.0",
46
- "@linxiraos/pi-utils": "1.3.0",
47
- "@linxiraos/pi-wire": "1.3.0",
48
- "@linxiraos/pi-snapcompact": "1.3.0"
41
+ "@linxiraos/pi-omptype": "1.3.2",
42
+ "@linxiraos/pi-agent-core": "1.3.2",
43
+ "@linxiraos/pi-ai": "1.3.2",
44
+ "@linxiraos/pi-catalog": "1.3.2",
45
+ "@linxiraos/pi-natives": "1.3.2",
46
+ "@linxiraos/pi-utils": "1.3.2",
47
+ "@linxiraos/pi-wire": "1.3.2",
48
+ "@linxiraos/pi-snapcompact": "1.3.2"
49
49
  },
50
50
  "devDependencies": {
51
51
  "kitty-vt-wasm": "^0.2.0"
@@ -148,7 +148,17 @@ export interface TableAnalysis {
148
148
  readonly composition?: Composition;
149
149
  }
150
150
 
151
- const NUMBER = String.raw`(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?(?:[eE][+-]?\d+)?|\.\d+`;
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.replaceAll(",", ""));
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 = /^\d[\d,]*$/.test(text.slice(text.indexOf("/") + 1, end).trim());
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 = /(\d[\d,]*)(?:\.(\d+))?/.exec(cell.figure);
890
+ const match = WRITTEN.exec(cell.figure);
880
891
  if (!match) return 0;
881
- const written = Number(`${match[1]!.replaceAll(",", "")}${match[2] ? `.${match[2]}` : ""}`);
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 = /^[~≈]?[$€£¥]?\d[\d,]*(?:\.\d+)?\s*(?:%|[A-Za-zµμ]+)?$/;
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
- 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 "@linxiraos/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,
@@ -162,6 +162,10 @@ const WATCHER_FAILURE_POLL_TTL_MS = 5000;
162
162
  const BRAND_FADE_MS = 450;
163
163
  /** Repaint cadence while the brand fade is in flight (rust zeta's `FADE_FRAME`). */
164
164
  const BRAND_FADE_FRAME_MS = 40;
165
+ /** Repaint cadence while a turn's active-processing window is open: keeps the
166
+ * status bar's clock-driven readouts (pi spinner, turn timer) animating when
167
+ * no other component is requesting renders. */
168
+ const ACTIVE_WINDOW_REPAINT_MS = 250;
165
169
 
166
170
  /**
167
171
  * Providers whose subscription quota is a single monthly bucket, so their
@@ -698,6 +702,8 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
698
702
  #gitWatcherUnavailable = false;
699
703
  #onBranchChange: (() => void) | null = null;
700
704
  #disposed = false;
705
+ /** Unsubscribe for the native-rendering feed; re-arms the turn-window ticker when a TSP surface is revoked. */
706
+ #unwatchNativeRendering: (() => void) | undefined;
701
707
  #autoCompactEnabled: boolean = true;
702
708
  /** Pulse timer for the running-speculation indicator; live only while speculation runs. */
703
709
  #speculationBlinkTimer: NodeJS.Timeout | undefined;
@@ -708,6 +714,8 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
708
714
  #brandWorking = false;
709
715
  /** Frame timer driving repaints while the brand fade is unsettled. */
710
716
  #brandFadeTimer: NodeJS.Timeout | undefined;
717
+ /** Repaint ticker while a turn's active-processing window is open (see #syncActiveWindowRepaint). */
718
+ #activeWindowRepaintTimer: NodeJS.Timeout | undefined;
711
719
  /** One wall-clock wakeup for the active model's next tariff change, including while idle. */
712
720
  #pricingTimer: NodeJS.Timeout | undefined;
713
721
  #pricingTimerCost: ModelCost | undefined;
@@ -833,6 +841,7 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
833
841
  private readonly host: StatusLineHost<TSession>,
834
842
  ) {
835
843
  this.#settings = host.getSettings();
844
+ this.#unwatchNativeRendering = onNativeRenderingChange(() => this.#syncActiveWindowRepaint());
836
845
  }
837
846
 
838
847
  #gitEnabled(): boolean {
@@ -939,6 +948,12 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
939
948
  this.#invalidateSessionCaches();
940
949
  this.#closeStaleActiveWindow();
941
950
  }
951
+ // The ticker self-stops once its tick sees no open window for the
952
+ // newly-attached session; re-pointing focus at a session whose turn
953
+ // is still running must re-arm it, and no other path reliably fires
954
+ // here (closeStaleActiveWindow returns early while streaming, and
955
+ // the synthesized agent_start no-ops on an already-open window).
956
+ this.#syncActiveWindowRepaint();
942
957
  this.invalidate();
943
958
  }
944
959
 
@@ -1037,6 +1052,7 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
1037
1052
  meter.activeMs = 0;
1038
1053
  meter.activeStartedAt = null;
1039
1054
  this.#invalidateStatusLineRenderCache();
1055
+ this.#syncActiveWindowRepaint();
1040
1056
  }
1041
1057
 
1042
1058
  /**
@@ -1051,6 +1067,7 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
1051
1067
  if (meter.activeStartedAt !== null) return;
1052
1068
  meter.activeStartedAt = Date.now();
1053
1069
  this.#invalidateStatusLineRenderCache();
1070
+ this.#syncActiveWindowRepaint();
1054
1071
  }
1055
1072
 
1056
1073
  /**
@@ -1065,6 +1082,7 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
1065
1082
  meter.activeMs += Math.max(0, Date.now() - meter.activeStartedAt);
1066
1083
  meter.activeStartedAt = null;
1067
1084
  this.#invalidateStatusLineRenderCache();
1085
+ this.#syncActiveWindowRepaint();
1068
1086
  }
1069
1087
 
1070
1088
  /**
@@ -1294,8 +1312,11 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
1294
1312
  this.#onBranchChange = null;
1295
1313
  this.#stopSpeculationBlink();
1296
1314
  this.#stopBrandFadeTimer();
1315
+ this.#stopActiveWindowRepaint();
1297
1316
  this.#stopPricingTimer();
1298
1317
  this.#clearUsageStartTimer();
1318
+ this.#unwatchNativeRendering?.();
1319
+ this.#unwatchNativeRendering = undefined;
1299
1320
  this.#onCodexResetFireworks = undefined;
1300
1321
  this.#onResetExpiryNotice = undefined;
1301
1322
  this.#codexResetSnapshots.clear();
@@ -1399,6 +1420,40 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
1399
1420
  this.#brandFadeTimer = undefined;
1400
1421
  }
1401
1422
 
1423
+ /**
1424
+ * Keep the status bar repainting while a turn's active-processing window
1425
+ * is open. The `pi` segment's spinner and turn timer are painted from the
1426
+ * wall clock at render time, so they only animate when frames flow; during
1427
+ * a quiet turn the working-row loader's 80 ms tick is normally the sole
1428
+ * repaint source, and paths that stop or bypass that loader mid-turn (an
1429
+ * error banner, a stale retry/compaction loader reference) froze the
1430
+ * spinner until the next keystroke. The status line owns the active
1431
+ * window, so it owns a repaint source for it; a native surface clocks its
1432
+ * own spinner and needs none.
1433
+ */
1434
+ #syncActiveWindowRepaint(): void {
1435
+ if (this.#disposed || isNativeRendering() || this.getTurnElapsedMs() === null) {
1436
+ this.#stopActiveWindowRepaint();
1437
+ return;
1438
+ }
1439
+ if (this.#activeWindowRepaintTimer) return;
1440
+ const timer = setInterval(() => {
1441
+ if (this.#disposed || isNativeRendering() || this.getTurnElapsedMs() === null) {
1442
+ this.#stopActiveWindowRepaint();
1443
+ return;
1444
+ }
1445
+ this.#onBranchChange?.();
1446
+ }, ACTIVE_WINDOW_REPAINT_MS);
1447
+ timer.unref?.();
1448
+ this.#activeWindowRepaintTimer = timer;
1449
+ }
1450
+
1451
+ #stopActiveWindowRepaint(): void {
1452
+ if (!this.#activeWindowRepaintTimer) return;
1453
+ clearInterval(this.#activeWindowRepaintTimer);
1454
+ this.#activeWindowRepaintTimer = undefined;
1455
+ }
1456
+
1402
1457
  #stopPricingTimer(): void {
1403
1458
  clearTimeout(this.#pricingTimer);
1404
1459
  this.#pricingTimer = undefined;