@sigrun/tui 0.4.1 → 0.5.0

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 (114) hide show
  1. package/CHANGELOG.md +10 -1
  2. package/dist/actions.d.ts +119 -0
  3. package/dist/actions.js +12 -0
  4. package/dist/api.d.ts +209 -0
  5. package/dist/api.js +282 -0
  6. package/dist/blocks/braille.d.ts +41 -0
  7. package/dist/blocks/braille.js +131 -0
  8. package/dist/blocks/chart.d.ts +20 -0
  9. package/dist/blocks/chart.js +397 -0
  10. package/dist/blocks/chat-transport.d.ts +44 -0
  11. package/dist/blocks/chat-transport.js +116 -0
  12. package/dist/blocks/collection/calendar.d.ts +28 -0
  13. package/dist/blocks/collection/calendar.js +232 -0
  14. package/dist/blocks/collection/card.d.ts +36 -0
  15. package/dist/blocks/collection/card.js +128 -0
  16. package/dist/blocks/collection/collection.d.ts +9 -0
  17. package/dist/blocks/collection/collection.js +26 -0
  18. package/dist/blocks/collection/grid.d.ts +18 -0
  19. package/dist/blocks/collection/grid.js +72 -0
  20. package/dist/blocks/collection/lanes.d.ts +32 -0
  21. package/dist/blocks/collection/lanes.js +320 -0
  22. package/dist/blocks/collection/list.d.ts +30 -0
  23. package/dist/blocks/collection/list.js +273 -0
  24. package/dist/blocks/collection/move.d.ts +67 -0
  25. package/dist/blocks/collection/move.js +96 -0
  26. package/dist/blocks/collection/shared.d.ts +87 -0
  27. package/dist/blocks/collection/shared.js +177 -0
  28. package/dist/blocks/collection/table.d.ts +26 -0
  29. package/dist/blocks/collection/table.js +221 -0
  30. package/dist/blocks/content.d.ts +48 -0
  31. package/dist/blocks/content.js +253 -0
  32. package/dist/blocks/context.d.ts +117 -0
  33. package/dist/blocks/context.js +65 -0
  34. package/dist/blocks/diff.d.ts +21 -0
  35. package/dist/blocks/diff.js +25 -0
  36. package/dist/blocks/ext-transport.d.ts +70 -0
  37. package/dist/blocks/ext-transport.js +165 -0
  38. package/dist/blocks/form/choice.d.ts +54 -0
  39. package/dist/blocks/form/choice.js +175 -0
  40. package/dist/blocks/form/fields.d.ts +56 -0
  41. package/dist/blocks/form/fields.js +136 -0
  42. package/dist/blocks/form/file.d.ts +61 -0
  43. package/dist/blocks/form/file.js +121 -0
  44. package/dist/blocks/form/form.d.ts +66 -0
  45. package/dist/blocks/form/form.js +764 -0
  46. package/dist/blocks/form/list.d.ts +37 -0
  47. package/dist/blocks/form/list.js +70 -0
  48. package/dist/blocks/form/values.d.ts +20 -0
  49. package/dist/blocks/form/values.js +106 -0
  50. package/dist/blocks/icons.d.ts +14 -0
  51. package/dist/blocks/icons.js +74 -0
  52. package/dist/blocks/image.d.ts +39 -0
  53. package/dist/blocks/image.js +82 -0
  54. package/dist/blocks/interaction-transport.d.ts +42 -0
  55. package/dist/blocks/interaction-transport.js +79 -0
  56. package/dist/blocks/layout.d.ts +31 -0
  57. package/dist/blocks/layout.js +155 -0
  58. package/dist/blocks/log.d.ts +24 -0
  59. package/dist/blocks/log.js +111 -0
  60. package/dist/blocks/meter.d.ts +16 -0
  61. package/dist/blocks/meter.js +50 -0
  62. package/dist/blocks/page.d.ts +54 -0
  63. package/dist/blocks/page.js +360 -0
  64. package/dist/blocks/render.d.ts +31 -0
  65. package/dist/blocks/render.js +163 -0
  66. package/dist/blocks/surface-view.d.ts +41 -0
  67. package/dist/blocks/surface-view.js +137 -0
  68. package/dist/blocks/surface.d.ts +80 -0
  69. package/dist/blocks/surface.js +506 -0
  70. package/dist/blocks/tones.d.ts +23 -0
  71. package/dist/blocks/tones.js +63 -0
  72. package/dist/blocks/treeview.d.ts +22 -0
  73. package/dist/blocks/treeview.js +121 -0
  74. package/dist/blocks/width.d.ts +32 -0
  75. package/dist/blocks/width.js +70 -0
  76. package/dist/clipboard.d.ts +20 -0
  77. package/dist/clipboard.js +19 -0
  78. package/dist/editor-launch.d.ts +34 -0
  79. package/dist/editor-launch.js +70 -0
  80. package/dist/keymaps/emacs.d.ts +18 -0
  81. package/dist/keymaps/emacs.js +172 -0
  82. package/dist/keymaps/standard.d.ts +17 -0
  83. package/dist/keymaps/standard.js +175 -0
  84. package/dist/keymaps/types.d.ts +43 -0
  85. package/dist/keymaps/types.js +28 -0
  86. package/dist/keymaps/vim.d.ts +48 -0
  87. package/dist/keymaps/vim.js +291 -0
  88. package/dist/launch.d.ts +159 -0
  89. package/dist/launch.js +478 -0
  90. package/dist/sse.d.ts +112 -0
  91. package/dist/sse.js +418 -0
  92. package/dist/theme.d.ts +51 -0
  93. package/dist/theme.js +119 -0
  94. package/dist/ui/ambient.d.ts +78 -0
  95. package/dist/ui/ambient.js +264 -0
  96. package/dist/ui/app.d.ts +319 -0
  97. package/dist/ui/app.js +1539 -0
  98. package/dist/ui/composer.d.ts +66 -0
  99. package/dist/ui/composer.js +133 -0
  100. package/dist/ui/conversation-pane.d.ts +143 -0
  101. package/dist/ui/conversation-pane.js +615 -0
  102. package/dist/ui/help.d.ts +15 -0
  103. package/dist/ui/help.js +80 -0
  104. package/dist/ui/interactions.d.ts +91 -0
  105. package/dist/ui/interactions.js +477 -0
  106. package/dist/ui/picker.d.ts +26 -0
  107. package/dist/ui/picker.js +130 -0
  108. package/dist/ui/settings.d.ts +68 -0
  109. package/dist/ui/settings.js +254 -0
  110. package/dist/ui/sidebar.d.ts +99 -0
  111. package/dist/ui/sidebar.js +269 -0
  112. package/dist/ui/status-bar.d.ts +77 -0
  113. package/dist/ui/status-bar.js +146 -0
  114. package/package.json +4 -3
@@ -0,0 +1,80 @@
1
+ /**
2
+ * The key reference overlay (Task C5, UI refresh): `showHelp` mounts a
3
+ * centred overlay listing the active keymap's `describe()` table in two
4
+ * columns. Esc, `q`, or the keymap's own help key (`?` standard, `C-h`
5
+ * emacs, `g?` vim — a two-key sequence, matched via a pending-`g` flag)
6
+ * closes it. Built on the same `showOverlay` mechanism as C3's picker.
7
+ */
8
+ import { Key, matchesKey } from "@earendil-works/pi-tui";
9
+ /** The keymap's own help key doubles as a close key (undefined = none). */
10
+ function helpKey(keymap) {
11
+ if (keymap.name === "standard")
12
+ return "?";
13
+ if (keymap.name === "emacs")
14
+ return "\x08"; // C-h
15
+ if (keymap.name === "vim")
16
+ return "g?"; // the g-prefix chord (? stays search)
17
+ return undefined;
18
+ }
19
+ class HelpOverlay {
20
+ #rows;
21
+ #helpKey;
22
+ #options;
23
+ #overlay;
24
+ /** Set after a bare "g" while #helpKey is "g?" (the vim close chord). */
25
+ #pendingG = false;
26
+ constructor(keymap, options) {
27
+ this.#rows = keymap.describe();
28
+ this.#helpKey = helpKey(keymap);
29
+ this.#options = options;
30
+ }
31
+ /** The overlay handle is installed by {@link showHelp} after show. */
32
+ setOverlay(overlay) {
33
+ this.#overlay = overlay;
34
+ }
35
+ #close() {
36
+ this.#overlay?.hide();
37
+ this.#options.onClose?.();
38
+ }
39
+ handleInput(data) {
40
+ const helpClose = this.#helpKey === "g?"
41
+ ? this.#pendingG && data === "?"
42
+ : this.#helpKey !== undefined && data === this.#helpKey;
43
+ this.#pendingG = this.#helpKey === "g?" && data === "g";
44
+ if (matchesKey(data, Key.escape) || data === "q" || helpClose) {
45
+ this.#close();
46
+ }
47
+ // Every other key is consumed: the overlay is modal.
48
+ }
49
+ render(width) {
50
+ const half = Math.ceil(this.#rows.length / 2);
51
+ // The left column's field width: its longest "keys action" pair.
52
+ const leftWidth = Math.min(Math.max(...this.#rows
53
+ .slice(0, half)
54
+ .map((row) => row.keys.length + 2 + row.action.length)) + 4, Math.floor(width / 2));
55
+ const lines = ["key reference (esc or q closes)"];
56
+ for (let i = 0; i < half; i++) {
57
+ const left = this.#rows[i];
58
+ const right = this.#rows[i + half];
59
+ const leftText = `${left.keys} ${left.action}`;
60
+ if (right === undefined) {
61
+ lines.push(leftText);
62
+ }
63
+ else {
64
+ lines.push(`${leftText.padEnd(leftWidth)}${right.keys} ${right.action}`);
65
+ }
66
+ }
67
+ return lines;
68
+ }
69
+ invalidate() { }
70
+ }
71
+ /** Open the key reference overlay for `keymap`; Esc/q/help-key closes. */
72
+ export function showHelp(tui, keymap, options = {}) {
73
+ const help = new HelpOverlay(keymap, options);
74
+ const overlay = tui.showOverlay(help, {
75
+ anchor: "center",
76
+ width: "70%",
77
+ maxHeight: "70%",
78
+ });
79
+ help.setOverlay(overlay);
80
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * The interaction seam's TUI renderer (UI-blocks U6 Task 3, the parity
3
+ * rule): the pending-interaction stack rendered FROM BLOCK TREES — each
4
+ * record's body is a root `form` (placement `interaction`, spec §9.1)
5
+ * rendered through the shared renderer against a
6
+ * {@link createInteractionTransport}: the record IS the surface (the
7
+ * tree is the load, the form's submit POSTs `{values}` to the ONE
8
+ * generic answer route — ruling 20). The strip sits between the
9
+ * conversation pane and the composer, where the answer keys live.
10
+ *
11
+ * Genericity (the seam's whole point): the renderer renders the tree
12
+ * and knows NOTHING about kinds. `details` renders as one collapsed
13
+ * JSON line, never interpreted.
14
+ *
15
+ * Answering: ctrl+o ARMS the prompt (the app routes it via
16
+ * {@link TuiApp.handleKeybinding}; ctrl+a is the editor's
17
+ * cursorLineStart, so the prompt uses ctrl+o); while armed, keys route
18
+ * to the rendered form (choice rows take digits 1-9 as a direct pick —
19
+ * the medium-appropriate accelerator — plus the form's own keys; a
20
+ * multi-question form's stepper advances with Tab, reviews, and submits
21
+ * ONCE with Enter). Answer mode PINS the record it was armed on: if a
22
+ * poll replaces the top of the stack before the user keys an answer,
23
+ * the mode exits instead of answering a record the user never saw.
24
+ *
25
+ * Expiry mid-stepper (Review Focus 2): a submit that lands on an
26
+ * already-settled record (404 not_pending) refetches the record through
27
+ * {@link SigrunClient.getInteraction}, renders its settled status, and
28
+ * keeps the typed values visible but read-only (the form's state
29
+ * survives — the tree never changed).
30
+ *
31
+ * Refetch triggers (W3 Task 4 — the 2s poll interval is RETIRED): kicks
32
+ * arrive on each stream frame and on `interaction.*` host events. Kicks
33
+ * coalesce (one fetch in flight, one queued). Poll failures are QUIET —
34
+ * the prompt keeps its last state; answer failures (user-initiated) are
35
+ * LOUD via {@link onError}.
36
+ */
37
+ import { type Component } from "@earendil-works/pi-tui";
38
+ import type { InteractionWire, SigrunClient } from "../api.js";
39
+ import { SurfaceState } from "../blocks/surface.js";
40
+ import { type TuiTheme } from "../theme.js";
41
+ export declare class InteractionPrompter implements Component {
42
+ #private;
43
+ /** Answer failures (user-initiated — loud). The app routes to the pane. */
44
+ onError?: (message: string) => void;
45
+ /** Rendered-state changes (the app requests a repaint). */
46
+ onChanged?: () => void;
47
+ constructor(client: SigrunClient, options?: {
48
+ theme?: TuiTheme;
49
+ });
50
+ /** The latest pending list (top of stack first). */
51
+ get pending(): readonly InteractionWire[];
52
+ /** Whether keys are captured for answering. */
53
+ get answerMode(): boolean;
54
+ /** The live surface's state (tests drive non-submit dispatches
55
+ * through it). */
56
+ get surface(): SurfaceState | undefined;
57
+ /**
58
+ * Retarget the prompter: reset state and fetch immediately.
59
+ * `undefined` clears the prompt. (W3 Task 4: no poll interval —
60
+ * stream frames and `interaction.*` host events own the refreshes.)
61
+ */
62
+ setConversation(id: number | undefined): void;
63
+ /** Coalesced poll (the app's stream frames call this). */
64
+ kick(): void;
65
+ /**
66
+ * kick(), awaited. Production polls are fire-and-forget; tests need to
67
+ * await the poll's settle to assert the state after it.
68
+ */
69
+ kickAndWait(): Promise<void>;
70
+ /**
71
+ * Enter answer mode — only when the top pending item carries a
72
+ * block-tree body to answer. Returns whether the mode was entered (the
73
+ * caller consumes the key only then).
74
+ */
75
+ beginAnswer(): boolean;
76
+ /**
77
+ * Route one key while in answer mode. Returns true when consumed.
78
+ * Unrelated keys pass through (the seam never blocks — the user can
79
+ * keep typing while a prompt waits).
80
+ */
81
+ handleKey(data: string): boolean;
82
+ /** Detach (the app calls this on dispose): stale fetches never land. */
83
+ dispose(): void;
84
+ /**
85
+ * The prompt strip: nothing when no item is pending; otherwise the top
86
+ * item's header, its block tree, the answer affordance, and a stack
87
+ * count.
88
+ */
89
+ render(width: number): string[];
90
+ invalidate(): void;
91
+ }
@@ -0,0 +1,477 @@
1
+ /**
2
+ * The interaction seam's TUI renderer (UI-blocks U6 Task 3, the parity
3
+ * rule): the pending-interaction stack rendered FROM BLOCK TREES — each
4
+ * record's body is a root `form` (placement `interaction`, spec §9.1)
5
+ * rendered through the shared renderer against a
6
+ * {@link createInteractionTransport}: the record IS the surface (the
7
+ * tree is the load, the form's submit POSTs `{values}` to the ONE
8
+ * generic answer route — ruling 20). The strip sits between the
9
+ * conversation pane and the composer, where the answer keys live.
10
+ *
11
+ * Genericity (the seam's whole point): the renderer renders the tree
12
+ * and knows NOTHING about kinds. `details` renders as one collapsed
13
+ * JSON line, never interpreted.
14
+ *
15
+ * Answering: ctrl+o ARMS the prompt (the app routes it via
16
+ * {@link TuiApp.handleKeybinding}; ctrl+a is the editor's
17
+ * cursorLineStart, so the prompt uses ctrl+o); while armed, keys route
18
+ * to the rendered form (choice rows take digits 1-9 as a direct pick —
19
+ * the medium-appropriate accelerator — plus the form's own keys; a
20
+ * multi-question form's stepper advances with Tab, reviews, and submits
21
+ * ONCE with Enter). Answer mode PINS the record it was armed on: if a
22
+ * poll replaces the top of the stack before the user keys an answer,
23
+ * the mode exits instead of answering a record the user never saw.
24
+ *
25
+ * Expiry mid-stepper (Review Focus 2): a submit that lands on an
26
+ * already-settled record (404 not_pending) refetches the record through
27
+ * {@link SigrunClient.getInteraction}, renders its settled status, and
28
+ * keeps the typed values visible but read-only (the form's state
29
+ * survives — the tree never changed).
30
+ *
31
+ * Refetch triggers (W3 Task 4 — the 2s poll interval is RETIRED): kicks
32
+ * arrive on each stream frame and on `interaction.*` host events. Kicks
33
+ * coalesce (one fetch in flight, one queued). Poll failures are QUIET —
34
+ * the prompt keeps its last state; answer failures (user-initiated) are
35
+ * LOUD via {@link onError}.
36
+ */
37
+ import { Key, matchesKey } from "@earendil-works/pi-tui";
38
+ import { createInteractionTransport } from "../blocks/interaction-transport.js";
39
+ import { SurfaceState } from "../blocks/surface.js";
40
+ import { SurfaceView } from "../blocks/surface-view.js";
41
+ import { plainTuiTheme } from "../theme.js";
42
+ import { describeCommandError } from "./composer.js";
43
+ /** The answer-mode direct-pick keys: digits 1-9. */
44
+ const DIGIT = /^[1-9]$/;
45
+ /** The record-as-surface data document: the form binds `/values`, so
46
+ * the document is `{values}` — a settled record's stored answer seeds
47
+ * the read-only render (spec §6.5; secret fields render write-only
48
+ * regardless). */
49
+ function recordData(record) {
50
+ if (record.answer === undefined)
51
+ return {};
52
+ return { values: { ...record.answer.values } };
53
+ }
54
+ export class InteractionPrompter {
55
+ #client;
56
+ #theme;
57
+ #conversationId;
58
+ /** Monotonic per-conversation generation: stale fetches never land. */
59
+ #gen = 0;
60
+ #pending = [];
61
+ #answerMode = false;
62
+ /**
63
+ * The record answer mode is ARMED ON. Pinned at beginAnswer: a poll can
64
+ * replace the top of the stack at any time (the record settled via the
65
+ * webui, the extension resolved it, it expired), and a key pressed for
66
+ * the prompt the user READ must never answer the prompt that replaced
67
+ * it. Any top-identity change exits answer mode instead.
68
+ */
69
+ #answerCallId;
70
+ /**
71
+ * The rendered surface of the TOP record (rebuilt when the top's
72
+ * identity changes; NOT rebuilt on a mere list refresh of the same
73
+ * record — the form's typed state must survive a poll, Review Focus
74
+ * 2's "typed values stay visible").
75
+ */
76
+ #surface;
77
+ /** Coalescing poll state: one fetch in flight, at most one queued. */
78
+ #inFlight = false;
79
+ #queued = false;
80
+ /** Answer failures (user-initiated — loud). The app routes to the pane. */
81
+ onError;
82
+ /** Rendered-state changes (the app requests a repaint). */
83
+ onChanged;
84
+ constructor(client, options = {}) {
85
+ this.#client = client;
86
+ // No-color default (the shell pins structure; styling rides the
87
+ // app's theme when the host passes one).
88
+ this.#theme = options.theme ?? plainTuiTheme();
89
+ }
90
+ /** The latest pending list (top of stack first). */
91
+ get pending() {
92
+ return this.#pending;
93
+ }
94
+ /** Whether keys are captured for answering. */
95
+ get answerMode() {
96
+ return this.#answerMode;
97
+ }
98
+ /** The live surface's state (tests drive non-submit dispatches
99
+ * through it). */
100
+ get surface() {
101
+ return this.#surface?.state;
102
+ }
103
+ /**
104
+ * Retarget the prompter: reset state and fetch immediately.
105
+ * `undefined` clears the prompt. (W3 Task 4: no poll interval —
106
+ * stream frames and `interaction.*` host events own the refreshes.)
107
+ */
108
+ setConversation(id) {
109
+ this.#gen += 1;
110
+ this.#conversationId = id;
111
+ this.#pending = [];
112
+ this.#surface?.state.dispose();
113
+ this.#surface = undefined;
114
+ this.#exitAnswer();
115
+ // A fetch from the OLD conversation may still be in flight (or stuck);
116
+ // its result is fenced by the generation guard, so its promise is
117
+ // orphaned — the new conversation must not queue behind it.
118
+ this.#inFlight = false;
119
+ this.#queued = false;
120
+ if (id === undefined) {
121
+ this.#changed();
122
+ return;
123
+ }
124
+ void this.#refresh();
125
+ }
126
+ /** Coalesced poll (the app's stream frames call this). */
127
+ kick() {
128
+ void this.#refresh();
129
+ }
130
+ /**
131
+ * kick(), awaited. Production polls are fire-and-forget; tests need to
132
+ * await the poll's settle to assert the state after it.
133
+ */
134
+ async kickAndWait() {
135
+ await this.#refresh();
136
+ }
137
+ /**
138
+ * Enter answer mode — only when the top pending item carries a
139
+ * block-tree body to answer. Returns whether the mode was entered (the
140
+ * caller consumes the key only then).
141
+ */
142
+ beginAnswer() {
143
+ const top = this.#pending[0];
144
+ if (top === undefined || top.body === undefined)
145
+ return false;
146
+ this.#ensureSurface(top);
147
+ this.#answerMode = true;
148
+ // Pin the target: keys answer THIS record or nothing.
149
+ this.#answerCallId = top.callId;
150
+ this.#changed();
151
+ return true;
152
+ }
153
+ /**
154
+ * Route one key while in answer mode. Returns true when consumed.
155
+ * Unrelated keys pass through (the seam never blocks — the user can
156
+ * keep typing while a prompt waits).
157
+ */
158
+ handleKey(data) {
159
+ const top = this.#pending[0];
160
+ if (!this.#answerMode || top === undefined) {
161
+ this.#exitAnswer();
162
+ return false;
163
+ }
164
+ // Target-pin check: if a poll replaced the armed record, the keys
165
+ // the user is pressing belong to a prompt they never saw — exit
166
+ // answer mode and let the key pass through unanswered.
167
+ if (this.#answerCallId !== top.callId) {
168
+ this.#exitAnswer();
169
+ this.#changed();
170
+ return false;
171
+ }
172
+ if (matchesKey(data, Key.escape)) {
173
+ this.#exitAnswer();
174
+ this.#changed();
175
+ return true;
176
+ }
177
+ this.#ensureSurface(top);
178
+ if (this.#surface === undefined) {
179
+ this.#exitAnswer();
180
+ return false;
181
+ }
182
+ // A digit aims at the top record's choice field — the
183
+ // one-question gate's direct pick (1-9 answers immediately).
184
+ // Everything else — arrows, Tab, Enter, typed text — is the
185
+ // form's own key language. The shortcut only fires on a
186
+ // ONE-question gate (no stepper): a multi-question stepper's
187
+ // digits belong to the form's per-step choice editing.
188
+ if (DIGIT.test(data)) {
189
+ const body = top.body;
190
+ const choice = body === undefined ? undefined : firstChoice(body);
191
+ const stepper = body !== undefined &&
192
+ body.root.type === "form" &&
193
+ stepperSections(body.root, true) !== null;
194
+ if (choice !== undefined && !stepper) {
195
+ const index = Number(data) - 1;
196
+ const option = Array.isArray(choice.options)
197
+ ? choice.options[index]
198
+ : undefined;
199
+ if (option !== undefined) {
200
+ const form = formComponent(this.#surface);
201
+ if (form !== undefined) {
202
+ if (choice.multi === true) {
203
+ // Multi: toggle into the form's value; Enter (the
204
+ // form's key) submits the accumulated set.
205
+ const current = form.fieldValue(choice.name);
206
+ const set = new Set(Array.isArray(current) ? current : []);
207
+ if (set.has(option.id)) {
208
+ set.delete(option.id);
209
+ }
210
+ else {
211
+ set.add(option.id);
212
+ }
213
+ form.setFieldValue(choice.name, [...set]);
214
+ this.#changed();
215
+ return true;
216
+ }
217
+ form.setFieldValue(choice.name, option.id);
218
+ // Single-choice: the pick IS the answer — submit at
219
+ // once (the one-question gate's direct pick). The name
220
+ // must match the form's onSubmit action (custom actions
221
+ // like "decide" gate the transport's submit).
222
+ this.#surface.state.dispatch({ type: "submit", name: this.#surface.submitAction }, { values: { ...(form.values ?? {}) } });
223
+ return true;
224
+ }
225
+ }
226
+ // No matching option: the digit still belongs to the prompt.
227
+ return true;
228
+ }
229
+ // A stepper's digits route to the form (its stepper owns the
230
+ // per-step choice editing).
231
+ if (choice !== undefined) {
232
+ this.#surface.view.handleInput(data);
233
+ return true;
234
+ }
235
+ return true;
236
+ }
237
+ this.#surface.view.handleInput(data);
238
+ return true;
239
+ }
240
+ /** Build (or keep) the top record's surface pair. A mid-edit settle
241
+ * swap (Review Focus 2) reuses the same callId: the settled envelope
242
+ * flips access to read; the tree is unchanged so the typed values
243
+ * stay on screen. */
244
+ #ensureSurface(record) {
245
+ const existing = this.#surface;
246
+ if (existing !== undefined && existing.record.callId === record.callId) {
247
+ // A settled envelope (the mid-edit settle swap) sticks: the
248
+ // pending poll re-serves the record as pending, and the
249
+ // settled render must not revert to write access.
250
+ if (existing.settled !== undefined)
251
+ return;
252
+ const access = record.status === "pending" ? "write" : "read";
253
+ if (existing.access === access)
254
+ return;
255
+ this.#surface = { ...existing, record, access };
256
+ return;
257
+ }
258
+ const access = record.status === "pending" ? "write" : "read";
259
+ const conversationId = this.#conversationId;
260
+ if (conversationId === undefined || record.body === undefined)
261
+ return;
262
+ const root = record.body.root;
263
+ const submitAction = root.type === "form" ? (root.onSubmit ?? "submit") : "submit";
264
+ const transport = createInteractionTransport({
265
+ client: this.#client,
266
+ conversationId,
267
+ callId: record.callId,
268
+ tree: record.body,
269
+ data: recordData(record),
270
+ access,
271
+ submitAction,
272
+ onAnswered: () => {
273
+ // The settle lands via the next list poll (the server drops
274
+ // answered records from pending).
275
+ this.#exitAnswer();
276
+ void this.#refresh();
277
+ },
278
+ onSettledElsewhere: (_message, cause) => {
279
+ // The settle raced the user's submit (expiry, another channel,
280
+ // the extension): refetch the record and render it settled —
281
+ // the typed values stay visible, read-only (Review Focus 2).
282
+ const id = this.#conversationId;
283
+ if (id === undefined) {
284
+ this.onError?.(describeCommandError("answer", cause));
285
+ return;
286
+ }
287
+ this.#client.getInteraction(id, record.callId).then((fresh) => {
288
+ // Swap ONLY the record the user was working on; a
289
+ // conversation switch since the submit discards it.
290
+ if (this.#surface?.record.callId !== record.callId)
291
+ return;
292
+ // Only a genuinely settled record swaps in — a still-
293
+ // pending refetch (a 400, a network blip) must NOT wedge
294
+ // the card read-only: report the failure and stay
295
+ // writable (Review fix).
296
+ if (fresh.status === "pending") {
297
+ this.onError?.(describeCommandError("answer", cause));
298
+ this.#changed();
299
+ return;
300
+ }
301
+ this.#surface = this.#surface === undefined
302
+ ? undefined
303
+ : {
304
+ ...this.#surface,
305
+ settled: fresh,
306
+ access: "read",
307
+ };
308
+ // Flip the LIVE surface's access so the read-only
309
+ // render takes effect immediately (the transport's
310
+ // access was captured at load).
311
+ this.#surface?.state.setAccess("read");
312
+ this.#exitAnswer();
313
+ this.#changed();
314
+ }, () => {
315
+ this.onError?.(describeCommandError("answer", cause));
316
+ });
317
+ },
318
+ });
319
+ const state = new SurfaceState(transport);
320
+ const view = new SurfaceView(state, {
321
+ theme: this.#theme,
322
+ unicode: true,
323
+ options: { interaction: true },
324
+ });
325
+ this.#surface = { record, state, view, access, submitAction };
326
+ // The record's tree is static, so the load resolves within a
327
+ // microtask; the repaint lands via the change subscription.
328
+ state.onChange(() => this.#changed());
329
+ void state.load();
330
+ }
331
+ #exitAnswer() {
332
+ this.#answerMode = false;
333
+ this.#answerCallId = undefined;
334
+ }
335
+ /** Generation-guarded, coalesced fetch of the pending list. */
336
+ async #refresh() {
337
+ const id = this.#conversationId;
338
+ const gen = this.#gen;
339
+ if (id === undefined)
340
+ return;
341
+ if (this.#inFlight) {
342
+ this.#queued = true;
343
+ return;
344
+ }
345
+ this.#inFlight = true;
346
+ try {
347
+ do {
348
+ this.#queued = false;
349
+ let list;
350
+ try {
351
+ list = await this.#client.listInteractions(id);
352
+ }
353
+ catch {
354
+ // Quiet poll failure: keep the last pending list.
355
+ continue;
356
+ }
357
+ if (gen !== this.#gen)
358
+ return; // stale — a switch happened
359
+ // The route serves pending-only; the filter is belt for races.
360
+ this.#pending = list.interactions.filter((record) => record.status === "pending");
361
+ // The armed record left the top of the stack (settled via
362
+ // another channel, resolved by its extension, expired — or
363
+ // simply reordered): the user's armed keys belong to a prompt
364
+ // they are no longer looking at. Exit answer mode rather than
365
+ // risk answering a record they never saw.
366
+ if (this.#answerMode &&
367
+ this.#pending[0]?.callId !== this.#answerCallId) {
368
+ this.#exitAnswer();
369
+ }
370
+ // A top-identity change rebuilds the surface; a same-record
371
+ // refresh keeps it (typed values survive the poll).
372
+ const top = this.#pending[0];
373
+ if (top === undefined) {
374
+ this.#surface?.state.dispose();
375
+ this.#surface = undefined;
376
+ }
377
+ else if (this.#surface?.record.callId !== top.callId) {
378
+ this.#surface?.state.dispose();
379
+ this.#surface = undefined;
380
+ // Build eagerly (not at render) so the async load settles
381
+ // with the poll's flush — a strip that waits a frame for
382
+ // "loading…" reads as a flicker.
383
+ this.#ensureSurface(top);
384
+ }
385
+ this.#changed();
386
+ } while (this.#queued && gen === this.#gen);
387
+ }
388
+ finally {
389
+ if (gen === this.#gen)
390
+ this.#inFlight = false;
391
+ }
392
+ }
393
+ #changed() {
394
+ this.onChanged?.();
395
+ }
396
+ /** Detach (the app calls this on dispose): stale fetches never land. */
397
+ dispose() {
398
+ this.#gen += 1;
399
+ }
400
+ /**
401
+ * The prompt strip: nothing when no item is pending; otherwise the top
402
+ * item's header, its block tree, the answer affordance, and a stack
403
+ * count.
404
+ */
405
+ render(width) {
406
+ const top = this.#pending[0];
407
+ if (top === undefined)
408
+ return [];
409
+ const lines = [];
410
+ const shown = this.#surface?.settled ?? top;
411
+ const suffix = this.#answerMode
412
+ ? " — answering"
413
+ : shown.status === "pending"
414
+ ? ""
415
+ : ` — ${shown.status}`;
416
+ lines.push(`[interaction] ${shown.kind}${suffix}`);
417
+ if (top.body !== undefined) {
418
+ this.#ensureSurface(top);
419
+ if (this.#surface !== undefined) {
420
+ lines.push(...this.#surface.view.render(width));
421
+ }
422
+ }
423
+ if (top.details !== undefined) {
424
+ lines.push(`[details] ${JSON.stringify(top.details)}`);
425
+ }
426
+ if (this.#pending.length > 1) {
427
+ lines.push(`+${this.#pending.length - 1} more pending`);
428
+ }
429
+ if (top.body !== undefined) {
430
+ lines.push(this.#answerMode
431
+ ? "1-9 answer · Tab next · esc cancel"
432
+ : "ctrl+o to answer");
433
+ }
434
+ return lines;
435
+ }
436
+ invalidate() {
437
+ this.#surface?.view.invalidate();
438
+ }
439
+ }
440
+ import { stepperSections } from "../blocks/form/form.js";
441
+ /** The form's first choice field (the digit accelerator's target), or
442
+ * undefined when the form holds none. */
443
+ function firstChoice(tree) {
444
+ const root = tree.root;
445
+ if (root.type !== "form")
446
+ return undefined;
447
+ const walk = (children) => {
448
+ for (const child of children) {
449
+ if (typeof child !== "object" || child === null)
450
+ continue;
451
+ const record = child;
452
+ if (record.type === "choice" && typeof record.name === "string") {
453
+ // SAFETY: the type/name guard above is the choice field's
454
+ // identifying shape (the tree passed placement validation).
455
+ return record;
456
+ }
457
+ if (record.type === "section" && Array.isArray(record.children)) {
458
+ const found = walk(record.children);
459
+ if (found !== undefined)
460
+ return found;
461
+ }
462
+ }
463
+ return undefined;
464
+ };
465
+ return walk(root.children);
466
+ }
467
+ /** The wrapped form component, when the surface's root is a form. */
468
+ function formComponent(surface) {
469
+ const inner = surface.view.inner;
470
+ if (inner === undefined)
471
+ return undefined;
472
+ if (!("setFieldValue" in inner))
473
+ return undefined;
474
+ // SAFETY: the WidthGated wrapper forwards the form introspection
475
+ // (the render seam's forwarding contract).
476
+ return inner;
477
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The picker overlay (Task C3, UI refresh): a centred `showOverlay` box —
3
+ * a title line, a one-line fuzzy filter, and a `SelectList`. Typing
4
+ * re-filters (pi-tui's `fuzzyFilter` over label + detail), Enter picks,
5
+ * Esc closes, and a mouse click picks (SelectList's own mouse handling).
6
+ *
7
+ * Conversation/model/command pickers are built by the app from the
8
+ * sidebar tree, `listModels()`, and the slash catalog respectively.
9
+ */
10
+ import { type TUI } from "@earendil-works/pi-tui";
11
+ import type { TuiTheme } from "../theme.js";
12
+ export interface PickerItem<T> {
13
+ readonly label: string;
14
+ readonly detail?: string;
15
+ readonly value: T;
16
+ }
17
+ export interface PickerOptions<T> {
18
+ readonly title: string;
19
+ readonly onPick: (value: T) => void;
20
+ /** Fires on close (pick OR cancel) so the app can clear overlayOpen. */
21
+ readonly onClose?: () => void;
22
+ /** The token theme; defaults to plain identity styling. */
23
+ readonly theme?: TuiTheme;
24
+ }
25
+ /** Open a centred picker overlay; Enter/click picks, Esc closes. */
26
+ export declare function openPicker<T>(tui: TUI, items: readonly PickerItem<T>[], options: PickerOptions<T>): void;