@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,615 @@
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
+ import { SurfaceState } from "../blocks/surface.js";
48
+ import { SurfaceView } from "../blocks/surface-view.js";
49
+ /**
50
+ * The message's text: string content as-is, or its text blocks joined
51
+ * with a blank line (the webui `messageText` rule, mirrored).
52
+ */
53
+ function messageText(message) {
54
+ const content = message.content;
55
+ if (typeof content === "string")
56
+ return content;
57
+ if (content === undefined)
58
+ return "";
59
+ return content
60
+ .filter((block) => block.type === "text" && typeof block.text === "string")
61
+ .map((block) => block.text)
62
+ .join("\n\n");
63
+ }
64
+ /** The message's non-text/non-thinking parts (uploads), as path markers. */
65
+ function attachmentMarkers(message) {
66
+ const content = message.content;
67
+ if (typeof content === "string" || content === undefined)
68
+ return [];
69
+ return content
70
+ .filter((block) => block.type !== "text" && block.type !== "thinking")
71
+ .map((block) => `[${block.type ?? "part"}: ${block.path ?? "?"}]`);
72
+ }
73
+ /** A turn's fallback body (an `other` kind's text), if any. */
74
+ function entryText(entry) {
75
+ return (entry.model ?? [])
76
+ .map((message) => messageText(message))
77
+ .filter((part) => part !== "")
78
+ .join(" ");
79
+ }
80
+ /** A user entry's model-facing text (message texts + upload markers) — the
81
+ * plain-turn body, and the EXPANDED text an expanded turn's chip reveals. */
82
+ function userModelText(entry) {
83
+ const parts = (entry.model ?? []).flatMap((message) => [
84
+ messageText(message),
85
+ ...attachmentMarkers(message),
86
+ ]);
87
+ return parts.filter((part) => part !== "").join("\n");
88
+ }
89
+ /** The markdown theme rides the TuiTheme's token colours. */
90
+ function markdownTheme(theme) {
91
+ return {
92
+ heading: (text) => theme.bold(theme.fg("accent")(text)),
93
+ link: theme.fg("accent"),
94
+ linkUrl: theme.dim,
95
+ code: theme.fg("code"),
96
+ codeBlock: theme.fg("code"),
97
+ codeBlockBorder: theme.fg("border"),
98
+ quote: theme.italic,
99
+ quoteBorder: theme.fg("border"),
100
+ hr: theme.fg("border"),
101
+ listBullet: theme.fg("accent"),
102
+ bold: theme.bold,
103
+ italic: theme.italic,
104
+ strikethrough: (text) => text,
105
+ underline: (text) => text,
106
+ };
107
+ }
108
+ /** Pad/centre the compaction divider to the render width. */
109
+ function compactionLine(theme) {
110
+ return new (class {
111
+ render(width) {
112
+ const label = "── context compacted ──";
113
+ const pad = Math.max(0, Math.floor((width - label.length) / 2));
114
+ return [theme.dim(`${" ".repeat(pad)}${label}`)];
115
+ }
116
+ invalidate() { }
117
+ })();
118
+ }
119
+ function noticeComponent(notice) {
120
+ return new Text(`[${notice.kind}] ${notice.text}`, 0, 0);
121
+ }
122
+ /** The notice backlog cap (fix wave A5 — most recent win). */
123
+ const MAX_NOTICES = 10;
124
+ /**
125
+ * A one-line render slot whose text is painted by the owner each frame
126
+ * (pi-tui's `Text` drops whitespace-only text and caches by content —
127
+ * neither fits a line whose text changes under a `MouseRegion`).
128
+ */
129
+ class RenderSlot {
130
+ paint = () => "";
131
+ render(width) {
132
+ return [this.paint(width)];
133
+ }
134
+ invalidate() { }
135
+ }
136
+ /** The folded note row's summary (spec §8.3): collapsed to this line;
137
+ * opening the turn's fold reveals the note text beneath it. */
138
+ export const NOTE_SUMMARY = "UI changes sent to the agent";
139
+ /** The blocks entry's title from its payload (the answer chip's label). */
140
+ function blocksTitleOf(data) {
141
+ if (typeof data !== "object" || data === null)
142
+ return undefined;
143
+ const title = data.title;
144
+ return typeof title === "string" && title !== "" ? title : undefined;
145
+ }
146
+ /** The answer entry's block entry id (spec §8.4's `data.entryId`). */
147
+ function answerEntryIdOf(data) {
148
+ if (typeof data !== "object" || data === null)
149
+ return undefined;
150
+ const entryId = data.entryId;
151
+ return typeof entryId === "string" && entryId !== "" ? entryId : undefined;
152
+ }
153
+ /** The blank separator between turns. */
154
+ const TURN_GAP = 1;
155
+ export class ConversationPane {
156
+ #container = new Container();
157
+ #theme;
158
+ #markdownTheme;
159
+ /** The chat-block attach seam (U5 Task 4; absent = placeholder). */
160
+ #attachChatTransport;
161
+ #notices = [];
162
+ /** The notices' mounted components, aligned with `#notices`. */
163
+ #noticeComponents = [];
164
+ #turns = [];
165
+ /** The open view's conversation id (the chat-block attach's address). */
166
+ #conversationId = 0;
167
+ /** The mounted chat-block surface states, keyed by entry id
168
+ * (`<conversationId>:<entryId>`). Every rebuild REUSES the state
169
+ * (one `load`, one hub subscription per block); states are disposed
170
+ * on conversation swap and when the block's event leaves the view
171
+ * (a fork rewound the transcript). Without this, every stream frame
172
+ * remounted a fresh state and subscription and never disposed the
173
+ * old one — N frames = N stale subscriptions fanning out refetches. */
174
+ #surfaceStates = new Map();
175
+ /** The surface keys `#mountTurn` mounted during the in-flight
176
+ * `#rebuild` (undefined outside a rebuild) — the prune in `#rebuild`
177
+ * disposes every cached state NOT in this set. */
178
+ #mountKeysSeen;
179
+ #focus;
180
+ /** Fold state by turn index (assistant turns only fold). */
181
+ #open = new Set();
182
+ /** Line spans from the last render, aligned with `#turns`. */
183
+ #ranges = [];
184
+ /** The mounted turn records, aligned with `#turns`. */
185
+ #mounted = [];
186
+ constructor(options) {
187
+ this.#theme = options?.theme ?? plainTuiTheme();
188
+ this.#markdownTheme = markdownTheme(this.#theme);
189
+ this.#attachChatTransport = options?.attachChatTransport;
190
+ }
191
+ /** The current view's turns (B1 grouping), in render order. */
192
+ turns() {
193
+ return this.#turns;
194
+ }
195
+ /** The focused turn index; `undefined` = none (follow the end). */
196
+ get focusIndex() {
197
+ return this.#focus;
198
+ }
199
+ /** Focus a turn (or clear with `undefined`); repaints the marker. */
200
+ setFocus(index) {
201
+ if (index !== undefined && (index < 0 || index >= this.#turns.length)) {
202
+ return;
203
+ }
204
+ if (index === this.#focus)
205
+ return;
206
+ this.#focus = index;
207
+ this.#container.invalidate();
208
+ }
209
+ /** Open/close an assistant turn's activity fold — or an expanded user
210
+ * turn's chip disclosure (Task 2c: the full admitted text hides behind
211
+ * the `expanded from /name` line, same fold state, same seam). */
212
+ toggleFold(index) {
213
+ const turn = this.#turns[index];
214
+ if (turn === undefined)
215
+ return;
216
+ const foldable = turn.kind === "assistant" ||
217
+ (turn.kind === "user" && expandedUserEntry(turn.entry) !== undefined);
218
+ if (!foldable)
219
+ return;
220
+ if (this.#open.has(index)) {
221
+ this.#open.delete(index);
222
+ }
223
+ else {
224
+ this.#open.add(index);
225
+ }
226
+ this.#rebuild();
227
+ }
228
+ /**
229
+ * The turn's line offsets from the last render (for
230
+ * `ScrollView.scrollTo`): `start` is the turn's first line, `end` the
231
+ * first line after it. Undefined before a render or out of range.
232
+ */
233
+ lineRangeOf(index) {
234
+ return this.#ranges[index];
235
+ }
236
+ /** The turn's markdown source (the copy payload). */
237
+ textOf(index) {
238
+ const turn = this.#turns[index];
239
+ if (turn === undefined)
240
+ return "";
241
+ switch (turn.kind) {
242
+ case "user": {
243
+ // An expanded turn copies the TYPED command (what the user
244
+ // wrote — re-submitting it re-expands server-side).
245
+ const expanded = expandedUserEntry(turn.entry);
246
+ if (expanded !== undefined)
247
+ return expanded.typed;
248
+ return userModelText(turn.entry);
249
+ }
250
+ case "assistant":
251
+ return turn.text;
252
+ case "other":
253
+ return entryText(turn.entry);
254
+ }
255
+ }
256
+ /** The turn's wire identity: the user entry id / assistant lastEntryId. */
257
+ entryIdOf(index) {
258
+ const turn = this.#turns[index];
259
+ if (turn === undefined)
260
+ return -1;
261
+ switch (turn.kind) {
262
+ case "user":
263
+ return turn.entry.id;
264
+ case "assistant":
265
+ return turn.lastEntryId;
266
+ case "other":
267
+ return turn.entry.id;
268
+ }
269
+ }
270
+ /**
271
+ * Replace the pane with the projection of `wire` (the SSE rule: a full
272
+ * revision replaces; a reconnect's snapshot subsumes missed frames).
273
+ * Composer notices are not stream state: they re-render after the
274
+ * turns.
275
+ */
276
+ showView(wire) {
277
+ const conversationId = wire.conversation.id;
278
+ if (conversationId !== this.#conversationId) {
279
+ // Conversation swap: dispose every surface state (unsubscribe +
280
+ // cancel timers) — a stale state would keep refetching the OLD
281
+ // conversation's block on every hub event.
282
+ for (const state of this.#surfaceStates.values())
283
+ state.dispose();
284
+ this.#surfaceStates.clear();
285
+ }
286
+ this.#conversationId = conversationId;
287
+ this.#turns = groupTurns(wire.entries);
288
+ if (this.#focus !== undefined && this.#focus >= this.#turns.length) {
289
+ this.#focus = undefined;
290
+ }
291
+ this.#rebuild();
292
+ }
293
+ /** Dispose every mounted chat-block surface state (the shell calls
294
+ * this on teardown — the pane never leaves a hub subscription or an
295
+ * action timer behind). */
296
+ dispose() {
297
+ for (const state of this.#surfaceStates.values())
298
+ state.dispose();
299
+ this.#surfaceStates.clear();
300
+ }
301
+ /** The cached surface state for a blocks entry id (test/debug seam —
302
+ * the turn components dispatch through their own closures). */
303
+ surfaceStateFor(entryId) {
304
+ return this.#surfaceStates.get(`${this.#conversationId}:${entryId}`);
305
+ }
306
+ /** Append an `[error]` line (a failed composer action's surface). */
307
+ showError(text) {
308
+ this.#appendNotice({ kind: "error", text });
309
+ }
310
+ /** Append a `[notice]` line (an outcome, e.g. an abort that settled). */
311
+ showNotice(text) {
312
+ this.#appendNotice({ kind: "notice", text });
313
+ }
314
+ /**
315
+ * Discard all composer-surfaced lines (fix wave F4): the shell calls
316
+ * this on every conversation swap so a stale error never renders in
317
+ * the new conversation.
318
+ */
319
+ clearNotices() {
320
+ if (this.#notices.length === 0)
321
+ return;
322
+ this.#notices.length = 0;
323
+ for (const component of this.#noticeComponents.splice(0)) {
324
+ this.#container.removeChild(component);
325
+ }
326
+ this.#container.invalidate();
327
+ }
328
+ #appendNotice(notice) {
329
+ this.#notices.push(notice);
330
+ const component = noticeComponent(notice);
331
+ this.#noticeComponents.push(component);
332
+ this.#container.addChild(component);
333
+ while (this.#notices.length > MAX_NOTICES) {
334
+ this.#notices.shift();
335
+ const oldest = this.#noticeComponents.shift();
336
+ if (oldest === undefined)
337
+ break;
338
+ this.#container.removeChild(oldest);
339
+ }
340
+ this.#container.invalidate();
341
+ }
342
+ /** Rebuild the container from the current turns + folds + notices. */
343
+ #rebuild() {
344
+ this.#container.clear();
345
+ this.#noticeComponents.length = 0;
346
+ this.#mountKeysSeen = new Set();
347
+ this.#mounted = this.#turns.map((turn, index) => this.#mountTurn(turn, index));
348
+ // Prune states whose block left the view (a fork rewound the
349
+ // transcript): an orphaned state would keep its live hub
350
+ // subscription + action timers forever. `#mountTurn` records the
351
+ // keys it mounted this pass in `#mountKeysSeen`.
352
+ const seen = this.#mountKeysSeen;
353
+ if (seen !== undefined) {
354
+ for (const [key, state] of this.#surfaceStates) {
355
+ if (!seen.has(key)) {
356
+ state.dispose();
357
+ this.#surfaceStates.delete(key);
358
+ }
359
+ }
360
+ this.#mountKeysSeen = undefined;
361
+ }
362
+ for (const mounted of this.#mounted) {
363
+ for (const component of mounted.components) {
364
+ this.#container.addChild(component);
365
+ }
366
+ this.#container.addChild(new RenderSlot()); // turn gap
367
+ }
368
+ for (const notice of this.#notices) {
369
+ const component = noticeComponent(notice);
370
+ this.#noticeComponents.push(component);
371
+ this.#container.addChild(component);
372
+ }
373
+ this.#container.invalidate();
374
+ }
375
+ /** Project one turn into its components (mouse regions included). */
376
+ #mountTurn(turn, index) {
377
+ const components = [];
378
+ const focusThis = () => {
379
+ this.setFocus(index);
380
+ return { handled: true };
381
+ };
382
+ const body = (component) => new MouseRegion(component, (event) => event.type === "click" && event.button === "left"
383
+ ? focusThis()
384
+ : undefined);
385
+ switch (turn.kind) {
386
+ case "user": {
387
+ const expanded = expandedUserEntry(turn.entry);
388
+ if (expanded !== undefined) {
389
+ // The expanded turn (Task 2c): the TYPED command is the body
390
+ // (the accent bar still marks it a user turn); a dim chip line
391
+ // folds the full admitted text — closed by default, or
392
+ // `/skill:greet hej` would dump the whole skill body.
393
+ components.push(body(new Markdown(expanded.typed, 0, 0, this.#markdownTheme)));
394
+ const slot = new RenderSlot();
395
+ slot.paint = () => this.#theme.dim(`${this.#open.has(index) ? "▾" : "▸"} expanded from /${expanded.command ?? "…"}`);
396
+ components.push(new MouseRegion(slot, (event) => {
397
+ if (event.type === "click" && event.button === "left") {
398
+ this.toggleFold(index);
399
+ return { handled: true };
400
+ }
401
+ return undefined;
402
+ }));
403
+ if (this.#open.has(index)) {
404
+ for (const line of userModelText(turn.entry).split("\n")) {
405
+ components.push(new Text(this.#theme.dim(line), 0, 0));
406
+ }
407
+ }
408
+ break;
409
+ }
410
+ const text = this.textOf(index);
411
+ if (text !== "") {
412
+ components.push(body(new Markdown(text, 0, 0, this.#markdownTheme)));
413
+ }
414
+ // U5 Task 4 (spec §8.4): a block answer is a user turn whose
415
+ // chip names the surface it answered (`from <title>` — the
416
+ // `sigrun.blocks` entry's title, else its entry id).
417
+ if (turn.entry.kind === "sigrun.blocks.answer") {
418
+ const answerEntryId = answerEntryIdOf(turn.entry.data);
419
+ const title = this.#turns
420
+ .map((candidate) => candidate.kind === "assistant"
421
+ ? candidate.entries.find((entry) => entry.kind === "sigrun.blocks" &&
422
+ String(entry.id) === answerEntryId)
423
+ : undefined)
424
+ .map((entry) => entry === undefined ? undefined : blocksTitleOf(entry.data))
425
+ .find((candidate) => candidate !== undefined);
426
+ components.push(new Text(this.#theme.dim(`from ${title ?? `entry ${answerEntryId ?? "?"}`}`), 0, 0));
427
+ }
428
+ break;
429
+ }
430
+ case "assistant": {
431
+ if (turn.text.trim() !== "") {
432
+ components.push(body(new Markdown(turn.text, 0, 0, this.#markdownTheme)));
433
+ }
434
+ // U5 Task 4: the chat-block surfaces render INLINE, OUTSIDE
435
+ // the folded activity line (the surface is the turn's visible
436
+ // artifact, never hidden behind the fold). Each mounts a
437
+ // SurfaceView over the attach seam's transport (the hub's
438
+ // onChanged refreshes it when another viewer edits the block).
439
+ for (const event of turn.timeline) {
440
+ if (event.kind !== "blocks")
441
+ continue;
442
+ const attach = this.#attachChatTransport;
443
+ const key = `${this.#conversationId}:${event.entryId}`;
444
+ // Reuse the cached state across rebuilds; create + load once.
445
+ let state = this.#surfaceStates.get(key);
446
+ if (state !== undefined)
447
+ this.#mountKeysSeen?.add(key);
448
+ if (state === undefined && attach !== undefined) {
449
+ const fresh = new SurfaceState(attach(this.#conversationId, String(event.entryId)));
450
+ void fresh.load();
451
+ fresh.onChange(() => {
452
+ // Surface rejections/toasts as pane notices (the chat
453
+ // pane owns no toast surface of its own).
454
+ for (const notice of fresh.consumeNotices()) {
455
+ this.#appendNotice({ kind: "error", text: notice });
456
+ }
457
+ this.#container.invalidate();
458
+ });
459
+ this.#surfaceStates.set(key, fresh);
460
+ this.#mountKeysSeen?.add(key);
461
+ state = fresh;
462
+ }
463
+ if (attach === undefined) {
464
+ components.push(new Text(this.#theme.dim(`[blocks entry ${event.entryId} — no chat transport]`), 0, 0));
465
+ continue;
466
+ }
467
+ // Narrowed above (state === undefined implies attach was too).
468
+ components.push(new SurfaceView(state, {
469
+ theme: this.#theme,
470
+ unicode: true,
471
+ }));
472
+ }
473
+ const notes = turn.timeline.filter((event) => event.kind === "note");
474
+ const label = activityLabel(turn);
475
+ const summaryParts = [
476
+ ...(label === "" ? [] : [label]),
477
+ ...(notes.length > 0
478
+ ? [
479
+ notes.length === 1
480
+ ? NOTE_SUMMARY
481
+ : `${notes.length}× ${NOTE_SUMMARY}`,
482
+ ]
483
+ : []),
484
+ ];
485
+ if (summaryParts.length > 0) {
486
+ // The slot renders one line; the fold marker + label are
487
+ // painted per frame (the fold state changes after mount, and
488
+ // a Text's content cache would pin the stale marker).
489
+ const slot = new RenderSlot();
490
+ slot.paint = () => this.#theme.dim(`${this.#open.has(index) ? "▾" : "▸"} ${summaryParts.join(" · ")}`);
491
+ components.push(new MouseRegion(slot, (event) => {
492
+ if (event.type === "click" && event.button === "left") {
493
+ this.toggleFold(index);
494
+ return { handled: true };
495
+ }
496
+ return undefined;
497
+ }));
498
+ if (this.#open.has(index)) {
499
+ for (const thinking of turn.thinking) {
500
+ if (thinking.trim() === "")
501
+ continue;
502
+ components.push(new Text(this.#theme.dim(this.#theme.italic(thinking)), 0, 0));
503
+ }
504
+ for (const tool of turn.tools) {
505
+ const glyph = tool.isError
506
+ ? "✗"
507
+ : tool.resultEntryId === undefined
508
+ ? "·"
509
+ : "✓";
510
+ const preview = tool.preview === "" ? "" : ` ${tool.preview}`;
511
+ const row = `${glyph} ${tool.name}${preview}`;
512
+ components.push(new Text(tool.isError ? this.#theme.fg("muted")(row) : row, 0, 0));
513
+ }
514
+ // The note texts ride the SAME fold (the note row's
515
+ // disclosure): opening the fold reveals the notes
516
+ // the turn admitted, in timeline order.
517
+ for (const event of turn.timeline) {
518
+ if (event.kind !== "note")
519
+ continue;
520
+ const entry = turn.entries.find((candidate) => candidate.id === event.entryId);
521
+ const text = entry === undefined ? "" : entryText(entry);
522
+ for (const line of text.split("\n")) {
523
+ if (line.trim() === "")
524
+ continue;
525
+ components.push(new Text(this.#theme.dim(line), 0, 0));
526
+ }
527
+ }
528
+ }
529
+ }
530
+ break;
531
+ }
532
+ case "other": {
533
+ if (turn.entry.kind === "pi.compaction") {
534
+ components.push(compactionLine(this.#theme));
535
+ break;
536
+ }
537
+ const text = entryText(turn.entry);
538
+ if (turn.entry.kind === "pi.system") {
539
+ if (text !== "")
540
+ components.push(new Text(text, 0, 0));
541
+ }
542
+ else {
543
+ // Fail visible (the webui rule): name the kind plus any text.
544
+ const suffix = text === "" ? "" : ` — ${text}`;
545
+ components.push(new Text(`unsupported entry kind: ${turn.entry.kind}${suffix}`, 0, 0));
546
+ }
547
+ break;
548
+ }
549
+ }
550
+ return { start: 0, end: 0, components, heights: [] };
551
+ }
552
+ render(width) {
553
+ const lines = this.#container.render(width);
554
+ this.#recordRanges(lines, width);
555
+ return this.#decorate(lines, width);
556
+ }
557
+ /**
558
+ * Record each turn's line span from the render: every turn is followed
559
+ * by one blank gap line (see `#rebuild`), so heights + TURN_GAP walk
560
+ * the offsets deterministically.
561
+ */
562
+ #recordRanges(_lines, width) {
563
+ const ranges = [];
564
+ let offset = 0;
565
+ for (const mounted of this.#mounted) {
566
+ let height = 0;
567
+ mounted.heights.length = 0;
568
+ for (const component of mounted.components) {
569
+ const componentHeight = component.render(width).length;
570
+ mounted.heights.push(componentHeight);
571
+ height += componentHeight;
572
+ }
573
+ mounted.start = offset;
574
+ mounted.end = offset + height;
575
+ ranges.push({ start: mounted.start, end: mounted.end });
576
+ offset += height + TURN_GAP;
577
+ }
578
+ this.#ranges = ranges;
579
+ }
580
+ /**
581
+ * Apply the focus + user-bar chrome: the focused turn's FIRST line
582
+ * gets the `surface2` background and the `›` gutter marker; every line
583
+ * of a user turn carries the accent `▎` bar. The two are independent —
584
+ * a focused user turn's first line composes `› ▎ …` on surface2.
585
+ */
586
+ #decorate(lines, width) {
587
+ const out = [...lines];
588
+ for (const [index, mounted] of this.#mounted.entries()) {
589
+ const turn = this.#turns[index];
590
+ if (turn === undefined)
591
+ continue;
592
+ const focused = index === this.#focus;
593
+ const bar = turn.kind === "user"
594
+ ? `${this.#theme.fg("accent")("▎")} `
595
+ : "";
596
+ for (let line = mounted.start; line < mounted.end; line++) {
597
+ const original = out[line] ?? "";
598
+ if (line === mounted.start && focused) {
599
+ const marked = `› ${bar}${original}`;
600
+ out[line] = this.#theme.bg("surface2")(marked + " ".repeat(Math.max(0, width - marked.length)));
601
+ }
602
+ else if (turn.kind === "user") {
603
+ out[line] = `${bar}${original}`;
604
+ }
605
+ else if (focused) {
606
+ out[line] = ` ${original}`;
607
+ }
608
+ }
609
+ }
610
+ return out;
611
+ }
612
+ invalidate() {
613
+ this.#container.invalidate();
614
+ }
615
+ }
@@ -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;