@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.
- package/CHANGELOG.md +42 -0
- package/dist/types/apps/git/sidebar.d.ts +2 -2
- package/dist/types/chat/recap-notice.d.ts +13 -0
- package/dist/types/components/editor.d.ts +15 -3
- package/dist/types/components/input.d.ts +11 -1
- package/dist/types/native/backend.d.ts +3 -2
- package/dist/types/native/blobs.d.ts +1 -1
- package/dist/types/native/node.d.ts +63 -1
- package/dist/types/overlays/agent-hub-projection.d.ts +9 -0
- package/dist/types/overlays/ask-dialog.d.ts +9 -5
- package/dist/types/overlays/copy-selector.d.ts +0 -23
- package/dist/types/overlays/error-banner.d.ts +5 -2
- package/dist/types/overlays/hook-editor.d.ts +12 -0
- package/dist/types/overlays/jobs-panel.d.ts +25 -1
- package/dist/types/overlays/rewind-selector.d.ts +17 -4
- package/dist/types/prompt/custom-editor.d.ts +16 -8
- package/dist/types/prompt/welcome.d.ts +3 -3
- package/dist/types/status-line/component.d.ts +2 -0
- package/dist/types/status-line/host.d.ts +2 -0
- package/dist/types/status-line/metrics.d.ts +4 -0
- package/dist/types/status-line/types.d.ts +13 -2
- package/dist/types/theme/defaults/index.d.ts +700 -0
- package/dist/types/theme/schema.d.ts +18 -0
- package/dist/types/tui.d.ts +8 -1
- package/package.json +9 -9
- package/src/apps/git/sidebar.ts +15 -2
- package/src/chat/assistant-message.ts +2 -2
- package/src/chat/recap-notice.ts +30 -0
- package/src/chat/tool-execution.ts +19 -3
- package/src/chat/user-message.ts +2 -1
- package/src/components/editor.ts +88 -14
- package/src/components/input.ts +57 -1
- package/src/native/backend.ts +72 -17
- package/src/native/blobs.ts +1 -1
- package/src/native/encode.ts +1 -0
- package/src/native/node.ts +108 -1
- package/src/native/reconcile.ts +5 -4
- package/src/overlays/agent-hub-projection.ts +25 -0
- package/src/overlays/agent-hub.ts +4 -15
- package/src/overlays/ask-dialog.ts +20 -32
- package/src/overlays/copy-selector.ts +6 -6
- package/src/overlays/error-banner.ts +14 -3
- package/src/overlays/hook-editor.ts +59 -6
- package/src/overlays/jobs-panel.ts +132 -49
- package/src/overlays/rewind-selector.ts +235 -332
- package/src/overlays/session-selector.ts +19 -20
- package/src/prompt/custom-editor.ts +119 -30
- package/src/prompt/welcome.ts +4 -33
- package/src/status-line/component.ts +61 -30
- package/src/status-line/host.ts +2 -0
- package/src/status-line/metrics.ts +36 -5
- package/src/status-line/segments.ts +9 -1
- package/src/status-line/types.ts +13 -2
- package/src/terminal.ts +27 -6
- package/src/theme/dark.json +10 -0
- package/src/theme/defaults/alabaster.json +10 -0
- package/src/theme/defaults/amethyst.json +10 -0
- package/src/theme/defaults/anthracite.json +10 -0
- package/src/theme/defaults/basalt.json +10 -0
- package/src/theme/defaults/birch.json +10 -0
- package/src/theme/defaults/dark-abyss.json +10 -0
- package/src/theme/defaults/dark-arctic.json +10 -0
- package/src/theme/defaults/dark-aurora.json +10 -0
- package/src/theme/defaults/dark-catppuccin.json +10 -0
- package/src/theme/defaults/dark-cavern.json +10 -0
- package/src/theme/defaults/dark-celestial.json +10 -0
- package/src/theme/defaults/dark-copper.json +10 -0
- package/src/theme/defaults/dark-cosmos.json +10 -0
- package/src/theme/defaults/dark-cyberpunk.json +10 -0
- package/src/theme/defaults/dark-dracula.json +10 -0
- package/src/theme/defaults/dark-eclipse.json +10 -0
- package/src/theme/defaults/dark-ember.json +10 -0
- package/src/theme/defaults/dark-equinox.json +10 -0
- package/src/theme/defaults/dark-forest.json +10 -0
- package/src/theme/defaults/dark-github.json +10 -0
- package/src/theme/defaults/dark-gruvbox.json +10 -0
- package/src/theme/defaults/dark-lavender.json +10 -0
- package/src/theme/defaults/dark-lunar.json +10 -0
- package/src/theme/defaults/dark-midnight.json +10 -0
- package/src/theme/defaults/dark-monochrome.json +10 -0
- package/src/theme/defaults/dark-monokai.json +10 -0
- package/src/theme/defaults/dark-nebula.json +10 -0
- package/src/theme/defaults/dark-neon-noir.json +10 -0
- package/src/theme/defaults/dark-nord.json +10 -0
- package/src/theme/defaults/dark-ocean.json +10 -0
- package/src/theme/defaults/dark-one.json +10 -0
- package/src/theme/defaults/dark-poimandres.json +10 -0
- package/src/theme/defaults/dark-rainforest.json +10 -0
- package/src/theme/defaults/dark-reef.json +10 -0
- package/src/theme/defaults/dark-retro.json +10 -0
- package/src/theme/defaults/dark-rose-pine.json +10 -0
- package/src/theme/defaults/dark-sakura.json +10 -0
- package/src/theme/defaults/dark-slate.json +10 -0
- package/src/theme/defaults/dark-solarized.json +10 -0
- package/src/theme/defaults/dark-solstice.json +10 -0
- package/src/theme/defaults/dark-starfall.json +10 -0
- package/src/theme/defaults/dark-sunset.json +10 -0
- package/src/theme/defaults/dark-swamp.json +10 -0
- package/src/theme/defaults/dark-synthwave.json +10 -0
- package/src/theme/defaults/dark-taiga.json +10 -0
- package/src/theme/defaults/dark-terminal.json +10 -0
- package/src/theme/defaults/dark-tokyo-night.json +10 -0
- package/src/theme/defaults/dark-tundra.json +10 -0
- package/src/theme/defaults/dark-twilight.json +10 -0
- package/src/theme/defaults/dark-volcanic.json +10 -0
- package/src/theme/defaults/graphite.json +10 -0
- package/src/theme/defaults/light-arctic.json +10 -0
- package/src/theme/defaults/light-aurora-day.json +10 -0
- package/src/theme/defaults/light-canyon.json +10 -0
- package/src/theme/defaults/light-catppuccin.json +10 -0
- package/src/theme/defaults/light-cirrus.json +10 -0
- package/src/theme/defaults/light-coral.json +10 -0
- package/src/theme/defaults/light-cyberpunk.json +10 -0
- package/src/theme/defaults/light-dawn.json +10 -0
- package/src/theme/defaults/light-dunes.json +10 -0
- package/src/theme/defaults/light-eucalyptus.json +10 -0
- package/src/theme/defaults/light-forest.json +10 -0
- package/src/theme/defaults/light-frost.json +10 -0
- package/src/theme/defaults/light-github.json +10 -0
- package/src/theme/defaults/light-glacier.json +10 -0
- package/src/theme/defaults/light-gruvbox.json +10 -0
- package/src/theme/defaults/light-haze.json +10 -0
- package/src/theme/defaults/light-honeycomb.json +10 -0
- package/src/theme/defaults/light-lagoon.json +10 -0
- package/src/theme/defaults/light-lavender.json +10 -0
- package/src/theme/defaults/light-meadow.json +10 -0
- package/src/theme/defaults/light-mint.json +10 -0
- package/src/theme/defaults/light-monochrome.json +10 -0
- package/src/theme/defaults/light-ocean.json +10 -0
- package/src/theme/defaults/light-one.json +10 -0
- package/src/theme/defaults/light-opal.json +10 -0
- package/src/theme/defaults/light-orchard.json +10 -0
- package/src/theme/defaults/light-paper.json +10 -0
- package/src/theme/defaults/light-poimandres.json +10 -0
- package/src/theme/defaults/light-prism.json +10 -0
- package/src/theme/defaults/light-retro.json +10 -0
- package/src/theme/defaults/light-sand.json +10 -0
- package/src/theme/defaults/light-savanna.json +10 -0
- package/src/theme/defaults/light-solarized.json +10 -0
- package/src/theme/defaults/light-soleil.json +10 -0
- package/src/theme/defaults/light-sunset.json +10 -0
- package/src/theme/defaults/light-synthwave.json +10 -0
- package/src/theme/defaults/light-tokyo-night.json +10 -0
- package/src/theme/defaults/light-wetland.json +10 -0
- package/src/theme/defaults/light-zenith.json +10 -0
- package/src/theme/defaults/limestone.json +10 -0
- package/src/theme/defaults/mahogany.json +10 -0
- package/src/theme/defaults/marble.json +10 -0
- package/src/theme/defaults/obsidian.json +10 -0
- package/src/theme/defaults/onyx.json +10 -0
- package/src/theme/defaults/pearl.json +10 -0
- package/src/theme/defaults/porcelain.json +10 -0
- package/src/theme/defaults/quartz.json +10 -0
- package/src/theme/defaults/sandstone.json +10 -0
- package/src/theme/defaults/titanium.json +10 -0
- package/src/theme/light.json +10 -0
- package/src/theme/schema-validation.ts +7 -0
- package/src/theme/schema.ts +18 -0
- package/src/theme/theme-schema.json +30 -0
- package/src/tools/eval.ts +43 -11
- package/src/tools/grouped-file-output.ts +6 -0
- package/src/tui.ts +13 -2
package/src/native/backend.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
#
|
|
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.#
|
|
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:
|
|
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
|
-
|
|
412
|
-
|
|
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 = [
|
|
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:
|
|
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(
|
|
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();
|
package/src/native/blobs.ts
CHANGED
|
@@ -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);
|
package/src/native/encode.ts
CHANGED
|
@@ -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
|
};
|
package/src/native/node.ts
CHANGED
|
@@ -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 {
|
package/src/native/reconcile.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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(
|
|
140
|
+
onPrompt(prompt: AskDialogPrompt, prefill?: string): Promise<string | undefined>;
|
|
157
141
|
/** Prompt that accepts pasted images; without it, prompts use `onPrompt`. */
|
|
158
|
-
onImagePrompt?(
|
|
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
|
-
|
|
1286
|
+
prompt: AskDialogPrompt,
|
|
1300
1287
|
prefill: AskDialogPromptValue | undefined,
|
|
1301
1288
|
): Promise<string | AskDialogPromptValue | undefined> {
|
|
1302
1289
|
return this.callbacks.onImagePrompt
|
|
1303
|
-
? this.callbacks.onImagePrompt(
|
|
1304
|
-
: this.callbacks.onPrompt(
|
|
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(
|
|
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
|
-
|
|
51
|
+
const EARLIER_TURNS_KEY = "earlier";
|
|
52
52
|
|
|
53
53
|
/** Leading item of a truncated transcript list; selecting it loads the older turns. */
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
206
|
+
class TimelineItems {
|
|
207
207
|
#memo: { targets: readonly OutlineTarget[]; items: TspPickerItem[] } | undefined;
|
|
208
208
|
|
|
209
209
|
of(targets: readonly OutlineTarget[]): TspPickerItem[] {
|