@oh-my-pi/pi-tui 18.4.5 → 18.4.8

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 (162) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/dist/types/apps/git/sidebar.d.ts +2 -2
  3. package/dist/types/chat/recap-notice.d.ts +13 -0
  4. package/dist/types/components/editor.d.ts +15 -3
  5. package/dist/types/components/input.d.ts +11 -1
  6. package/dist/types/native/backend.d.ts +3 -2
  7. package/dist/types/native/blobs.d.ts +1 -1
  8. package/dist/types/native/node.d.ts +63 -1
  9. package/dist/types/overlays/agent-hub-projection.d.ts +9 -0
  10. package/dist/types/overlays/ask-dialog.d.ts +9 -5
  11. package/dist/types/overlays/copy-selector.d.ts +0 -23
  12. package/dist/types/overlays/error-banner.d.ts +5 -2
  13. package/dist/types/overlays/hook-editor.d.ts +12 -0
  14. package/dist/types/overlays/jobs-panel.d.ts +25 -1
  15. package/dist/types/overlays/rewind-selector.d.ts +17 -4
  16. package/dist/types/prompt/custom-editor.d.ts +16 -8
  17. package/dist/types/prompt/welcome.d.ts +3 -3
  18. package/dist/types/status-line/component.d.ts +2 -0
  19. package/dist/types/status-line/host.d.ts +2 -0
  20. package/dist/types/status-line/metrics.d.ts +4 -0
  21. package/dist/types/status-line/types.d.ts +13 -2
  22. package/dist/types/theme/defaults/index.d.ts +700 -0
  23. package/dist/types/theme/schema.d.ts +18 -0
  24. package/dist/types/tui.d.ts +8 -1
  25. package/package.json +9 -9
  26. package/src/apps/git/sidebar.ts +15 -2
  27. package/src/chat/assistant-message.ts +2 -2
  28. package/src/chat/recap-notice.ts +30 -0
  29. package/src/chat/tool-execution.ts +19 -3
  30. package/src/chat/user-message.ts +2 -1
  31. package/src/components/editor.ts +88 -14
  32. package/src/components/input.ts +57 -1
  33. package/src/native/backend.ts +72 -17
  34. package/src/native/blobs.ts +1 -1
  35. package/src/native/encode.ts +1 -0
  36. package/src/native/node.ts +108 -1
  37. package/src/native/reconcile.ts +5 -4
  38. package/src/overlays/agent-hub-projection.ts +25 -0
  39. package/src/overlays/agent-hub.ts +4 -15
  40. package/src/overlays/ask-dialog.ts +20 -32
  41. package/src/overlays/copy-selector.ts +6 -6
  42. package/src/overlays/error-banner.ts +14 -3
  43. package/src/overlays/hook-editor.ts +59 -6
  44. package/src/overlays/jobs-panel.ts +132 -49
  45. package/src/overlays/rewind-selector.ts +235 -332
  46. package/src/overlays/session-selector.ts +19 -20
  47. package/src/prompt/custom-editor.ts +119 -30
  48. package/src/prompt/welcome.ts +4 -33
  49. package/src/status-line/component.ts +61 -30
  50. package/src/status-line/host.ts +2 -0
  51. package/src/status-line/metrics.ts +36 -5
  52. package/src/status-line/segments.ts +9 -1
  53. package/src/status-line/types.ts +13 -2
  54. package/src/terminal.ts +27 -6
  55. package/src/theme/dark.json +10 -0
  56. package/src/theme/defaults/alabaster.json +10 -0
  57. package/src/theme/defaults/amethyst.json +10 -0
  58. package/src/theme/defaults/anthracite.json +10 -0
  59. package/src/theme/defaults/basalt.json +10 -0
  60. package/src/theme/defaults/birch.json +10 -0
  61. package/src/theme/defaults/dark-abyss.json +10 -0
  62. package/src/theme/defaults/dark-arctic.json +10 -0
  63. package/src/theme/defaults/dark-aurora.json +10 -0
  64. package/src/theme/defaults/dark-catppuccin.json +10 -0
  65. package/src/theme/defaults/dark-cavern.json +10 -0
  66. package/src/theme/defaults/dark-celestial.json +10 -0
  67. package/src/theme/defaults/dark-copper.json +10 -0
  68. package/src/theme/defaults/dark-cosmos.json +10 -0
  69. package/src/theme/defaults/dark-cyberpunk.json +10 -0
  70. package/src/theme/defaults/dark-dracula.json +10 -0
  71. package/src/theme/defaults/dark-eclipse.json +10 -0
  72. package/src/theme/defaults/dark-ember.json +10 -0
  73. package/src/theme/defaults/dark-equinox.json +10 -0
  74. package/src/theme/defaults/dark-forest.json +10 -0
  75. package/src/theme/defaults/dark-github.json +10 -0
  76. package/src/theme/defaults/dark-gruvbox.json +10 -0
  77. package/src/theme/defaults/dark-lavender.json +10 -0
  78. package/src/theme/defaults/dark-lunar.json +10 -0
  79. package/src/theme/defaults/dark-midnight.json +10 -0
  80. package/src/theme/defaults/dark-monochrome.json +10 -0
  81. package/src/theme/defaults/dark-monokai.json +10 -0
  82. package/src/theme/defaults/dark-nebula.json +10 -0
  83. package/src/theme/defaults/dark-neon-noir.json +10 -0
  84. package/src/theme/defaults/dark-nord.json +10 -0
  85. package/src/theme/defaults/dark-ocean.json +10 -0
  86. package/src/theme/defaults/dark-one.json +10 -0
  87. package/src/theme/defaults/dark-poimandres.json +10 -0
  88. package/src/theme/defaults/dark-rainforest.json +10 -0
  89. package/src/theme/defaults/dark-reef.json +10 -0
  90. package/src/theme/defaults/dark-retro.json +10 -0
  91. package/src/theme/defaults/dark-rose-pine.json +10 -0
  92. package/src/theme/defaults/dark-sakura.json +10 -0
  93. package/src/theme/defaults/dark-slate.json +10 -0
  94. package/src/theme/defaults/dark-solarized.json +10 -0
  95. package/src/theme/defaults/dark-solstice.json +10 -0
  96. package/src/theme/defaults/dark-starfall.json +10 -0
  97. package/src/theme/defaults/dark-sunset.json +10 -0
  98. package/src/theme/defaults/dark-swamp.json +10 -0
  99. package/src/theme/defaults/dark-synthwave.json +10 -0
  100. package/src/theme/defaults/dark-taiga.json +10 -0
  101. package/src/theme/defaults/dark-terminal.json +10 -0
  102. package/src/theme/defaults/dark-tokyo-night.json +10 -0
  103. package/src/theme/defaults/dark-tundra.json +10 -0
  104. package/src/theme/defaults/dark-twilight.json +10 -0
  105. package/src/theme/defaults/dark-volcanic.json +10 -0
  106. package/src/theme/defaults/graphite.json +10 -0
  107. package/src/theme/defaults/light-arctic.json +10 -0
  108. package/src/theme/defaults/light-aurora-day.json +10 -0
  109. package/src/theme/defaults/light-canyon.json +10 -0
  110. package/src/theme/defaults/light-catppuccin.json +10 -0
  111. package/src/theme/defaults/light-cirrus.json +10 -0
  112. package/src/theme/defaults/light-coral.json +10 -0
  113. package/src/theme/defaults/light-cyberpunk.json +10 -0
  114. package/src/theme/defaults/light-dawn.json +10 -0
  115. package/src/theme/defaults/light-dunes.json +10 -0
  116. package/src/theme/defaults/light-eucalyptus.json +10 -0
  117. package/src/theme/defaults/light-forest.json +10 -0
  118. package/src/theme/defaults/light-frost.json +10 -0
  119. package/src/theme/defaults/light-github.json +10 -0
  120. package/src/theme/defaults/light-glacier.json +10 -0
  121. package/src/theme/defaults/light-gruvbox.json +10 -0
  122. package/src/theme/defaults/light-haze.json +10 -0
  123. package/src/theme/defaults/light-honeycomb.json +10 -0
  124. package/src/theme/defaults/light-lagoon.json +10 -0
  125. package/src/theme/defaults/light-lavender.json +10 -0
  126. package/src/theme/defaults/light-meadow.json +10 -0
  127. package/src/theme/defaults/light-mint.json +10 -0
  128. package/src/theme/defaults/light-monochrome.json +10 -0
  129. package/src/theme/defaults/light-ocean.json +10 -0
  130. package/src/theme/defaults/light-one.json +10 -0
  131. package/src/theme/defaults/light-opal.json +10 -0
  132. package/src/theme/defaults/light-orchard.json +10 -0
  133. package/src/theme/defaults/light-paper.json +10 -0
  134. package/src/theme/defaults/light-poimandres.json +10 -0
  135. package/src/theme/defaults/light-prism.json +10 -0
  136. package/src/theme/defaults/light-retro.json +10 -0
  137. package/src/theme/defaults/light-sand.json +10 -0
  138. package/src/theme/defaults/light-savanna.json +10 -0
  139. package/src/theme/defaults/light-solarized.json +10 -0
  140. package/src/theme/defaults/light-soleil.json +10 -0
  141. package/src/theme/defaults/light-sunset.json +10 -0
  142. package/src/theme/defaults/light-synthwave.json +10 -0
  143. package/src/theme/defaults/light-tokyo-night.json +10 -0
  144. package/src/theme/defaults/light-wetland.json +10 -0
  145. package/src/theme/defaults/light-zenith.json +10 -0
  146. package/src/theme/defaults/limestone.json +10 -0
  147. package/src/theme/defaults/mahogany.json +10 -0
  148. package/src/theme/defaults/marble.json +10 -0
  149. package/src/theme/defaults/obsidian.json +10 -0
  150. package/src/theme/defaults/onyx.json +10 -0
  151. package/src/theme/defaults/pearl.json +10 -0
  152. package/src/theme/defaults/porcelain.json +10 -0
  153. package/src/theme/defaults/quartz.json +10 -0
  154. package/src/theme/defaults/sandstone.json +10 -0
  155. package/src/theme/defaults/titanium.json +10 -0
  156. package/src/theme/light.json +10 -0
  157. package/src/theme/schema-validation.ts +7 -0
  158. package/src/theme/schema.ts +18 -0
  159. package/src/theme/theme-schema.json +30 -0
  160. package/src/tools/eval.ts +43 -11
  161. package/src/tools/grouped-file-output.ts +6 -0
  162. package/src/tui.ts +13 -2
@@ -38,7 +38,7 @@ import {
38
38
  type NativeThemePalette,
39
39
  setNativeSymbolPreset,
40
40
  } from "../theme/theme";
41
- import type { Component, OverlayOptions } from "../tui";
41
+ import type { Component, OverlayOptions, RenderScheduler, RenderTimer } from "../tui";
42
42
  import { TspDocument } from "./apply";
43
43
  import { getNativeBlob } from "./blobs";
44
44
  import { node } from "./describe";
@@ -80,13 +80,29 @@ export interface NativeBackendOptions {
80
80
  readonly recordPath?: string;
81
81
  /** Log the `rows` fallback count per frame. Defaults to `PI_TUI_NATIVE_STATS=1`. */
82
82
  readonly stats?: boolean;
83
- readonly now?: () => number;
83
+ /** Clock and timers (stall wake-up); defaults to `Date.now` and unref'd `setTimeout`. */
84
+ readonly scheduler?: RenderScheduler;
84
85
  }
85
86
 
87
+ /** Real clock and timers that never keep the process alive on their own. */
88
+ const DEFAULT_SCHEDULER: RenderScheduler = {
89
+ now: () => Date.now(),
90
+ scheduleImmediate: callback => {
91
+ setImmediate(callback);
92
+ },
93
+ scheduleRender: (callback, delayMs) => {
94
+ const timer = setTimeout(callback, delayMs);
95
+ timer.unref();
96
+ return { cancel: () => clearTimeout(timer) };
97
+ },
98
+ };
99
+
86
100
  /** Frames kept for the debug `tsp` op. */
87
101
  const RECENT_FRAMES = 64;
88
102
  /** An unanswered frame older than this no longer holds rendering back. */
89
103
  const STALLED_ACK_MS = 5000;
104
+ /** Role of the session's surfaces; a screen page may name its own. */
105
+ const SESSION_ROLE = "omp.session";
90
106
 
91
107
  class NativeContext implements DescribeContext {
92
108
  cols: number;
@@ -148,6 +164,8 @@ export function assumedTspHello(terminal: Terminal): TspHello {
148
164
  class Surface {
149
165
  readonly id: string;
150
166
  readonly mode: "inline" | "screen";
167
+ /** The `o` role: `omp.session`, or a screen page's own (`NativeScreen.role`). */
168
+ readonly role: string;
151
169
  readonly reconciler: Reconciler;
152
170
  readonly doc: TspDocument | null;
153
171
  seq = 0;
@@ -158,9 +176,10 @@ class Surface {
158
176
  focus: string | null = null;
159
177
  dirty = false;
160
178
 
161
- constructor(id: string, mode: "inline" | "screen", mirror: boolean) {
179
+ constructor(id: string, mode: "inline" | "screen", role: string, mirror: boolean) {
162
180
  this.id = id;
163
181
  this.mode = mode;
182
+ this.role = role;
164
183
  this.reconciler = new Reconciler(id);
165
184
  this.doc = mirror ? new TspDocument(id) : null;
166
185
  }
@@ -211,7 +230,9 @@ export class NativeBackend {
211
230
  #mirror: boolean;
212
231
  #recordPath: string | undefined;
213
232
  #stats: boolean;
214
- #now: () => number;
233
+ #scheduler: RenderScheduler;
234
+ /** Wakes a render when the oldest unacked frame of a credit-blocked surface turns stalled. */
235
+ #stallTimer: RenderTimer | undefined;
215
236
  #recent: TspFrame[] = [];
216
237
  #sawResize = false;
217
238
  #live = false;
@@ -232,7 +253,7 @@ export class NativeBackend {
232
253
  this.#mirror = options.mirror === true;
233
254
  this.#recordPath = options.recordPath ?? (Bun.env.PI_TUI_TSP_RECORD || undefined);
234
255
  this.#stats = options.stats ?? Bun.env.PI_TUI_NATIVE_STATS === "1";
235
- this.#now = options.now ?? Date.now;
256
+ this.#scheduler = options.scheduler ?? DEFAULT_SCHEDULER;
236
257
  this.#inline = this.#newSurface("inline");
237
258
  }
238
259
 
@@ -333,6 +354,7 @@ export class NativeBackend {
333
354
  this.#unbindTheme?.();
334
355
  this.#unbindTheme = undefined;
335
356
  this.#useNerdSymbols(false);
357
+ this.#clearStallTimer();
336
358
  }
337
359
 
338
360
  /**
@@ -353,7 +375,7 @@ export class NativeBackend {
353
375
  surface.acked = surface.seq;
354
376
  surface.focus = null;
355
377
  surface.dirty = false;
356
- this.#write("o", { id: surface.id, mode: "inline", title: "omp", role: "omp.session", adopt: true });
378
+ this.#write("o", { id: surface.id, mode: "inline", title: "omp", role: SESSION_ROLE, adopt: true });
357
379
  this.#sendPalette(surface);
358
380
  // After the `o`, as in `start()`.
359
381
  setNativeRendering(true);
@@ -408,13 +430,18 @@ export class NativeBackend {
408
430
  let dock: readonly NativeChild[];
409
431
  let layer: NativeChild[];
410
432
  if (fullscreen >= 0) {
411
- if (!this.#screen) {
412
- this.#screen = this.#newSurface("screen");
433
+ const component = overlays[fullscreen]!.component;
434
+ const page = component.describeScreen?.(this.#cx);
435
+ const role = page?.role ?? SESSION_ROLE;
436
+ if (this.#screen?.role !== role) {
437
+ // A page with another role is another surface: its styling keys off the `o`.
438
+ if (this.#screen) this.#close(this.#screen, false);
439
+ this.#screen = this.#newSurface("screen", role);
413
440
  this.#open(this.#screen);
414
441
  }
415
442
  surface = this.#screen;
416
- main = [overlays[fullscreen]!.component];
417
- dock = [];
443
+ main = page?.main ?? [component];
444
+ dock = page?.dock ?? [];
418
445
  layer = overlays.slice(fullscreen + 1).map(overlay => this.#overlayNode(overlay));
419
446
  } else {
420
447
  if (this.#screen) {
@@ -430,6 +457,7 @@ export class NativeBackend {
430
457
  this.#pruneOverlayNodes(overlays);
431
458
  if (!this.#hasCredit(surface)) {
432
459
  surface.dirty = true;
460
+ this.#armStallTimer(surface);
433
461
  return;
434
462
  }
435
463
  surface.dirty = false;
@@ -478,12 +506,12 @@ export class NativeBackend {
478
506
  this.#sawResize = false;
479
507
  }
480
508
 
481
- #newSurface(mode: "inline" | "screen"): Surface {
482
- return new Surface(`s:${this.#nextSurface++}`, mode, this.#mirror);
509
+ #newSurface(mode: "inline" | "screen", role = SESSION_ROLE): Surface {
510
+ return new Surface(`s:${this.#nextSurface++}`, mode, role, this.#mirror);
483
511
  }
484
512
 
485
513
  #open(surface: Surface): void {
486
- this.#write("o", { id: surface.id, mode: surface.mode, title: "omp", role: "omp.session" });
514
+ this.#write("o", { id: surface.id, mode: surface.mode, title: "omp", role: surface.role });
487
515
  this.#sendPalette(surface);
488
516
  }
489
517
 
@@ -494,7 +522,7 @@ export class NativeBackend {
494
522
  #hasCredit(surface: Surface): boolean {
495
523
  if (surface.unacked.length < this.#credits) return true;
496
524
  const oldest = surface.unacked[0]!;
497
- if (this.#now() - oldest < STALLED_ACK_MS) return false;
525
+ if (this.#scheduler.now() - oldest < STALLED_ACK_MS) return false;
498
526
  logger.warn("TSP: terminal stopped acknowledging frames; resuming without credits", {
499
527
  sf: surface.id,
500
528
  s: surface.seq,
@@ -505,9 +533,27 @@ export class NativeBackend {
505
533
  return true;
506
534
  }
507
535
 
536
+ /** Render again once `surface`'s oldest unacked frame counts as stalled, in case no ack ever arrives. */
537
+ #armStallTimer(surface: Surface): void {
538
+ if (this.#stallTimer) return;
539
+ const delay = surface.unacked[0]! + STALLED_ACK_MS - this.#scheduler.now();
540
+ this.#stallTimer = this.#scheduler.scheduleRender(
541
+ () => {
542
+ this.#stallTimer = undefined;
543
+ this.#host.requestRender();
544
+ },
545
+ Math.max(0, delay),
546
+ );
547
+ }
548
+
549
+ #clearStallTimer(): void {
550
+ this.#stallTimer?.cancel();
551
+ this.#stallTimer = undefined;
552
+ }
553
+
508
554
  #sendFrame(surface: Surface, ops: readonly TspOp[]): void {
509
555
  surface.seq++;
510
- surface.unacked.push(this.#now());
556
+ surface.unacked.push(this.#scheduler.now());
511
557
  const frame: TspFrame = { sf: surface.id, s: surface.seq, ops };
512
558
  if (surface.doc) {
513
559
  const errors = surface.doc.applyFrame(frame);
@@ -553,7 +599,7 @@ export class NativeBackend {
553
599
  }
554
600
  }
555
601
  try {
556
- fs.appendFileSync(path, `${JSON.stringify({ t: this.#now(), dir, verb, params, body: payload })}\n`);
602
+ fs.appendFileSync(path, `${JSON.stringify({ t: this.#scheduler.now(), dir, verb, params, body: payload })}\n`);
557
603
  } catch (error) {
558
604
  logger.warn("TSP: recording failed; disabling", { path, error: String(error) });
559
605
  this.#recordPath = undefined;
@@ -573,6 +619,7 @@ export class NativeBackend {
573
619
  const newly = Math.min(event.s, surface.seq) - surface.acked;
574
620
  surface.acked = Math.min(event.s, surface.seq);
575
621
  surface.unacked.splice(0, newly);
622
+ this.#clearStallTimer();
576
623
  if (surface.dirty) this.#host.requestRender();
577
624
  return;
578
625
  }
@@ -611,12 +658,15 @@ export class NativeBackend {
611
658
  case "activate":
612
659
  case "action":
613
660
  case "change":
661
+ case "edit":
614
662
  this.#routeUiEvent(event);
615
663
  return;
616
664
  }
617
665
  }
618
666
 
619
- #routeUiEvent(event: Extract<TspEvent, { ev: "toggle" | "select" | "activate" | "action" | "change" }>): void {
667
+ #routeUiEvent(
668
+ event: Extract<TspEvent, { ev: "toggle" | "select" | "activate" | "action" | "change" | "edit" }>,
669
+ ): void {
620
670
  const reconciler = this.#surfaceFor(event.sf)?.reconciler;
621
671
  const target = reconciler?.target(event.id);
622
672
  if (!reconciler || !target?.component.handleNativeEvent) return;
@@ -644,6 +694,11 @@ export class NativeBackend {
644
694
  case "change":
645
695
  ui = { type: "change", key: target.keypath, item: event.item, value: event.value };
646
696
  break;
697
+ case "edit": {
698
+ const { from, to, text, cursor, len } = event;
699
+ ui = { type: "edit", key: target.keypath, from, to, text, cursor, len };
700
+ break;
701
+ }
647
702
  }
648
703
  target.component.handleNativeEvent(ui);
649
704
  this.#host.requestRender();
@@ -48,7 +48,7 @@ export function getNativeBlob(id: string): NativeBlob | undefined {
48
48
  export function base64ImageNode(
49
49
  data: string,
50
50
  mimeType: string,
51
- p?: Omit<TspProps<"image">, "blob" | "w" | "h">,
51
+ p?: Omit<TspProps<"image">, "blob" | "builtin" | "w" | "h">,
52
52
  key?: string,
53
53
  ): NativeNode {
54
54
  const blob = registerNativeBlob(Buffer.from(data, "base64"), mimeType);
@@ -165,6 +165,7 @@ const EVENT_REQUIRED: Readonly<Record<string, Readonly<Record<string, "string" |
165
165
  select: { id: "string", item: "string" },
166
166
  activate: { id: "string", item: "string" },
167
167
  action: { id: "string", act: "string" },
168
+ edit: { id: "string", from: "number", to: "number", text: "string", cursor: "number", len: "number" },
168
169
  error: { msg: "string" },
169
170
  gone: { ids: "array" },
170
171
  };
@@ -28,6 +28,11 @@ export type NativeNode = {
28
28
  * or reordered so their ids and terminal-side view state survive.
29
29
  */
30
30
  readonly key?: string;
31
+ /**
32
+ * Scroll the node into view, placed like the `reveal` op, when it is
33
+ * added. Key a node by what it points at to reveal it again on a move.
34
+ */
35
+ readonly reveal?: "start" | "end" | "nearest";
31
36
  };
32
37
  }[TspKind];
33
38
 
@@ -60,6 +65,15 @@ export interface NativeSurface {
60
65
  readonly dock: readonly NativeChild[];
61
66
  }
62
67
 
68
+ /**
69
+ * The screen surface a fullscreen overlay fills as a page of its own (see
70
+ * `Component.describeScreen`): its regions plus the surface `role`, which the
71
+ * terminal exposes to styling.
72
+ */
73
+ export interface NativeScreen extends NativeSurface {
74
+ readonly role: string;
75
+ }
76
+
63
77
  /**
64
78
  * A user action on a node a component described, routed back to that
65
79
  * component. `key` is the node's key path inside the component (`""` for the
@@ -83,7 +97,100 @@ export type NativeUiEvent =
83
97
  readonly key: string;
84
98
  readonly item: string;
85
99
  readonly value: boolean | number | string | readonly string[] | null;
86
- };
100
+ }
101
+ /** An edit over the terminal's own selection in an `editor`/`input` node (see {@link NativeTextEdit}). */
102
+ | ({ readonly type: "edit"; readonly key: string } & NativeTextEdit);
103
+
104
+ /**
105
+ * A primitive edit the terminal made over its own text selection (cut,
106
+ * typing, Backspace/Delete or paste over it, or collapsing it). Offsets are
107
+ * UTF-16 code units into the text last described (lines joined with `"\n"`),
108
+ * the coordinates of the node's `cursor`: replace `[from, to)` with `text`,
109
+ * then put the caret at `cursor` in the resulting text. `from == to` with an
110
+ * empty `text` is a pure caret move. `len` is the length of the text the
111
+ * terminal saw: when the current text differs (keys in flight changed it) the
112
+ * edit is stale and ignored.
113
+ */
114
+ export interface NativeTextEdit {
115
+ readonly from: number;
116
+ readonly to: number;
117
+ readonly text: string;
118
+ readonly cursor: number;
119
+ readonly len: number;
120
+ }
121
+
122
+ /** A {@link NativeTextEdit} checked against the current text and clamped, ready to apply. */
123
+ export interface ResolvedTextEdit {
124
+ /** Replaced range in the current text, widened by the caller's `widen`. */
125
+ readonly from: number;
126
+ readonly to: number;
127
+ /** The cleaned replacement. */
128
+ readonly insert: string;
129
+ /** The whole text after the edit. */
130
+ readonly text: string;
131
+ /** Caret in {@link text}. */
132
+ readonly cursor: number;
133
+ /** Whether the text changes (false: a pure caret move). */
134
+ readonly changed: boolean;
135
+ }
136
+
137
+ /** `value` as an integer offset in `[0, max]` (anything non-finite is 0). */
138
+ export function clampTextOffset(value: number, max: number): number {
139
+ return Number.isFinite(value) ? Math.min(Math.max(Math.trunc(value), 0), max) : 0;
140
+ }
141
+
142
+ /** Whether `offset` falls between the halves of a surrogate pair in `text`. */
143
+ function splitsSurrogatePair(text: string, offset: number): boolean {
144
+ if (offset <= 0 || offset >= text.length) return false;
145
+ const high = text.charCodeAt(offset - 1);
146
+ const low = text.charCodeAt(offset);
147
+ return high >= 0xd800 && high <= 0xdbff && low >= 0xdc00 && low <= 0xdfff;
148
+ }
149
+
150
+ /**
151
+ * Check `edit` against `current` and resolve it: null when stale (`len`
152
+ * differs), else the range clamped (reversed ends swapped, never splitting a
153
+ * surrogate pair), widened by `widen` when non-empty, the replacement passed
154
+ * through `clean`, and the caret mapped from the text the terminal expects
155
+ * onto the text the edit actually produces.
156
+ */
157
+ export function resolveTextEdit(
158
+ current: string,
159
+ edit: NativeTextEdit,
160
+ clean: (text: string) => string,
161
+ widen?: (from: number, to: number) => { from: number; to: number },
162
+ ): ResolvedTextEdit | null {
163
+ if (edit.len !== current.length) return null;
164
+ const size = current.length;
165
+ let from = clampTextOffset(edit.from, size);
166
+ let to = clampTextOffset(edit.to, size);
167
+ if (to < from) [from, to] = [to, from];
168
+ if (splitsSurrogatePair(current, from)) from--;
169
+ if (splitsSurrogatePair(current, to)) to++;
170
+ const raw = typeof edit.text === "string" ? edit.text : "";
171
+ const widened = widen && from < to ? widen(from, to) : { from, to };
172
+ const insert = raw ? clean(raw) : "";
173
+ const text = current.slice(0, widened.from) + insert + current.slice(widened.to);
174
+ // Where the terminal's caret sits in its own result (`[from, to)` replaced
175
+ // by `raw`), carried over to ours: widening swallows neighbours, cleaning
176
+ // may change the replacement's length.
177
+ const expected = clampTextOffset(edit.cursor, size - (to - from) + raw.length);
178
+ let cursor: number;
179
+ if (expected <= widened.from) cursor = expected;
180
+ else if (expected <= from) cursor = widened.from;
181
+ else if (expected < from + raw.length) cursor = widened.from + Math.min(expected - from, insert.length);
182
+ else cursor = widened.from + insert.length + Math.max(0, expected - from - raw.length - (widened.to - to));
183
+ cursor = Math.min(cursor, text.length);
184
+ if (splitsSurrogatePair(text, cursor)) cursor--;
185
+ return {
186
+ from: widened.from,
187
+ to: widened.to,
188
+ insert,
189
+ text,
190
+ cursor,
191
+ changed: widened.from !== widened.to || insert !== "",
192
+ };
193
+ }
87
194
 
88
195
  /** The expanded state a `toggle` on the component's root node asks for, else undefined. */
89
196
  export function rootToggleExpanded(event: NativeUiEvent): boolean | undefined {
@@ -211,8 +211,8 @@ export class Reconciler {
211
211
  #ops: TspOp[] = [];
212
212
  #dels: Entry[] = [];
213
213
  #settles: string[] = [];
214
- /** Selected list items sent this frame, revealed once the frame's adds have landed. */
215
- #reveals: string[] = [];
214
+ /** Selected list items and added `reveal` nodes sent this frame, revealed once the frame's adds have landed. */
215
+ #reveals: [id: string, at: "start" | "end" | "nearest"][] = [];
216
216
  /** Wire ids `list.selected` keys resolved to (an unresolved key names no node to reveal). */
217
217
  #selectedIds = new Set<string>();
218
218
  #moves: PendingMove[] = [];
@@ -292,7 +292,7 @@ export class Reconciler {
292
292
  }
293
293
  this.#parked = [];
294
294
  for (const id of this.#settles) this.#ops.push(["settle", id]);
295
- for (const id of this.#reveals) this.#ops.push(["reveal", id, "nearest"]);
295
+ for (const [id, at] of this.#reveals) this.#ops.push(["reveal", id, at]);
296
296
  const ops = this.#ops;
297
297
  this.#ops = [];
298
298
  return this.#gone.size === 0 ? ops : ops.filter(op => !this.#touchesGone(op));
@@ -716,6 +716,7 @@ export class Reconciler {
716
716
 
717
717
  /** A full wire subtree for an entry; nested components get states (or pending moves when mounted elsewhere). */
718
718
  #materialize(entry: NodeEntry, walk: Walk): TspNode {
719
+ if (entry.node.reveal) this.#reveals.push([entry.id, entry.node.reveal]);
719
720
  const entries = this.#entries(entry.node.c, entry.keypath, entry.owner, entry.hoist, walk);
720
721
  const children: TspNode[] = [];
721
722
  for (let i = 0; i < entries.length; i++) {
@@ -779,7 +780,7 @@ export class Reconciler {
779
780
 
780
781
  /** A list's selection went out (on add or change): scroll it into view at frame end. */
781
782
  #noteSelected(selected: unknown): void {
782
- if (typeof selected === "string" && this.#selectedIds.has(selected)) this.#reveals.push(selected);
783
+ if (typeof selected === "string" && this.#selectedIds.has(selected)) this.#reveals.push([selected, "nearest"]);
783
784
  }
784
785
 
785
786
  /** Move components an `add` left out into their new parent, last first so every `before` exists. */
@@ -110,6 +110,31 @@ function readSessionMetrics(session: NonNullable<AgentRecordLike["session"]>): A
110
110
  }
111
111
  }
112
112
 
113
+ /** Live session whose own messages back a row's metrics when no observer progress exists. */
114
+ export function hubFallbackStatsSession<TRecord extends AgentRecordLike>(
115
+ ref: TRecord,
116
+ observed: ObservableSession | undefined,
117
+ ): NonNullable<TRecord["session"]> | undefined {
118
+ if (observed?.progress) return undefined;
119
+ const session = ref.session;
120
+ return session && typeof session.getSessionStats === "function" ? session : undefined;
121
+ }
122
+
123
+ /**
124
+ * One roster row's usage: live observer progress, then persisted history, then
125
+ * the cached fallback read of a live session (populated by {@link aggregateMetrics}).
126
+ */
127
+ export function hubRowMetrics<TRecord extends AgentRecordLike>(
128
+ ref: TRecord,
129
+ observed: ObservableSession | undefined,
130
+ sessionMetrics: WeakMap<object, { metrics: AgentMetrics | undefined }>,
131
+ ): AgentMetrics | undefined {
132
+ if (observed?.progress) return progressMetrics(observed);
133
+ if (ref.history?.metrics) return ref.history.metrics;
134
+ const session = hubFallbackStatsSession(ref, observed);
135
+ return session ? sessionMetrics.get(session)?.metrics : undefined;
136
+ }
137
+
113
138
  export function aggregateMetrics<TRecord extends AgentRecordLike>(args: {
114
139
  rows: readonly TRecord[];
115
140
  observedById: ReadonlyMap<string, ObservableSession>;
@@ -49,7 +49,8 @@ import {
49
49
  type AgentMetrics,
50
50
  type AggregateMetrics,
51
51
  aggregateMetrics,
52
- progressMetrics,
52
+ hubFallbackStatsSession,
53
+ hubRowMetrics,
53
54
  projectAgentTree,
54
55
  STATUS_ORDER,
55
56
  } from "./agent-hub-projection";
@@ -1661,19 +1662,7 @@ export class AgentHubOverlayComponent<TRecord extends AgentRecordLike = AgentRec
1661
1662
  }
1662
1663
 
1663
1664
  #metricsFor(ref: TRecord, observed: ObservableSession | undefined): AgentMetrics | undefined {
1664
- if (observed?.progress) return progressMetrics(observed);
1665
- if (ref.history?.metrics) return ref.history.metrics;
1666
- const session = this.#fallbackStatsSession(ref, observed);
1667
- return session ? this.#sessionMetrics.get(session)?.metrics : undefined;
1668
- }
1669
-
1670
- #fallbackStatsSession(
1671
- ref: TRecord,
1672
- observed: ObservableSession | undefined,
1673
- ): NonNullable<TRecord["session"]> | undefined {
1674
- if (observed?.progress) return undefined;
1675
- const session = ref.session;
1676
- return session && typeof session.getSessionStats === "function" ? session : undefined;
1665
+ return hubRowMetrics(ref, observed, this.#sessionMetrics);
1677
1666
  }
1678
1667
 
1679
1668
  // ========================================================================
@@ -2009,7 +1998,7 @@ export class AgentHubOverlayComponent<TRecord extends AgentRecordLike = AgentRec
2009
1998
  rows: this.#rows,
2010
1999
  observedById: this.#observedById,
2011
2000
  metricsFor: (ref, observed) => this.#metricsFor(ref, observed),
2012
- fallbackStatsSession: (ref, observed) => this.#fallbackStatsSession(ref, observed),
2001
+ fallbackStatsSession: hubFallbackStatsSession,
2013
2002
  sessionMetrics: this.#sessionMetrics,
2014
2003
  refreshFallback,
2015
2004
  });
@@ -117,45 +117,32 @@ const DIALOG_HEIGHT_RATIO = 0.7;
117
117
  const MIN_DIALOG_ROWS = 12;
118
118
  const MIN_BODY_ROWS = 5;
119
119
  const MAX_HEADER_CHIP_WIDTH = 16;
120
- /** Maximum number of title lines shown in the prompt editor overlay, so a
121
- * long or multi-line question cannot push the input row off-screen. Mirrors
122
- * the bounded-title pattern from the legacy ask path without its option-window
123
- * coupling. */
124
- const MAX_PROMPT_TITLE_ROWS = 3;
125
- /** Border (2) + padX (2) columns consumed by the HookEditor chrome. */
126
- const PROMPT_TITLE_CHROME_COLUMNS = 4;
127
120
  /** Maximum number of wrapped lines for an in-body question header, so a long
128
121
  * or multi-line question cannot push the option list off-screen. Mirrors the
129
- * row-cap pattern used by boundPromptTitle for the prompt editor overlay. */
122
+ * row-cap pattern of the prompt editor's title (`boundPromptTitle`). */
130
123
  const MAX_HEADER_ROWS = 4;
131
124
  /** Maximum number of wrapped lines shown for an option description before
132
125
  * Ctrl+O expansion. Mirrors the header row-cap above so long descriptions
133
126
  * cannot push later options off-screen. */
134
127
  const MAX_DESC_ROWS = 2;
135
128
 
136
- function promptTitleContentWidth(): number {
137
- const cols = process.stdout.columns ?? 80;
138
- return Math.max(1, cols - PROMPT_TITLE_CHROME_COLUMNS);
139
- }
140
-
141
- /** Bound a prompt editor title to a fixed row/width budget so long or
142
- * multi-line questions stay usable inside the small prompt overlay. */
143
- export function boundPromptTitle(prefix: string, question: string): string {
144
- const width = promptTitleContentWidth();
145
- const flat = normalizedInlineInput(`${prefix}${question}`);
146
- const wrapped = wrapTextWithAnsi(flat, width);
147
- if (wrapped.length <= MAX_PROMPT_TITLE_ROWS) return wrapped.join("\n");
148
- const kept = wrapped.slice(0, MAX_PROMPT_TITLE_ROWS - 1);
149
- const last = truncateToWidth(wrapped[MAX_PROMPT_TITLE_ROWS - 1] ?? "", width, Ellipsis.Unicode);
150
- return [...kept, last].join("\n");
129
+ /** What a custom-answer or note prompt asks: the host shows `title` over `question` (`HookEditorOptions.question`). */
130
+ export interface AskDialogPrompt {
131
+ /** `Custom answer`, `Note for <option>`. */
132
+ title: string;
133
+ /** The question being answered, verbatim. */
134
+ question: string;
151
135
  }
152
136
 
153
137
  interface AskDialogCallbacks {
154
138
  onSubmit(result: ExtensionAskDialogSubmitResult): void;
155
139
  onCancel(): void;
156
- onPrompt(title: string, prefill?: string): Promise<string | undefined>;
140
+ onPrompt(prompt: AskDialogPrompt, prefill?: string): Promise<string | undefined>;
157
141
  /** Prompt that accepts pasted images; without it, prompts use `onPrompt`. */
158
- onImagePrompt?(title: string, prefill: AskDialogPromptValue | undefined): Promise<AskDialogPromptValue | undefined>;
142
+ onImagePrompt?(
143
+ prompt: AskDialogPrompt,
144
+ prefill: AskDialogPromptValue | undefined,
145
+ ): Promise<AskDialogPromptValue | undefined>;
159
146
  }
160
147
 
161
148
  interface AskDialogInputGuard {
@@ -1296,12 +1283,12 @@ export class AskDialogComponent implements Component {
1296
1283
  * caller keeps one `await` before clearing `#promptActive`, which the host's restore relies on.
1297
1284
  */
1298
1285
  #openPrompt(
1299
- title: string,
1286
+ prompt: AskDialogPrompt,
1300
1287
  prefill: AskDialogPromptValue | undefined,
1301
1288
  ): Promise<string | AskDialogPromptValue | undefined> {
1302
1289
  return this.callbacks.onImagePrompt
1303
- ? this.callbacks.onImagePrompt(title, prefill)
1304
- : this.callbacks.onPrompt(title, prefill?.text);
1290
+ ? this.callbacks.onImagePrompt(prompt, prefill)
1291
+ : this.callbacks.onPrompt(prompt, prefill?.text);
1305
1292
  }
1306
1293
 
1307
1294
  async #promptForCustomInput(
@@ -1311,10 +1298,9 @@ export class AskDialogComponent implements Component {
1311
1298
  ): Promise<void> {
1312
1299
  this.#promptActive = true;
1313
1300
  try {
1314
- const title = boundPromptTitle("Custom answer: ", question.question);
1315
1301
  const prefill =
1316
1302
  state.customInput === undefined ? undefined : { text: state.customInput, images: state.customInputImages };
1317
- const result = await this.#openPrompt(title, prefill);
1303
+ const result = await this.#openPrompt({ title: "Custom answer", question: question.question }, prefill);
1318
1304
  if (result === undefined || this.#closed) return;
1319
1305
  const input = splitPromptInput(result);
1320
1306
  if (input.text.trim() === "") {
@@ -1350,11 +1336,13 @@ export class AskDialogComponent implements Component {
1350
1336
  ): Promise<void> {
1351
1337
  this.#promptActive = true;
1352
1338
  try {
1353
- const title = boundPromptTitle(`Note for ${rowItem.label}: `, question.question);
1354
1339
  const isReedit = state.noteRowKey === rowItem.key;
1355
1340
  const prefill =
1356
1341
  isReedit && state.note !== undefined ? { text: state.note, images: state.noteImages } : undefined;
1357
- const result = await this.#openPrompt(title, prefill);
1342
+ const result = await this.#openPrompt(
1343
+ { title: `Note for ${rowItem.label}`, question: question.question },
1344
+ prefill,
1345
+ );
1358
1346
  if (result === undefined || this.#closed) return;
1359
1347
  const note = splitPromptInput(result);
1360
1348
  state.note = note.text;
@@ -48,10 +48,10 @@ import { actionHint, hintsRow, type NativeHint, overlayCard } from "../native/ov
48
48
  import { CLOSE_ACTION, type PickerEvent, picker, pickerAction, pickerEvent } from "../native/picker";
49
49
 
50
50
  /** Key of the leading list item that replays the history older than the startup tail. */
51
- export const EARLIER_TURNS_KEY = "earlier";
51
+ const EARLIER_TURNS_KEY = "earlier";
52
52
 
53
53
  /** Leading item of a truncated transcript list; selecting it loads the older turns. */
54
- export const earlierTurnsItem: NativeNode = node(
54
+ const earlierTurnsItem: NativeNode = node(
55
55
  "item",
56
56
  { label: [span("Earlier turns…", "muted")], hint: ["a"] },
57
57
  undefined,
@@ -112,7 +112,7 @@ function entryTime(entry: TranscriptEntry): string {
112
112
  * One list item per outline target, keyed by the turn's opening entry id:
113
113
  * the plain turn summary as label, `detail` (or the entry time) on the right.
114
114
  */
115
- export function turnItem(target: OutlineTarget, detail?: TspText): NativeNode {
115
+ function turnItem(target: OutlineTarget, detail?: TspText): NativeNode {
116
116
  const entry = target.entries[0]!;
117
117
  const { label, role } = turnSummary(entry);
118
118
  const time = entryTime(entry);
@@ -164,14 +164,14 @@ function toolCallLabel(name: string, args: Record<string, unknown> | undefined):
164
164
  }
165
165
 
166
166
  /** The timeline pickers' one fact column: a user turn's clock time. */
167
- export const TIMELINE_COLUMNS: readonly TspPickerColumn[] = [{ id: "at", format: "dim", priority: 1 }];
167
+ const TIMELINE_COLUMNS: readonly TspPickerColumn[] = [{ id: "at", format: "dim", priority: 1 }];
168
168
 
169
169
  /**
170
170
  * The `timeline` picker item of one outline target (rewind, copy), keyed by
171
171
  * the turn's opening entry id: user turns carry their `HH:MM`, pure tool turns
172
172
  * the tool's role and a `name target` label.
173
173
  */
174
- export function timelineItem(target: OutlineTarget): TspPickerItem {
174
+ function timelineItem(target: OutlineTarget): TspPickerItem {
175
175
  const entry = target.entries[0]!;
176
176
  const id = target.turnId;
177
177
  const summary = turnSummary(entry);
@@ -203,7 +203,7 @@ export function timelineItem(target: OutlineTarget): TspPickerItem {
203
203
  }
204
204
 
205
205
  /** Timeline picker items for `targets`, reused while `targets` is the same array. */
206
- export class TimelineItems {
206
+ class TimelineItems {
207
207
  #memo: { targets: readonly OutlineTarget[]; items: TspPickerItem[] } | undefined;
208
208
 
209
209
  of(targets: readonly OutlineTarget[]): TspPickerItem[] {