@sigrun/tui 0.0.0-stage → 0.1.1

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 (110) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/LICENSE +21 -0
  3. package/README.md +9 -2
  4. package/dist/actions.d.ts +119 -0
  5. package/dist/actions.js +12 -0
  6. package/dist/api.d.ts +152 -0
  7. package/dist/api.js +189 -0
  8. package/dist/blocks/braille.d.ts +41 -0
  9. package/dist/blocks/braille.js +131 -0
  10. package/dist/blocks/chart.d.ts +20 -0
  11. package/dist/blocks/chart.js +397 -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 +96 -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 +53 -0
  45. package/dist/blocks/form/form.js +703 -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 +104 -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/layout.d.ts +31 -0
  55. package/dist/blocks/layout.js +155 -0
  56. package/dist/blocks/log.d.ts +24 -0
  57. package/dist/blocks/log.js +111 -0
  58. package/dist/blocks/meter.d.ts +16 -0
  59. package/dist/blocks/meter.js +50 -0
  60. package/dist/blocks/page.d.ts +54 -0
  61. package/dist/blocks/page.js +355 -0
  62. package/dist/blocks/render.d.ts +31 -0
  63. package/dist/blocks/render.js +145 -0
  64. package/dist/blocks/surface-view.d.ts +33 -0
  65. package/dist/blocks/surface-view.js +86 -0
  66. package/dist/blocks/surface.d.ts +75 -0
  67. package/dist/blocks/surface.js +468 -0
  68. package/dist/blocks/tones.d.ts +23 -0
  69. package/dist/blocks/tones.js +63 -0
  70. package/dist/blocks/treeview.d.ts +22 -0
  71. package/dist/blocks/treeview.js +121 -0
  72. package/dist/blocks/width.d.ts +32 -0
  73. package/dist/blocks/width.js +70 -0
  74. package/dist/clipboard.d.ts +20 -0
  75. package/dist/clipboard.js +19 -0
  76. package/dist/editor-launch.d.ts +34 -0
  77. package/dist/editor-launch.js +70 -0
  78. package/dist/keymaps/emacs.d.ts +18 -0
  79. package/dist/keymaps/emacs.js +172 -0
  80. package/dist/keymaps/standard.d.ts +17 -0
  81. package/dist/keymaps/standard.js +175 -0
  82. package/dist/keymaps/types.d.ts +43 -0
  83. package/dist/keymaps/types.js +28 -0
  84. package/dist/keymaps/vim.d.ts +48 -0
  85. package/dist/keymaps/vim.js +291 -0
  86. package/dist/launch.d.ts +149 -0
  87. package/dist/launch.js +446 -0
  88. package/dist/sse.d.ts +109 -0
  89. package/dist/sse.js +401 -0
  90. package/dist/theme.d.ts +51 -0
  91. package/dist/theme.js +119 -0
  92. package/dist/ui/ambient.d.ts +81 -0
  93. package/dist/ui/ambient.js +301 -0
  94. package/dist/ui/app.d.ts +245 -0
  95. package/dist/ui/app.js +1325 -0
  96. package/dist/ui/composer.d.ts +66 -0
  97. package/dist/ui/composer.js +125 -0
  98. package/dist/ui/conversation-pane.d.ts +123 -0
  99. package/dist/ui/conversation-pane.js +463 -0
  100. package/dist/ui/help.d.ts +15 -0
  101. package/dist/ui/help.js +80 -0
  102. package/dist/ui/interactions.d.ts +90 -0
  103. package/dist/ui/interactions.js +330 -0
  104. package/dist/ui/picker.d.ts +26 -0
  105. package/dist/ui/picker.js +130 -0
  106. package/dist/ui/sidebar.d.ts +99 -0
  107. package/dist/ui/sidebar.js +269 -0
  108. package/dist/ui/status-bar.d.ts +77 -0
  109. package/dist/ui/status-bar.js +146 -0
  110. package/package.json +49 -5
@@ -0,0 +1,463 @@
1
+ /**
2
+ * The conversation pane (Task 6; restyled in C2, UI refresh): renders a
3
+ * `ConversationViewWire`'s entries as TURNS (B1's `groupTurns`) instead
4
+ * of raw entries — a user turn is the accent `▎` bar on every line, an
5
+ * assistant turn is prose plus ONE collapsible activity line
6
+ * (`▸ Thinking · 2 tools`; `▾` when open) that unfolds into the thinking
7
+ * text and `✓ tool preview` rows (`✗` error, `·` no result yet — the
8
+ * wire carries no per-tool running marker, so `·` covers pending too).
9
+ * `pi.compaction` renders the centred `── context compacted ──` divider. The `[tool]`/`[thinking]` tags of
10
+ * the entry-era pane are gone.
11
+ *
12
+ * Task 2c: an EXPANDED input (`/skill:name`, a prompt template) is one
13
+ * entry (`sigrun.user.expanded`, `data {typed, command}`, the expanded
14
+ * text on `model`) that groups as a USER turn — the body is the TYPED
15
+ * command with a dim `▸ expanded from /name` chip line that folds the
16
+ * full admitted text open/closed (closed by default).
17
+ *
18
+ * Focus (the C3 action layer's seam): `focusIndex` names the focused
19
+ * turn (`undefined` = none — the ScrollView follows the end), the
20
+ * focused turn's FIRST line carries the `surface2` background and a `›`
21
+ * gutter marker; `lineRangeOf` reports the turn's line offsets from the
22
+ * last render for `ScrollView.scrollTo`; `textOf`/`entryIdOf` are the
23
+ * copy payload and wire identity of a turn.
24
+ *
25
+ * Mouse (additive — the pane is fully keyboard-usable): each activity
26
+ * line is a `MouseRegion` whose left click toggles the fold; a click on
27
+ * a turn's body sets the focus to that turn.
28
+ *
29
+ * The SSE contract (Task 4): every snapshot/frame is a FULL revision, so
30
+ * {@link showView} REPLACES the rendered state wholesale. Folds and
31
+ * focus are keyed by turn INDEX within the latest view — a replacement
32
+ * re-derives the turns and keeps the index-based state (the action
33
+ * layer owns re-targeting when the structure shifts).
34
+ *
35
+ * Unknown entry kinds render a fail-visible fallback naming the kind
36
+ * (the webui's rule — a new server-side kind must never vanish silently).
37
+ *
38
+ * Error/notice lines (Task 7): the composer surfaces its immediate
39
+ * failures as `[error]`/`[notice]` rows. They are NOT stream state, so
40
+ * {@link showView}'s wholesale replacement preserves them (fix wave F4:
41
+ * the shell discards them on every conversation swap via
42
+ * {@link ConversationPane.clearNotices}).
43
+ */
44
+ import { Container, Markdown, MouseRegion, Text, } from "@earendil-works/pi-tui";
45
+ import { activityLabel, expandedUserEntry, groupTurns, } from "@sigrun/shared/turns";
46
+ import { plainTuiTheme } from "../theme.js";
47
+ /**
48
+ * The message's text: string content as-is, or its text blocks joined
49
+ * with a blank line (the webui `messageText` rule, mirrored).
50
+ */
51
+ function messageText(message) {
52
+ const content = message.content;
53
+ if (typeof content === "string")
54
+ return content;
55
+ if (content === undefined)
56
+ return "";
57
+ return content
58
+ .filter((block) => block.type === "text" && typeof block.text === "string")
59
+ .map((block) => block.text)
60
+ .join("\n\n");
61
+ }
62
+ /** The message's non-text/non-thinking parts (uploads), as path markers. */
63
+ function attachmentMarkers(message) {
64
+ const content = message.content;
65
+ if (typeof content === "string" || content === undefined)
66
+ return [];
67
+ return content
68
+ .filter((block) => block.type !== "text" && block.type !== "thinking")
69
+ .map((block) => `[${block.type ?? "part"}: ${block.path ?? "?"}]`);
70
+ }
71
+ /** A turn's fallback body (an `other` kind's text), if any. */
72
+ function entryText(entry) {
73
+ return (entry.model ?? [])
74
+ .map((message) => messageText(message))
75
+ .filter((part) => part !== "")
76
+ .join(" ");
77
+ }
78
+ /** A user entry's model-facing text (message texts + upload markers) — the
79
+ * plain-turn body, and the EXPANDED text an expanded turn's chip reveals. */
80
+ function userModelText(entry) {
81
+ const parts = (entry.model ?? []).flatMap((message) => [
82
+ messageText(message),
83
+ ...attachmentMarkers(message),
84
+ ]);
85
+ return parts.filter((part) => part !== "").join("\n");
86
+ }
87
+ /** The markdown theme rides the TuiTheme's token colours. */
88
+ function markdownTheme(theme) {
89
+ return {
90
+ heading: (text) => theme.bold(theme.fg("accent")(text)),
91
+ link: theme.fg("accent"),
92
+ linkUrl: theme.dim,
93
+ code: theme.fg("code"),
94
+ codeBlock: theme.fg("code"),
95
+ codeBlockBorder: theme.fg("border"),
96
+ quote: theme.italic,
97
+ quoteBorder: theme.fg("border"),
98
+ hr: theme.fg("border"),
99
+ listBullet: theme.fg("accent"),
100
+ bold: theme.bold,
101
+ italic: theme.italic,
102
+ strikethrough: (text) => text,
103
+ underline: (text) => text,
104
+ };
105
+ }
106
+ /** Pad/centre the compaction divider to the render width. */
107
+ function compactionLine(theme) {
108
+ return new (class {
109
+ render(width) {
110
+ const label = "── context compacted ──";
111
+ const pad = Math.max(0, Math.floor((width - label.length) / 2));
112
+ return [theme.dim(`${" ".repeat(pad)}${label}`)];
113
+ }
114
+ invalidate() { }
115
+ })();
116
+ }
117
+ function noticeComponent(notice) {
118
+ return new Text(`[${notice.kind}] ${notice.text}`, 0, 0);
119
+ }
120
+ /** The notice backlog cap (fix wave A5 — most recent win). */
121
+ const MAX_NOTICES = 10;
122
+ /**
123
+ * A one-line render slot whose text is painted by the owner each frame
124
+ * (pi-tui's `Text` drops whitespace-only text and caches by content —
125
+ * neither fits a line whose text changes under a `MouseRegion`).
126
+ */
127
+ class RenderSlot {
128
+ paint = () => "";
129
+ render(width) {
130
+ return [this.paint(width)];
131
+ }
132
+ invalidate() { }
133
+ }
134
+ /** The blank separator between turns. */
135
+ const TURN_GAP = 1;
136
+ export class ConversationPane {
137
+ #container = new Container();
138
+ #theme;
139
+ #markdownTheme;
140
+ #notices = [];
141
+ /** The notices' mounted components, aligned with `#notices`. */
142
+ #noticeComponents = [];
143
+ #turns = [];
144
+ #focus;
145
+ /** Fold state by turn index (assistant turns only fold). */
146
+ #open = new Set();
147
+ /** Line spans from the last render, aligned with `#turns`. */
148
+ #ranges = [];
149
+ /** The mounted turn records, aligned with `#turns`. */
150
+ #mounted = [];
151
+ constructor(options) {
152
+ this.#theme = options?.theme ?? plainTuiTheme();
153
+ this.#markdownTheme = markdownTheme(this.#theme);
154
+ }
155
+ /** The current view's turns (B1 grouping), in render order. */
156
+ turns() {
157
+ return this.#turns;
158
+ }
159
+ /** The focused turn index; `undefined` = none (follow the end). */
160
+ get focusIndex() {
161
+ return this.#focus;
162
+ }
163
+ /** Focus a turn (or clear with `undefined`); repaints the marker. */
164
+ setFocus(index) {
165
+ if (index !== undefined && (index < 0 || index >= this.#turns.length)) {
166
+ return;
167
+ }
168
+ if (index === this.#focus)
169
+ return;
170
+ this.#focus = index;
171
+ this.#container.invalidate();
172
+ }
173
+ /** Open/close an assistant turn's activity fold — or an expanded user
174
+ * turn's chip disclosure (Task 2c: the full admitted text hides behind
175
+ * the `expanded from /name` line, same fold state, same seam). */
176
+ toggleFold(index) {
177
+ const turn = this.#turns[index];
178
+ if (turn === undefined)
179
+ return;
180
+ const foldable = turn.kind === "assistant" ||
181
+ (turn.kind === "user" && expandedUserEntry(turn.entry) !== undefined);
182
+ if (!foldable)
183
+ return;
184
+ if (this.#open.has(index)) {
185
+ this.#open.delete(index);
186
+ }
187
+ else {
188
+ this.#open.add(index);
189
+ }
190
+ this.#rebuild();
191
+ }
192
+ /**
193
+ * The turn's line offsets from the last render (for
194
+ * `ScrollView.scrollTo`): `start` is the turn's first line, `end` the
195
+ * first line after it. Undefined before a render or out of range.
196
+ */
197
+ lineRangeOf(index) {
198
+ return this.#ranges[index];
199
+ }
200
+ /** The turn's markdown source (the copy payload). */
201
+ textOf(index) {
202
+ const turn = this.#turns[index];
203
+ if (turn === undefined)
204
+ return "";
205
+ switch (turn.kind) {
206
+ case "user": {
207
+ // An expanded turn copies the TYPED command (what the user
208
+ // wrote — re-submitting it re-expands server-side).
209
+ const expanded = expandedUserEntry(turn.entry);
210
+ if (expanded !== undefined)
211
+ return expanded.typed;
212
+ return userModelText(turn.entry);
213
+ }
214
+ case "assistant":
215
+ return turn.text;
216
+ case "other":
217
+ return entryText(turn.entry);
218
+ }
219
+ }
220
+ /** The turn's wire identity: the user entry id / assistant lastEntryId. */
221
+ entryIdOf(index) {
222
+ const turn = this.#turns[index];
223
+ if (turn === undefined)
224
+ return -1;
225
+ switch (turn.kind) {
226
+ case "user":
227
+ return turn.entry.id;
228
+ case "assistant":
229
+ return turn.lastEntryId;
230
+ case "other":
231
+ return turn.entry.id;
232
+ }
233
+ }
234
+ /**
235
+ * Replace the pane with the projection of `wire` (the SSE rule: a full
236
+ * revision replaces; a reconnect's snapshot subsumes missed frames).
237
+ * Composer notices are not stream state: they re-render after the
238
+ * turns.
239
+ */
240
+ showView(wire) {
241
+ this.#turns = groupTurns(wire.entries);
242
+ if (this.#focus !== undefined && this.#focus >= this.#turns.length) {
243
+ this.#focus = undefined;
244
+ }
245
+ this.#rebuild();
246
+ }
247
+ /** Append an `[error]` line (a failed composer action's surface). */
248
+ showError(text) {
249
+ this.#appendNotice({ kind: "error", text });
250
+ }
251
+ /** Append a `[notice]` line (an outcome, e.g. an abort that settled). */
252
+ showNotice(text) {
253
+ this.#appendNotice({ kind: "notice", text });
254
+ }
255
+ /**
256
+ * Discard all composer-surfaced lines (fix wave F4): the shell calls
257
+ * this on every conversation swap so a stale error never renders in
258
+ * the new conversation.
259
+ */
260
+ clearNotices() {
261
+ if (this.#notices.length === 0)
262
+ return;
263
+ this.#notices.length = 0;
264
+ for (const component of this.#noticeComponents.splice(0)) {
265
+ this.#container.removeChild(component);
266
+ }
267
+ this.#container.invalidate();
268
+ }
269
+ #appendNotice(notice) {
270
+ this.#notices.push(notice);
271
+ const component = noticeComponent(notice);
272
+ this.#noticeComponents.push(component);
273
+ this.#container.addChild(component);
274
+ while (this.#notices.length > MAX_NOTICES) {
275
+ this.#notices.shift();
276
+ const oldest = this.#noticeComponents.shift();
277
+ if (oldest === undefined)
278
+ break;
279
+ this.#container.removeChild(oldest);
280
+ }
281
+ this.#container.invalidate();
282
+ }
283
+ /** Rebuild the container from the current turns + folds + notices. */
284
+ #rebuild() {
285
+ this.#container.clear();
286
+ this.#noticeComponents.length = 0;
287
+ this.#mounted = this.#turns.map((turn, index) => this.#mountTurn(turn, index));
288
+ for (const mounted of this.#mounted) {
289
+ for (const component of mounted.components) {
290
+ this.#container.addChild(component);
291
+ }
292
+ this.#container.addChild(new RenderSlot()); // turn gap
293
+ }
294
+ for (const notice of this.#notices) {
295
+ const component = noticeComponent(notice);
296
+ this.#noticeComponents.push(component);
297
+ this.#container.addChild(component);
298
+ }
299
+ this.#container.invalidate();
300
+ }
301
+ /** Project one turn into its components (mouse regions included). */
302
+ #mountTurn(turn, index) {
303
+ const components = [];
304
+ const focusThis = () => {
305
+ this.setFocus(index);
306
+ return { handled: true };
307
+ };
308
+ const body = (component) => new MouseRegion(component, (event) => event.type === "click" && event.button === "left"
309
+ ? focusThis()
310
+ : undefined);
311
+ switch (turn.kind) {
312
+ case "user": {
313
+ const expanded = expandedUserEntry(turn.entry);
314
+ if (expanded !== undefined) {
315
+ // The expanded turn (Task 2c): the TYPED command is the body
316
+ // (the accent bar still marks it a user turn); a dim chip line
317
+ // folds the full admitted text — closed by default, or
318
+ // `/skill:greet hej` would dump the whole skill body.
319
+ components.push(body(new Markdown(expanded.typed, 0, 0, this.#markdownTheme)));
320
+ const slot = new RenderSlot();
321
+ slot.paint = () => this.#theme.dim(`${this.#open.has(index) ? "▾" : "▸"} expanded from /${expanded.command ?? "…"}`);
322
+ components.push(new MouseRegion(slot, (event) => {
323
+ if (event.type === "click" && event.button === "left") {
324
+ this.toggleFold(index);
325
+ return { handled: true };
326
+ }
327
+ return undefined;
328
+ }));
329
+ if (this.#open.has(index)) {
330
+ for (const line of userModelText(turn.entry).split("\n")) {
331
+ components.push(new Text(this.#theme.dim(line), 0, 0));
332
+ }
333
+ }
334
+ break;
335
+ }
336
+ const text = this.textOf(index);
337
+ if (text !== "") {
338
+ components.push(body(new Markdown(text, 0, 0, this.#markdownTheme)));
339
+ }
340
+ break;
341
+ }
342
+ case "assistant": {
343
+ if (turn.text.trim() !== "") {
344
+ components.push(body(new Markdown(turn.text, 0, 0, this.#markdownTheme)));
345
+ }
346
+ const label = activityLabel(turn);
347
+ if (label !== "") {
348
+ // The slot renders one line; the fold marker + label are
349
+ // painted per frame (the fold state changes after mount, and
350
+ // a Text's content cache would pin the stale marker).
351
+ const slot = new RenderSlot();
352
+ slot.paint = () => this.#theme.dim(`${this.#open.has(index) ? "▾" : "▸"} ${label}`);
353
+ components.push(new MouseRegion(slot, (event) => {
354
+ if (event.type === "click" && event.button === "left") {
355
+ this.toggleFold(index);
356
+ return { handled: true };
357
+ }
358
+ return undefined;
359
+ }));
360
+ if (this.#open.has(index)) {
361
+ for (const thinking of turn.thinking) {
362
+ if (thinking.trim() === "")
363
+ continue;
364
+ components.push(new Text(this.#theme.dim(this.#theme.italic(thinking)), 0, 0));
365
+ }
366
+ for (const tool of turn.tools) {
367
+ const glyph = tool.isError
368
+ ? "✗"
369
+ : tool.resultEntryId === undefined
370
+ ? "·"
371
+ : "✓";
372
+ const preview = tool.preview === "" ? "" : ` ${tool.preview}`;
373
+ const row = `${glyph} ${tool.name}${preview}`;
374
+ components.push(new Text(tool.isError ? this.#theme.fg("muted")(row) : row, 0, 0));
375
+ }
376
+ }
377
+ }
378
+ break;
379
+ }
380
+ case "other": {
381
+ if (turn.entry.kind === "pi.compaction") {
382
+ components.push(compactionLine(this.#theme));
383
+ break;
384
+ }
385
+ const text = entryText(turn.entry);
386
+ if (turn.entry.kind === "pi.system") {
387
+ if (text !== "")
388
+ components.push(new Text(text, 0, 0));
389
+ }
390
+ else {
391
+ // Fail visible (the webui rule): name the kind plus any text.
392
+ const suffix = text === "" ? "" : ` — ${text}`;
393
+ components.push(new Text(`unsupported entry kind: ${turn.entry.kind}${suffix}`, 0, 0));
394
+ }
395
+ break;
396
+ }
397
+ }
398
+ return { start: 0, end: 0, components, heights: [] };
399
+ }
400
+ render(width) {
401
+ const lines = this.#container.render(width);
402
+ this.#recordRanges(lines, width);
403
+ return this.#decorate(lines, width);
404
+ }
405
+ /**
406
+ * Record each turn's line span from the render: every turn is followed
407
+ * by one blank gap line (see `#rebuild`), so heights + TURN_GAP walk
408
+ * the offsets deterministically.
409
+ */
410
+ #recordRanges(_lines, width) {
411
+ const ranges = [];
412
+ let offset = 0;
413
+ for (const [index, mounted] of this.#mounted.entries()) {
414
+ let height = 0;
415
+ mounted.heights.length = 0;
416
+ for (const component of mounted.components) {
417
+ const componentHeight = component.render(width).length;
418
+ mounted.heights.push(componentHeight);
419
+ height += componentHeight;
420
+ }
421
+ mounted.start = offset;
422
+ mounted.end = offset + height;
423
+ ranges.push({ start: mounted.start, end: mounted.end });
424
+ offset += height + TURN_GAP;
425
+ }
426
+ this.#ranges = ranges;
427
+ }
428
+ /**
429
+ * Apply the focus + user-bar chrome: the focused turn's FIRST line
430
+ * gets the `surface2` background and the `›` gutter marker; every line
431
+ * of a user turn carries the accent `▎` bar. The two are independent —
432
+ * a focused user turn's first line composes `› ▎ …` on surface2.
433
+ */
434
+ #decorate(lines, width) {
435
+ const out = [...lines];
436
+ for (const [index, mounted] of this.#mounted.entries()) {
437
+ const turn = this.#turns[index];
438
+ if (turn === undefined)
439
+ continue;
440
+ const focused = index === this.#focus;
441
+ const bar = turn.kind === "user"
442
+ ? `${this.#theme.fg("accent")("▎")} `
443
+ : "";
444
+ for (let line = mounted.start; line < mounted.end; line++) {
445
+ const original = out[line] ?? "";
446
+ if (line === mounted.start && focused) {
447
+ const marked = `› ${bar}${original}`;
448
+ out[line] = this.#theme.bg("surface2")(marked + " ".repeat(Math.max(0, width - marked.length)));
449
+ }
450
+ else if (turn.kind === "user") {
451
+ out[line] = `${bar}${original}`;
452
+ }
453
+ else if (focused) {
454
+ out[line] = ` ${original}`;
455
+ }
456
+ }
457
+ }
458
+ return out;
459
+ }
460
+ invalidate() {
461
+ this.#container.invalidate();
462
+ }
463
+ }
@@ -0,0 +1,15 @@
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 { type TUI } from "@earendil-works/pi-tui";
9
+ import type { Keymap } from "../keymaps/types.js";
10
+ export interface HelpOptions {
11
+ /** Fires on close so the app can clear its overlayOpen flag. */
12
+ readonly onClose?: () => void;
13
+ }
14
+ /** Open the key reference overlay for `keymap`; Esc/q/help-key closes. */
15
+ export declare function showHelp(tui: TUI, keymap: Keymap, options?: HelpOptions): void;
@@ -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,90 @@
1
+ /**
2
+ * The interaction seam's TUI renderer (Task 8 — the parity rule): the
3
+ * pending-interaction stack rendered as an answerable prompt strip
4
+ * between the conversation pane and the composer (spec §3.2: "a visible
5
+ * prompt in the conversation pane" — the TUI's medium-appropriate form is
6
+ * a strip directly above the composer, where the answer keys live).
7
+ *
8
+ * Genericity (the seam's whole point): this component renders the closed
9
+ * primitives (text / options / fields / diff) and the envelope, and knows
10
+ * NOTHING about kinds — there is no gate-specific concept here. `details` renders as
11
+ * one collapsed JSON line, never interpreted (the single read is the
12
+ * webui-parity `details.multi === true` multi-select flag).
13
+ *
14
+ * Answering: ctrl+o enters answer mode (the app routes it via
15
+ * {@link TuiApp.handleKeybinding}; ctrl+a is the editor's cursorLineStart,
16
+ * so the prompt uses ctrl+o). Digits 1-9 pick options — single-choice
17
+ * answers immediately; multi (`details.multi`) toggles and enter submits
18
+ * the accumulated set; esc exits. Answer mode PINS the record it was
19
+ * armed on: if a poll replaces the top of the stack before the user keys
20
+ * an answer, the mode exits instead of answering a record the user never
21
+ * saw. The answer POSTs through the ONE generic route
22
+ * (`answerInteraction`); the record clears when the next list poll
23
+ * reflects the settle (the server drops answered records from pending).
24
+ *
25
+ * Medium limits (the parity rule's documented exceptions):
26
+ * - Form `fields` render read-only with a "answer in the webui" hint —
27
+ * the TUI's composer is the conversation's text input; capturing it for
28
+ * form values would collide with submission routing. v1 answers
29
+ * option-based prompts only.
30
+ * - Option `tone` is a color/severity hint; the shell pins a no-color
31
+ * theme (styling is a later task), so tone is not rendered.
32
+ *
33
+ * Refetch triggers (W3 Task 4 — the 2s poll interval is RETIRED): kicks
34
+ * arrive on each stream frame (a revision may change the pending set) and
35
+ * on `interaction.*` host events (the app wires `watchEvents`'s
36
+ * `interaction.pending`/`interaction.settled` to {@link kick}). Kicks
37
+ * coalesce (one fetch in flight, one queued). Poll failures are
38
+ * QUIET — the prompt keeps its last state; stream transport health is
39
+ * reported separately by the watcher's error/open transition notices
40
+ * (the launcher's stream-health wiring). Answer failures (user-initiated)
41
+ * are LOUD via {@link onError} (the app routes them to the pane's
42
+ * `[error]` line).
43
+ */
44
+ import { type Component } from "@earendil-works/pi-tui";
45
+ import type { InteractionWire, SigrunClient } from "../api.js";
46
+ export declare class InteractionPrompter implements Component {
47
+ #private;
48
+ /** Answer failures (user-initiated — loud). The app routes to the pane. */
49
+ onError?: (message: string) => void;
50
+ /** Rendered-state changes (the app requests a repaint). */
51
+ onChanged?: () => void;
52
+ constructor(client: SigrunClient);
53
+ /** The latest pending list (top of stack first). */
54
+ get pending(): readonly InteractionWire[];
55
+ /** Whether digit/enter/esc keys are captured for answering. */
56
+ get answerMode(): boolean;
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 is option-
72
+ * answerable. Returns whether the mode was entered (the caller consumes
73
+ * 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, body primitives, answer affordance, and a stack count.
87
+ */
88
+ render(_width: number): string[];
89
+ invalidate(): void;
90
+ }