@oh-my-pi/pi-tui 18.4.4 → 18.4.6

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 (63) hide show
  1. package/CHANGELOG.md +46 -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/blobs.d.ts +1 -1
  7. package/dist/types/native/node.d.ts +63 -1
  8. package/dist/types/overlays/agent-hub-projection.d.ts +9 -0
  9. package/dist/types/overlays/ask-dialog.d.ts +9 -5
  10. package/dist/types/overlays/copy-selector.d.ts +0 -23
  11. package/dist/types/overlays/error-banner.d.ts +5 -2
  12. package/dist/types/overlays/hook-editor.d.ts +12 -0
  13. package/dist/types/overlays/jobs-panel.d.ts +25 -1
  14. package/dist/types/overlays/model-hub.d.ts +2 -0
  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/render/render-utils.d.ts +5 -0
  19. package/dist/types/status-line/component.d.ts +2 -0
  20. package/dist/types/status-line/host.d.ts +2 -0
  21. package/dist/types/status-line/metrics.d.ts +4 -0
  22. package/dist/types/status-line/types.d.ts +13 -2
  23. package/dist/types/tools/edit.d.ts +2 -0
  24. package/dist/types/tools/task.d.ts +7 -0
  25. package/dist/types/tui.d.ts +8 -1
  26. package/package.json +9 -9
  27. package/src/apps/git/sidebar.ts +15 -2
  28. package/src/chat/assistant-message.ts +2 -2
  29. package/src/chat/recap-notice.ts +30 -0
  30. package/src/chat/tool-execution.ts +19 -3
  31. package/src/chat/user-message.ts +2 -1
  32. package/src/components/editor.ts +115 -16
  33. package/src/components/input.ts +57 -1
  34. package/src/native/backend.ts +28 -10
  35. package/src/native/blobs.ts +1 -1
  36. package/src/native/encode.ts +1 -0
  37. package/src/native/node.ts +108 -1
  38. package/src/native/reconcile.ts +5 -4
  39. package/src/overlays/agent-hub-projection.ts +25 -0
  40. package/src/overlays/agent-hub.ts +4 -15
  41. package/src/overlays/ask-dialog.ts +20 -32
  42. package/src/overlays/copy-selector.ts +6 -6
  43. package/src/overlays/error-banner.ts +14 -3
  44. package/src/overlays/hook-editor.ts +59 -6
  45. package/src/overlays/jobs-panel.ts +132 -49
  46. package/src/overlays/model-browser.ts +56 -12
  47. package/src/overlays/model-hub.ts +82 -29
  48. package/src/overlays/rewind-selector.ts +235 -332
  49. package/src/overlays/session-selector.ts +19 -20
  50. package/src/prompt/custom-editor.ts +119 -30
  51. package/src/prompt/welcome.ts +4 -33
  52. package/src/render/render-utils.ts +51 -3
  53. package/src/status-line/component.ts +61 -30
  54. package/src/status-line/host.ts +2 -0
  55. package/src/status-line/metrics.ts +36 -5
  56. package/src/status-line/segments.ts +9 -1
  57. package/src/status-line/types.ts +13 -2
  58. package/src/terminal.ts +27 -6
  59. package/src/tools/edit.ts +23 -0
  60. package/src/tools/eval.ts +43 -11
  61. package/src/tools/grouped-file-output.ts +6 -0
  62. package/src/tools/task.ts +29 -4
  63. package/src/tui.ts +9 -1
@@ -24,6 +24,10 @@
24
24
  * filter with the selection kept), Enter rewinds to the outlined item, A loads
25
25
  * earlier turns without changing selection (stepping above the oldest replayed
26
26
  * turn loads them too), Esc cancels.
27
+ *
28
+ * In a TSP terminal (Tern) the same replay is a page, not a sheet: a screen
29
+ * surface of the transcript's own blocks with `pick`/`drop` marks in place of
30
+ * the dotted outline (see `describeScreen`).
27
31
  */
28
32
  import type { AgentTool } from "@oh-my-pi/pi-agent-core";
29
33
  import {
@@ -57,23 +61,12 @@ import {
57
61
  positionRail,
58
62
  userTurnLabel,
59
63
  } from "../chat/transcript-outline";
60
- import type { TspPickerItem, TspPickerProps } from "@oh-my-pi/pi-wire";
61
- import { compact, node, span, text } from "../native/describe";
62
- import type { DescribeContext, NativeChild, NativeNode, NativeUiEvent } from "../native/node";
63
- import { actionHint, hintsRow, overlayCard } from "../native/overlay";
64
- import { CLOSE_ACTION, type PickerEvent, picker, pickerAction, pickerEvent, pickerQuery } from "../native/picker";
64
+ import type { TspMark } from "@oh-my-pi/pi-wire";
65
+ import { kbd, node, span, text } from "../native/describe";
66
+ import type { DescribeContext, NativeChild, NativeNode, NativeScreen, NativeUiEvent } from "../native/node";
67
+ import { actionBar, actionButton, actionHint, hintsRow } from "../native/overlay";
65
68
  import { isNativeRendering } from "../native/state";
66
- import {
67
- collectBlocks,
68
- EARLIER_TURNS_KEY,
69
- earlierTurnsItem,
70
- TIMELINE_COLUMNS,
71
- TimelineItems,
72
- targetCopy,
73
- timelineItem,
74
- turnItem,
75
- turnPreview,
76
- } from "./copy-selector";
69
+ import { collectBlocks, targetCopy } from "./copy-selector";
77
70
 
78
71
  /** One alternate branch at a divergence: its root and message path root → most-recent leaf. */
79
72
  export interface BranchVariantPath {
@@ -106,12 +99,58 @@ interface SiblingColumn {
106
99
  targets: OutlineTarget[];
107
100
  /** Short label for the column header: the branch's first user prompt. */
108
101
  label: string;
109
- /** Native list items for this column, built on first describe. */
110
- nativeItems?: NativeNode[];
111
- /** Picker catalogue while this column's tab is open: the main items, then this branch's. */
112
- pickerItems?: { main: readonly TspPickerItem[]; items: TspPickerItem[] };
113
102
  }
114
103
 
104
+ /**
105
+ * A replayed block on the rewind page, carrying a transient `mark`: it
106
+ * describes as the block with the mark merged into its root props, and hands
107
+ * events and rendering to the block.
108
+ */
109
+ class MarkedBlock implements Component {
110
+ mark: TspMark | undefined;
111
+ #memo: { node: NativeNode; mark: TspMark; out: NativeNode } | undefined;
112
+
113
+ constructor(readonly block: Component) {}
114
+
115
+ render(width: number): readonly string[] {
116
+ return this.block.render(width);
117
+ }
118
+
119
+ invalidate(): void {
120
+ this.block.invalidate?.();
121
+ }
122
+
123
+ describe(cx: DescribeContext): NativeNode | null {
124
+ const described = this.block.describe?.(cx) ?? null;
125
+ const mark = this.mark;
126
+ if (!described || !mark) return described;
127
+ const memo = this.#memo;
128
+ if (memo?.node === described && memo.mark === mark) return memo.out;
129
+ const out = node(described.k, { ...described.p, mark }, described.c, described.key);
130
+ this.#memo = { node: described, mark, out };
131
+ return out;
132
+ }
133
+
134
+ handleNativeEvent(event: NativeUiEvent): void {
135
+ this.block.handleNativeEvent?.(event);
136
+ }
137
+ }
138
+
139
+ const kMarked = Symbol("rewind.marked");
140
+
141
+ /** A replayed block tagged with its page wrapper, so stepping reuses one wrapper per block. */
142
+ interface MarkTagged {
143
+ [kMarked]?: MarkedBlock;
144
+ }
145
+
146
+ /** The page's first row while older history is unreplayed: `a` loads it. */
147
+ const EARLIER_TURNS = node(
148
+ "row",
149
+ { role: "omp.rewind.earlier", gap: "xs", align: "center" },
150
+ [kbd("a"), text([span("load earlier turns", "muted")])],
151
+ "earlier",
152
+ );
153
+
115
154
  /** Blank columns between branch-strip columns. */
116
155
  const STRIP_GAP = 2;
117
156
  /** Duration of the branch-swap camera slide. */
@@ -155,14 +194,8 @@ export class RewindSelectorComponent implements Component {
155
194
  #rowText = new WeakMap<readonly string[], string>();
156
195
  /** Native filter haystack: lowercased turn text per main target (no rendered rows under TSP). */
157
196
  #nativeTexts: { targets: OutlineTarget[]; texts: string[] } | undefined;
158
- /** Last described root and the state it was built from. */
159
- #native: { memo: string; targets: OutlineTarget[]; node: NativeNode } | undefined;
160
- /** Main-path list items, rebuilt only when the replayed targets change. */
161
- #nativeItems: { targets: OutlineTarget[]; truncated: boolean; items: NativeNode[] } | undefined;
162
- /** Timeline picker items of the replayed main path. */
163
- #pickerItems = new TimelineItems();
164
- /** Last described picker and the state it was built from. */
165
- #picker: { memo: string; targets: OutlineTarget[]; node: NativeNode } | undefined;
197
+ /** Last described bar and the state it was built from. */
198
+ #bar: { memo: string; targets: OutlineTarget[]; node: NativeNode } | undefined;
166
199
 
167
200
  constructor(
168
201
  entries: TranscriptEntry[],
@@ -579,338 +612,208 @@ export class RewindSelectorComponent implements Component {
579
612
  }
580
613
 
581
614
  // ========================================================================
582
- // Native
615
+ // Native: the transcript as a page
583
616
  // ========================================================================
584
617
 
585
- /** A `picker` is its own sheet: the backend mounts it in `layer`, over the transcript. */
586
- nativeSheet(cx: DescribeContext): boolean {
587
- return cx.supports("picker");
588
- }
589
-
590
- /** A branch tab click moves between the current path and its alternates, as Left/Right do. */
591
- #showVariant(value: string | undefined): void {
592
- const variant = Number(value);
593
- if (!Number.isInteger(variant) || variant === this.#activeVariant) return;
594
- if (variant < 0 || variant > this.#stripColumns().length) return;
595
- this.#siblingSelected = 0;
596
- if (variant === 0) {
597
- this.#activeVariant = 0;
598
- this.#stopSlide();
599
- this.deps.requestRender();
600
- } else {
601
- this.#slideTo(variant);
602
- }
603
- }
604
-
605
618
  /**
606
- * Picker pointer events: a row click outlines that turn, a second click
607
- * rewinds there (Enter); the action bar and tabs run their keys' paths.
619
+ * The rewind page: the replayed transcript fills a screen surface block by
620
+ * block, as the live one reads, the outlined turn marked `pick` under a
621
+ * "Continue from here" caption (revealed as the outline moves) and what the
622
+ * rewind drops marked `drop`. At a fork the region below it becomes a strip
623
+ * of branch columns, the current path first. The bar is this component's
624
+ * own node, docked under the page, so the filter field keeps the caret.
608
625
  */
609
- #handlePickerEvent(event: PickerEvent): void {
610
- if (event.kind === "action") {
611
- switch (event.act) {
612
- case "tab":
613
- this.#showVariant(event.value);
614
- return;
615
- case "rewind":
616
- if (this.#filter === undefined) this.#selectOutlined();
617
- else this.#selectFiltered();
618
- return;
619
- case "lateral":
620
- // A click steps back a user turn, or on to the next branch (wrapping to the current path).
621
- if (this.#filter !== undefined || this.#stripColumns().length === 0) this.#left();
622
- else if (this.#activeVariant < this.#stripColumns().length) this.#right();
623
- else this.#showVariant("0");
624
- return;
625
- case "filter":
626
- this.#openFilter();
627
- return;
628
- case "earlier":
629
- this.#loadFullHistory();
630
- return;
631
- case "close":
632
- if (this.#filter === undefined) this.deps.onCancel();
633
- else this.#closeFilter();
634
- return;
635
- case "clear":
636
- this.#closeFilter();
637
- return;
638
- default:
639
- return;
640
- }
641
- }
642
- if (!this.#outline(event.item)) return;
643
- this.deps.requestRender();
644
- if (event.kind === "activate") {
645
- if (this.#filter === undefined) this.#selectOutlined();
646
- else this.#selectFiltered();
647
- }
626
+ describeScreen(_cx: DescribeContext): NativeScreen {
627
+ return { role: "omp.rewind", main: this.#page(), dock: [this] };
648
628
  }
649
629
 
650
- /** Outline the turn `id`: on the active branch tab, or on the main path (leaving the tab). */
651
- #outline(id: string): boolean {
652
- if (this.#activeVariant > 0) {
653
- const targets = this.#stripColumns()[this.#activeVariant - 1]?.targets ?? [];
654
- const index = targets.findIndex(target => target.turnId === id);
655
- if (index >= 0) {
656
- this.#siblingSelected = index;
657
- return true;
630
+ /** The page's blocks, marked for the current outline. */
631
+ #page(): NativeChild[] {
632
+ const blocks = this.#builder.container.children;
633
+ const page: NativeChild[] = [];
634
+ const filter = this.#filter;
635
+ if (filter !== undefined) {
636
+ // Only the matching turns of the current path, like the filtered frame.
637
+ const matches = this.#filterMatches();
638
+ for (const index of matches) {
639
+ const target = this.#targets[index]!;
640
+ const picked = index === this.#selected;
641
+ if (picked) page.push(this.#caption(this.#targets, index, "main"));
642
+ for (let i = target.start; i < target.end; i++)
643
+ page.push(this.#marked(blocks[i]!, picked ? "pick" : undefined));
658
644
  }
645
+ if (matches.length === 0) {
646
+ page.push(text([span(`No turns match "${filter}"`, "muted")], { role: "omp.rewind.empty" }));
647
+ }
648
+ return page;
659
649
  }
660
- const index = this.#targets.findIndex(target => target.turnId === id);
661
- if (index < 0) return false;
662
- if (this.#filter !== undefined && !this.#filterMatches().includes(index)) return false;
663
- this.#selected = index;
664
- this.#activeVariant = 0;
665
- this.#siblingSelected = 0;
666
- this.#stopSlide();
667
- return true;
668
- }
669
-
670
- handleNativeEvent(event: NativeUiEvent): void {
671
- const picked = pickerEvent(event);
672
- if (picked) {
673
- this.#handlePickerEvent(picked);
674
- return;
675
- }
676
- if (event.type !== "select" && event.type !== "activate") return;
677
- if (event.key === "branches") {
678
- this.#showVariant(event.item);
679
- return;
650
+ if (this.#truncated) page.push(EARLIER_TURNS);
651
+ const columns = this.#stripColumns();
652
+ const anchor = this.#targets[this.#selected];
653
+ if (columns.length === 0 || !anchor) {
654
+ this.#markRun(page, blocks, 0, this.#targets, this.#selected, "main");
655
+ return page;
680
656
  }
681
- // A click on an item outlines it and rewinds there, as Enter would.
682
- if (event.key === "list") {
683
- if (event.item === EARLIER_TURNS_KEY) {
684
- this.#loadFullHistory();
685
- return;
686
- }
687
- const index = this.#targets.findIndex(target => target.turnId === event.item);
688
- if (index < 0) return;
689
- this.#selected = index;
690
- this.#activeVariant = 0;
691
- this.#siblingSelected = 0;
692
- this.#stopSlide();
693
- } else if (event.key === "branch" && this.#activeVariant > 0) {
694
- const targets = this.#stripColumns()[this.#activeVariant - 1]?.targets ?? [];
695
- const index = targets.findIndex(target => target.turnId === event.item);
696
- if (index < 0) return;
697
- this.#siblingSelected = index;
698
- } else {
699
- return;
657
+ // Shared history above the fork at full width, then the branches side by side.
658
+ for (let i = 0; i < anchor.start; i++) page.push(this.#marked(blocks[i]!, undefined));
659
+ const count = columns.length + 1;
660
+ const current: NativeChild[] = [this.#columnHead(0, count, "current")];
661
+ this.#markRun(
662
+ current,
663
+ blocks,
664
+ anchor.start,
665
+ this.#targets,
666
+ this.#activeVariant === 0 ? this.#selected : -1,
667
+ "main",
668
+ );
669
+ const strip = [this.#column(0, current, "current")];
670
+ for (let index = 0; index < columns.length; index++) {
671
+ const column = columns[index]!;
672
+ const active = this.#activeVariant === index + 1;
673
+ const children: NativeChild[] = [this.#columnHead(index + 1, count, column.label)];
674
+ const picked = active ? this.#siblingSelected : -1;
675
+ this.#markRun(children, column.builder.container.children, 0, column.targets, picked, column.rootId);
676
+ strip.push(this.#column(index + 1, children, column.rootId));
700
677
  }
701
- this.deps.requestRender();
702
- this.#selectOutlined();
678
+ page.push(node("row", { role: "omp.rewind.strip", gap: "lg", align: "start" }, strip, "strip"));
679
+ return page;
703
680
  }
704
681
 
705
- describe(cx: DescribeContext): NativeNode {
706
- return cx.supports("picker") ? this.#describePicker() : this.#describeCard();
682
+ /**
683
+ * Push `blocks[from..]` marked for `targets[picked]` (-1: none): unmarked
684
+ * above it, then the caption and `pick` on its blocks, `drop` below.
685
+ */
686
+ #markRun(
687
+ out: NativeChild[],
688
+ blocks: readonly Component[],
689
+ from: number,
690
+ targets: readonly OutlineTarget[],
691
+ picked: number,
692
+ column: string,
693
+ ): void {
694
+ const target = targets[picked];
695
+ for (let i = from; i < blocks.length; i++) {
696
+ if (target && i === target.start) out.push(this.#caption(targets, picked, column));
697
+ const mark = !target || i < target.start ? undefined : i < target.end ? "pick" : "drop";
698
+ out.push(this.#marked(blocks[i]!, mark));
699
+ }
707
700
  }
708
701
 
709
702
  /**
710
- * The `timeline` picker: one row per turn, the branch tabs at a fork, the
711
- * filter as the query, and the outlined turn's own transcript components
712
- * as the preview under the drop warning.
703
+ * The line over the outlined turn: what Enter does and what it drops.
704
+ * Keyed by the turn, so every step adds a fresh one that scrolls into view.
713
705
  */
714
- #describePicker(): NativeNode {
715
- const filter = this.#filter;
716
- const memo = `${this.#selected}|${this.#activeVariant}|${this.#siblingSelected}|${this.#truncated}|${filter ?? "\0"}|${this.#filterInput?.getCursor()}`;
717
- const cached = this.#picker;
718
- if (cached?.memo === memo && cached.targets === this.#targets) return cached.node;
706
+ #caption(targets: readonly OutlineTarget[], picked: number, column: string): NativeNode {
707
+ const target = targets[picked]!;
708
+ const below = targets.length - picked - 1;
709
+ const spans = [
710
+ span(`${theme.icon.rewind} `, "accent"),
711
+ span(target.isUserTurn ? "Rewind to here" : "Continue from here", "accent strong"),
712
+ ];
713
+ if (target.isUserTurn) spans.push(span(`${theme.sep.dot}the prompt returns to the editor`, "dim"));
714
+ spans.push(
715
+ span(
716
+ below > 0
717
+ ? `${theme.sep.dot}${below} turn${below === 1 ? "" : "s"} below dropped`
718
+ : `${theme.sep.dot}nothing below to drop`,
719
+ "dim",
720
+ ),
721
+ );
722
+ const caption = text(spans, { role: "omp.rewind.here", wrap: "none" });
723
+ return { ...caption, key: `here:${column}:${target.turnId}`, reveal: "start" };
724
+ }
719
725
 
720
- const main = this.#pickerItems.of(this.#targets);
721
- const columns = filter === undefined ? this.#stripColumns() : [];
722
- const column = columns[this.#activeVariant - 1];
723
- const matches = filter === undefined ? undefined : this.#filterMatches();
724
- const props: TspPickerProps = {
725
- title: "Rewind",
726
- subtitle: "Pick the point to continue from",
727
- icon: "rewind",
728
- noun: "turns",
729
- size: "lg",
730
- layout: "timeline",
731
- preview: "side",
732
- ...pickerQuery(this.#filterInput ?? null),
733
- placeholder: "Filter turns…",
734
- columns: TIMELINE_COLUMNS,
735
- items: main,
736
- selected: this.#targets[this.#selected]?.turnId ?? null,
737
- total: this.#targets.length,
738
- empty: "Nothing to rewind to",
739
- };
740
- if (matches) {
741
- props.order = matches.map(index => this.#targets[index]!.turnId);
742
- if (!matches.includes(this.#selected)) props.selected = null;
743
- }
744
- if (columns.length > 0) {
745
- props.tabs = [
746
- { id: "0", label: "Current" },
747
- ...columns.map((strip, index) => ({
748
- id: String(index + 1),
749
- label: strip.label.length > 32 ? `${strip.label.slice(0, 31)}…` : strip.label,
750
- })),
751
- ];
752
- props.tab = String(this.#activeVariant);
753
- }
754
- if (column) {
755
- // The shared history above the fork, then the alternate branch.
756
- if (column.pickerItems?.main !== main) {
757
- column.pickerItems = { main, items: [...main, ...column.targets.map(timelineItem)] };
758
- }
759
- props.items = column.pickerItems.items;
760
- props.order = [
761
- ...this.#targets.slice(0, this.#selected).map(target => target.turnId),
762
- ...column.targets.map(target => target.turnId),
763
- ];
764
- props.selected = column.targets[this.#siblingSelected]?.turnId ?? null;
765
- }
766
- props.actions = compact([
767
- pickerAction("rewind", "Rewind here", "enter", { primary: true }),
768
- pickerAction("lateral", columns.length > 0 ? "Branches" : "User turns", ["left", "right"]),
769
- filter === undefined ? pickerAction("filter", "Filter", "f") : undefined,
770
- filter === undefined && this.#truncated ? pickerAction("earlier", "Earlier turns", "a") : undefined,
771
- filter === undefined ? CLOSE_ACTION : { ...CLOSE_ACTION, label: "Show all" },
772
- ]);
773
-
774
- const preview: NativeChild[] = [];
775
- const outlined = column ? column.targets[this.#siblingSelected] : this.#targets[this.#selected];
776
- if (outlined && props.selected !== null) {
777
- // A user turn rewinds past itself (its text returns to the editor); anything else keeps itself.
778
- const kept = column || this.#targets[this.#selected]!.isUserTurn ? this.#selected : this.#selected + 1;
779
- const dropped = this.#targets.length - kept;
780
- preview.push(
781
- text(
782
- dropped > 0
783
- ? [
784
- span("Continue from here: everything below is dropped", "warning"),
785
- span(`${theme.sep.dot}${dropped} turn${dropped === 1 ? "" : "s"}`, "dim"),
786
- ]
787
- : [span("Continue from here: nothing below to drop", "warning")],
788
- { role: "omp.rewind.drop" },
789
- ),
790
- ...(column?.builder ?? this.#builder).container.children.slice(outlined.start, outlined.end),
791
- );
792
- }
793
- const root = picker(props, preview);
794
- this.#picker = { memo, targets: this.#targets, node: root };
795
- return root;
726
+ /** One branch column of the strip; the active one carries the accent tone. */
727
+ #column(index: number, children: readonly NativeChild[], key: string): NativeNode {
728
+ const active = index === this.#activeVariant;
729
+ return node(
730
+ "col",
731
+ { role: "omp.rewind.branch", gap: "lg", ...(active ? { tone: "accent" } : {}) },
732
+ children,
733
+ key,
734
+ );
735
+ }
736
+
737
+ /** A strip column's caption: `⎇ i/n · label`. */
738
+ #columnHead(index: number, count: number, label: string): NativeNode {
739
+ const active = index === this.#activeVariant;
740
+ return text(
741
+ [
742
+ span(`${theme.icon.branch} `, active ? "accent" : "dim"),
743
+ span(`${index + 1}/${count}`, active ? "accent" : "dim"),
744
+ span(`${theme.sep.dot}${label}`, active ? "strong" : "dim"),
745
+ ],
746
+ { role: "omp.rewind.branch.head", wrap: "none" },
747
+ );
796
748
  }
797
749
 
798
- #describeCard(): NativeNode {
799
- const memo = `${this.#selected}|${this.#activeVariant}|${this.#siblingSelected}|${this.#truncated}|${this.#filter ?? "\0"}|${this.#filterInput?.getCursor()}`;
800
- const cached = this.#native;
801
- if (cached?.memo === memo && cached.targets === this.#targets) return cached.node;
750
+ /** `block` wrapped to carry `mark`; one wrapper per block, so ids and view state persist while stepping. */
751
+ #marked(block: Component & MarkTagged, mark: TspMark | undefined): MarkedBlock {
752
+ const marked = (block[kMarked] ??= new MarkedBlock(block));
753
+ marked.mark = mark;
754
+ return marked;
755
+ }
802
756
 
757
+ /** The bar docked under the page: position, key hints, Cancel and Rewind; the filter field while filtering. */
758
+ describe(_cx: DescribeContext): NativeNode {
803
759
  const filter = this.#filter;
804
- const children: NativeChild[] = [];
805
- const allItems = this.#mainItems();
806
760
  const columns = filter === undefined ? this.#stripColumns() : [];
807
- const matches = filter === undefined ? undefined : this.#filterMatches();
808
- if (filter !== undefined && matches) {
809
- children.push(
810
- this.#filterInput!,
811
- node(
812
- "list",
813
- {
814
- selected: matches.includes(this.#selected) ? (this.#targets[this.#selected]?.turnId ?? null) : null,
815
- filter,
816
- empty: `No items match "${filter}"`,
817
- virtual: true,
818
- max: 0.5,
819
- },
820
- matches.map(index => allItems[index + (this.#truncated ? 1 : 0)]!),
821
- "list",
822
- ),
823
- );
761
+ const memo = `${this.#selected}|${this.#activeVariant}|${this.#siblingSelected}|${this.#truncated}|${columns.length}|${filter ?? "\0"}`;
762
+ if (this.#bar?.memo === memo && this.#bar.targets === this.#targets) return this.#bar.node;
763
+
764
+ const title = text([span(`${theme.icon.rewind} `, "accent"), span("Rewind", "strong")], { wrap: "none" });
765
+ const upDown = actionHint(["tui.select.up", "tui.select.down"], "step");
766
+ const rewind = actionButton("Rewind here", "rewind", { keys: "enter", tone: "accent" });
767
+ let children: NativeChild[];
768
+ if (filter === undefined) {
769
+ const column = columns[this.#activeVariant - 1];
770
+ const at = column ? this.#siblingSelected : this.#selected;
771
+ const of = column ? column.targets.length : this.#targets.length;
772
+ children = [
773
+ title,
774
+ text([span(`${at + 1}/${of}`, "dim")], { wrap: "none" }),
775
+ hintsRow([
776
+ upDown,
777
+ { keys: ["left", "right"], label: columns.length > 0 ? "branches" : "user turns" },
778
+ { keys: ["f"], label: "filter" },
779
+ this.#truncated ? { keys: ["a"], label: "earlier turns" } : undefined,
780
+ ]),
781
+ actionBar([null, actionButton("Cancel", "cancel", { keys: "escape" }), rewind]),
782
+ ];
824
783
  } else {
825
- children.push(
826
- node(
827
- "list",
828
- {
829
- selected: this.#targets[this.#selected]?.turnId ?? null,
830
- empty: "Nothing to rewind to",
831
- virtual: true,
832
- max: 0.5,
833
- },
834
- allItems,
835
- "list",
836
- ),
837
- );
838
- }
839
- if (columns.length > 0) {
840
- children.push(
841
- node(
842
- "tabs",
843
- {
844
- items: [
845
- { id: "0", label: "current" },
846
- ...columns.map((column, index) => ({ id: String(index + 1), label: column.label })),
847
- ],
848
- active: String(this.#activeVariant),
849
- },
850
- undefined,
851
- "branches",
784
+ const matches = this.#filterMatches();
785
+ const at = matches.indexOf(this.#selected);
786
+ children = [
787
+ title,
788
+ this.#filterInput!,
789
+ text(
790
+ [
791
+ matches.length === 0
792
+ ? span("no matches", "error")
793
+ : span(`${at >= 0 ? at + 1 : "-"}/${matches.length}`, "dim"),
794
+ ],
795
+ { wrap: "none" },
852
796
  ),
853
- );
854
- const column = columns[this.#activeVariant - 1];
855
- if (column) {
856
- column.nativeItems ??= column.targets.map(target => turnItem(target));
857
- children.push(
858
- node(
859
- "list",
860
- { selected: column.targets[this.#siblingSelected]?.turnId ?? null, virtual: true, max: 0.4 },
861
- column.nativeItems,
862
- "branch",
863
- ),
864
- );
865
- }
866
- }
867
- const outlined = filter === undefined ? this.#outlinedTarget() : this.#targets[this.#selected];
868
- if (outlined && (!matches || matches.includes(this.#selected))) {
869
- const item = targetCopy(outlined, collectBlocks(outlined.entries));
870
- children.push(
871
- node("section", { head: [span(item.label, "strong")] }, [turnPreview(outlined, item.content)], "preview"),
872
- );
797
+ hintsRow([upDown, { keys: ["left", "right"], label: "user turns" }]),
798
+ actionBar([null, actionButton("Show all", "cancel", { keys: "escape" }), rewind]),
799
+ ];
873
800
  }
874
- const upDown = actionHint(["tui.select.up", "tui.select.down"], "step");
875
- children.push(
876
- hintsRow(
877
- filter === undefined
878
- ? [
879
- upDown,
880
- { keys: ["left", "right"], label: columns.length > 0 ? "branches" : "user turns" },
881
- { keys: ["f"], label: "filter" },
882
- { keys: ["enter"], label: "rewind" },
883
- this.#truncated ? { keys: ["a"], label: "earlier turns" } : undefined,
884
- actionHint("tui.select.cancel", "cancel"),
885
- ]
886
- : [
887
- upDown,
888
- { keys: ["left", "right"], label: "user turns" },
889
- { keys: ["enter"], label: "rewind" },
890
- actionHint("tui.select.cancel", "show all"),
891
- ],
892
- ),
893
- );
894
- const root = overlayCard(
895
- "omp.overlay.rewind",
896
- [
897
- span(`${theme.icon.rewind} `),
898
- span("Rewind", "strong"),
899
- span(`${theme.sep.dot}pick the point to continue from`, "dim"),
900
- ],
901
- children,
902
- );
903
- this.#native = { memo, targets: this.#targets, node: root };
801
+ const root = node("row", { role: "omp.rewind.bar", gap: "md", align: "center", wrap: true }, children);
802
+ this.#bar = { memo, targets: this.#targets, node: root };
904
803
  return root;
905
804
  }
906
805
 
907
- #mainItems(): NativeNode[] {
908
- const cached = this.#nativeItems;
909
- if (cached?.targets === this.#targets && cached.truncated === this.#truncated) return cached.items;
910
- const items = this.#targets.map(target => turnItem(target));
911
- if (this.#truncated) items.unshift(earlierTurnsItem);
912
- this.#nativeItems = { targets: this.#targets, truncated: this.#truncated, items };
913
- return items;
806
+ /** The bar's buttons run their keys' paths; everything else belongs to the blocks. */
807
+ handleNativeEvent(event: NativeUiEvent): void {
808
+ if (event.type !== "action") return;
809
+ const filtering = this.#filter !== undefined;
810
+ if (event.act === "rewind") {
811
+ if (filtering) this.#selectFiltered();
812
+ else this.#selectOutlined();
813
+ } else if (event.act === "cancel") {
814
+ if (filtering) this.#closeFilter();
815
+ else this.deps.onCancel();
816
+ }
914
817
  }
915
818
 
916
819
  // ========================================================================