@sagmans/dsh-tui 0.4.0 → 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 (200) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +270 -68
  3. package/lib/agent/present.js +1 -1
  4. package/lib/agent/present.js.map +1 -1
  5. package/lib/agent/prompt-history.d.ts.map +1 -1
  6. package/lib/agent/prompt-history.js +5 -3
  7. package/lib/agent/prompt-history.js.map +1 -1
  8. package/lib/agent/status.d.ts +1 -1
  9. package/lib/cards.d.ts +83 -13
  10. package/lib/cards.d.ts.map +1 -1
  11. package/lib/cards.js +96 -28
  12. package/lib/cards.js.map +1 -1
  13. package/lib/export.d.ts +3 -3
  14. package/lib/export.d.ts.map +1 -1
  15. package/lib/export.js +23 -13
  16. package/lib/export.js.map +1 -1
  17. package/lib/fold-cursor.d.ts +12 -5
  18. package/lib/fold-cursor.d.ts.map +1 -1
  19. package/lib/fold-cursor.js +16 -5
  20. package/lib/fold-cursor.js.map +1 -1
  21. package/lib/gates.d.ts.map +1 -1
  22. package/lib/gates.js +42 -9
  23. package/lib/gates.js.map +1 -1
  24. package/lib/index.d.ts.map +1 -1
  25. package/lib/index.js +459 -72
  26. package/lib/index.js.map +1 -1
  27. package/lib/injection.d.ts +9 -0
  28. package/lib/injection.d.ts.map +1 -0
  29. package/lib/injection.js +128 -0
  30. package/lib/injection.js.map +1 -0
  31. package/lib/input/actions.d.ts +19 -2
  32. package/lib/input/actions.d.ts.map +1 -1
  33. package/lib/input/actions.js +57 -11
  34. package/lib/input/actions.js.map +1 -1
  35. package/lib/input/cancel.d.ts +55 -0
  36. package/lib/input/cancel.d.ts.map +1 -0
  37. package/lib/input/cancel.js +47 -0
  38. package/lib/input/cancel.js.map +1 -0
  39. package/lib/input/completion.d.ts +5 -2
  40. package/lib/input/completion.d.ts.map +1 -1
  41. package/lib/input/completion.js +79 -4
  42. package/lib/input/completion.js.map +1 -1
  43. package/lib/input/file-search.d.ts +105 -0
  44. package/lib/input/file-search.d.ts.map +1 -0
  45. package/lib/input/file-search.js +665 -0
  46. package/lib/input/file-search.js.map +1 -0
  47. package/lib/input/fuzzy.d.ts +42 -0
  48. package/lib/input/fuzzy.d.ts.map +1 -0
  49. package/lib/input/fuzzy.js +289 -0
  50. package/lib/input/fuzzy.js.map +1 -0
  51. package/lib/input/keymap.d.ts +3 -1
  52. package/lib/input/keymap.d.ts.map +1 -1
  53. package/lib/input/keymap.js +14 -3
  54. package/lib/input/keymap.js.map +1 -1
  55. package/lib/input/match.d.ts +12 -4
  56. package/lib/input/match.d.ts.map +1 -1
  57. package/lib/input/match.js +50 -51
  58. package/lib/input/match.js.map +1 -1
  59. package/lib/input/submission.d.ts +10 -0
  60. package/lib/input/submission.d.ts.map +1 -1
  61. package/lib/input/submission.js +7 -6
  62. package/lib/input/submission.js.map +1 -1
  63. package/lib/keys-command.d.ts +19 -3
  64. package/lib/keys-command.d.ts.map +1 -1
  65. package/lib/keys-command.js +34 -31
  66. package/lib/keys-command.js.map +1 -1
  67. package/lib/settings-notice.d.ts +5 -0
  68. package/lib/settings-notice.d.ts.map +1 -1
  69. package/lib/settings-notice.js +9 -7
  70. package/lib/settings-notice.js.map +1 -1
  71. package/lib/stash/paths.d.ts +8 -8
  72. package/lib/stash/paths.d.ts.map +1 -1
  73. package/lib/stash/paths.js +19 -18
  74. package/lib/stash/paths.js.map +1 -1
  75. package/lib/stash/schema.d.ts +11 -12
  76. package/lib/stash/schema.d.ts.map +1 -1
  77. package/lib/stash/schema.js +10 -29
  78. package/lib/stash/schema.js.map +1 -1
  79. package/lib/stash/store.js +10 -10
  80. package/lib/stash/store.js.map +1 -1
  81. package/lib/stash.d.ts +37 -6
  82. package/lib/stash.d.ts.map +1 -1
  83. package/lib/stash.js +93 -36
  84. package/lib/stash.js.map +1 -1
  85. package/lib/terminal/external-editor.d.ts +93 -0
  86. package/lib/terminal/external-editor.d.ts.map +1 -0
  87. package/lib/terminal/external-editor.js +263 -0
  88. package/lib/terminal/external-editor.js.map +1 -0
  89. package/lib/terminal/host-writes.d.ts +39 -0
  90. package/lib/terminal/host-writes.d.ts.map +1 -0
  91. package/lib/terminal/host-writes.js +136 -0
  92. package/lib/terminal/host-writes.js.map +1 -0
  93. package/lib/terminal/signals.d.ts +25 -0
  94. package/lib/terminal/signals.d.ts.map +1 -0
  95. package/lib/terminal/signals.js +39 -0
  96. package/lib/terminal/signals.js.map +1 -0
  97. package/lib/terminal/warning-screen.d.ts +19 -1
  98. package/lib/terminal/warning-screen.d.ts.map +1 -1
  99. package/lib/terminal/warning-screen.js +37 -1
  100. package/lib/terminal/warning-screen.js.map +1 -1
  101. package/lib/terminal-text.d.ts +72 -0
  102. package/lib/terminal-text.d.ts.map +1 -0
  103. package/lib/terminal-text.js +603 -0
  104. package/lib/terminal-text.js.map +1 -0
  105. package/lib/text.d.ts +45 -6
  106. package/lib/text.d.ts.map +1 -1
  107. package/lib/text.js +106 -46
  108. package/lib/text.js.map +1 -1
  109. package/lib/theme-command.d.ts +7 -4
  110. package/lib/theme-command.d.ts.map +1 -1
  111. package/lib/theme-command.js +32 -12
  112. package/lib/theme-command.js.map +1 -1
  113. package/lib/theme-files.d.ts +110 -0
  114. package/lib/theme-files.d.ts.map +1 -0
  115. package/lib/theme-files.js +352 -0
  116. package/lib/theme-files.js.map +1 -0
  117. package/lib/theme-schema.d.ts +121 -0
  118. package/lib/theme-schema.d.ts.map +1 -0
  119. package/lib/theme-schema.js +87 -0
  120. package/lib/theme-schema.js.map +1 -0
  121. package/lib/theme-settings.d.ts +43 -10
  122. package/lib/theme-settings.d.ts.map +1 -1
  123. package/lib/theme-settings.js +103 -43
  124. package/lib/theme-settings.js.map +1 -1
  125. package/lib/theme-tokens.d.ts +39 -7
  126. package/lib/theme-tokens.d.ts.map +1 -1
  127. package/lib/theme-tokens.js +96 -13
  128. package/lib/theme-tokens.js.map +1 -1
  129. package/lib/theme.d.ts +24 -2
  130. package/lib/theme.d.ts.map +1 -1
  131. package/lib/theme.js +49 -6
  132. package/lib/theme.js.map +1 -1
  133. package/lib/tool-display.d.ts +57 -0
  134. package/lib/tool-display.d.ts.map +1 -0
  135. package/lib/tool-display.js +51 -0
  136. package/lib/tool-display.js.map +1 -0
  137. package/lib/transcript.d.ts +28 -0
  138. package/lib/transcript.d.ts.map +1 -1
  139. package/lib/transcript.js +126 -37
  140. package/lib/transcript.js.map +1 -1
  141. package/lib/ui/diff.d.ts +49 -0
  142. package/lib/ui/diff.d.ts.map +1 -0
  143. package/lib/ui/diff.js +208 -0
  144. package/lib/ui/diff.js.map +1 -0
  145. package/lib/ui/dock.d.ts.map +1 -1
  146. package/lib/ui/dock.js +9 -6
  147. package/lib/ui/dock.js.map +1 -1
  148. package/lib/ui/editor.d.ts.map +1 -1
  149. package/lib/ui/editor.js +4 -2
  150. package/lib/ui/editor.js.map +1 -1
  151. package/lib/ui/frame.d.ts +16 -5
  152. package/lib/ui/frame.d.ts.map +1 -1
  153. package/lib/ui/frame.js +32 -13
  154. package/lib/ui/frame.js.map +1 -1
  155. package/lib/ui/keymap-picker.d.ts +16 -0
  156. package/lib/ui/keymap-picker.d.ts.map +1 -0
  157. package/lib/ui/keymap-picker.js +41 -0
  158. package/lib/ui/keymap-picker.js.map +1 -0
  159. package/lib/ui/layout.d.ts +33 -0
  160. package/lib/ui/layout.d.ts.map +1 -0
  161. package/lib/ui/layout.js +33 -0
  162. package/lib/ui/layout.js.map +1 -0
  163. package/lib/ui/markdown.d.ts +25 -3
  164. package/lib/ui/markdown.d.ts.map +1 -1
  165. package/lib/ui/markdown.js +12 -8
  166. package/lib/ui/markdown.js.map +1 -1
  167. package/lib/ui/picker-card.d.ts +42 -0
  168. package/lib/ui/picker-card.d.ts.map +1 -0
  169. package/lib/ui/picker-card.js +148 -0
  170. package/lib/ui/picker-card.js.map +1 -0
  171. package/lib/ui/picker.d.ts +40 -2
  172. package/lib/ui/picker.d.ts.map +1 -1
  173. package/lib/ui/picker.js +50 -6
  174. package/lib/ui/picker.js.map +1 -1
  175. package/lib/ui/prompt.d.ts +10 -2
  176. package/lib/ui/prompt.d.ts.map +1 -1
  177. package/lib/ui/prompt.js +21 -2
  178. package/lib/ui/prompt.js.map +1 -1
  179. package/lib/ui/rows.d.ts +8 -6
  180. package/lib/ui/rows.d.ts.map +1 -1
  181. package/lib/ui/rows.js +10 -18
  182. package/lib/ui/rows.js.map +1 -1
  183. package/lib/ui/stash-picker.d.ts +2 -2
  184. package/lib/ui/stash-picker.d.ts.map +1 -1
  185. package/lib/ui/stash-picker.js +4 -4
  186. package/lib/ui/stash-picker.js.map +1 -1
  187. package/lib/ui/status.d.ts.map +1 -1
  188. package/lib/ui/status.js +18 -16
  189. package/lib/ui/status.js.map +1 -1
  190. package/lib/ui/theme-picker.d.ts +18 -0
  191. package/lib/ui/theme-picker.d.ts.map +1 -0
  192. package/lib/ui/theme-picker.js +68 -0
  193. package/lib/ui/theme-picker.js.map +1 -0
  194. package/lib/ui/view.d.ts +146 -22
  195. package/lib/ui/view.d.ts.map +1 -1
  196. package/lib/ui/view.js +461 -154
  197. package/lib/ui/view.js.map +1 -1
  198. package/package.json +3 -1
  199. package/themes/deepseek-blue.yaml +183 -0
  200. package/themes/violet-orbit.yaml +178 -0
package/lib/ui/view.js CHANGED
@@ -1,15 +1,26 @@
1
- import { visibleWidth, wrapTextWithAnsi } from '@earendil-works/pi-tui';
2
- import { cardDetailRows, shellFoldHint, shellRetentionHint } from "../cards.js";
1
+ import { visibleWidth, wrapTextWithAnsi, } from '@earendil-works/pi-tui';
2
+ import { cardDetailRows, clip, oneLine, shellFoldHint, shellRetentionHint } from "../cards.js";
3
3
  import { defaultKeymap, hintKeys } from "../input/actions.js";
4
4
  import { CUSTOM_ROW_NUMBER } from "../gates.js";
5
- import { displayText } from "../text.js";
5
+ import { DEFAULT_TOOL_DISPLAY } from "../tool-display.js";
6
6
  import { CARD_ROW_TOKEN } from "../theme-tokens.js";
7
- import { canFrame, frameText } from "./frame.js";
7
+ import { codeBlockLines } from "./diff.js";
8
+ import { canFrame, frameLines, FRAME_COLUMNS, textWidth } from "./frame.js";
9
+ import { ANSWER_FACE } from "./markdown.js";
10
+ import { pickerCardLines } from "./picker-card.js";
8
11
  import { RowCache } from "./rows.js";
9
12
  const DETAIL_INDENT = ' ';
10
13
  const OPTION_INDENT = ' ';
11
14
  /** A nested call is a signpost under its card, so it sits one step shallower than that card's detail. */
12
15
  const SUBCALL_INDENT = ' ';
16
+ /**
17
+ * Blank columns a one-line tool row keeps at the right edge.
18
+ *
19
+ * The cut mark would otherwise sit under the terminal's own last column, where
20
+ * a border or the cursor lives; the padding is part of the look, not a
21
+ * preference, so it is not a settings key.
22
+ */
23
+ const COLLAPSED_EDGE_PADDING = 5;
13
24
  /** The mark a cursor falls back to when the reader has not set one, so no literal lives in a template. */
14
25
  const CURSOR_MARK = '❯';
15
26
  const APPROVAL_MARK = '⚠';
@@ -42,21 +53,74 @@ const STAT_TOKEN = {
42
53
  removed: 'tool.stat.removed',
43
54
  size: 'tool.stat.size',
44
55
  };
56
+ /**
57
+ * A markdown theme that keeps every element in one token.
58
+ *
59
+ * A thought is deliberately recessive, and the answer's theme would let a
60
+ * heading or a link inside it outshine the answer it produced. The structure
61
+ * still shows because markdown draws it around the text — bullets, fences,
62
+ * indents, table rules — rather than in the text's own colour. A fenced diff is
63
+ * the one block that does not recede: red and green are what the fence means
64
+ * rather than how loudly it is drawn, and a thought showing a change is the
65
+ * place the reader most needs to see which side of it moved.
66
+ */
67
+ function recessiveMarkdownTheme(style, theme) {
68
+ return {
69
+ heading: style,
70
+ link: style,
71
+ linkUrl: style,
72
+ code: style,
73
+ codeBlock: style,
74
+ codeBlockBorder: style,
75
+ quote: style,
76
+ quoteBorder: style,
77
+ hr: style,
78
+ listBullet: style,
79
+ bold: style,
80
+ italic: style,
81
+ strikethrough: style,
82
+ underline: style,
83
+ highlightCode: (code, lang) => codeBlockLines(code, lang, {
84
+ style: (token, text) => theme.style(token, text),
85
+ visible: token => theme.visible(token),
86
+ plain: style,
87
+ }),
88
+ };
89
+ }
45
90
  /**
46
91
  * The state a reader gets before opening anything, and the view's own fallback.
47
92
  *
48
- * Cards and thoughts start folded because either can be long enough to push the
49
- * answer off the screen. A program's calls are the opposite case: one clipped
50
- * line each, under a header that already names them, so folding them costs a
51
- * reader the very thing the card stands for.
93
+ * Thoughts start folded because they can be long enough to push the answer off
94
+ * the screen. Cards and a program's calls start from the reader's own `tools:`
95
+ * settings, which ship folded for cards — one clipped line each — and inline for
96
+ * a program's calls, whose one line per dispatch is what the card stands for.
52
97
  */
53
98
  export const DEFAULT_VIEW_STATE = { expandCards: false, expandReasoning: false, expandSubCalls: true };
99
+ /** Keys are prefixed because cards, thoughts, and dispatches all share one map. */
100
+ const toolClickKey = (id) => (id === '' ? undefined : `tool:${id}`);
101
+ const reasoningClickKey = (id) => (id === '' ? undefined : `reason:${id}`);
102
+ const subCallClickKey = (parentId, id) => `sub:${parentId}/${id}`;
103
+ /** The column a one-line tool row must not pass, so a cut never reaches the edge. */
104
+ const collapsedEdge = (width) => Math.max(1, width - COLLAPSED_EDGE_PADDING);
54
105
  export class TranscriptView {
55
106
  model;
56
107
  theme;
57
108
  markdown;
58
109
  options;
59
110
  rows;
111
+ /**
112
+ * Fold choices the reader made by clicking, per message.
113
+ *
114
+ * Absent means the key or the tool's own policy decides, so a settings edit
115
+ * stays in charge of messages nobody clicked. Keyed by the call or thought id
116
+ * rather than by the entry, because a result replaces the entry a click was
117
+ * made on and a thought outlives the live row that streamed it.
118
+ */
119
+ clicked = new Map();
120
+ /** The rows each clickable message drew last render, for mapping a click back to it. */
121
+ spans = [];
122
+ /** The same spans relative to their entry, so a cached entry still answers clicks. */
123
+ entrySpans = new WeakMap();
60
124
  constructor(model, theme, markdown, options = {}) {
61
125
  this.model = model;
62
126
  this.theme = theme;
@@ -67,6 +131,56 @@ export class TranscriptView {
67
131
  get viewState() {
68
132
  return this.options.state?.() ?? DEFAULT_VIEW_STATE;
69
133
  }
134
+ /** The display the reader configured for a tool, or the shipped one. */
135
+ toolDisplay(tool) {
136
+ return this.options.toolDisplay?.(tool) ?? DEFAULT_TOOL_DISPLAY;
137
+ }
138
+ /**
139
+ * Whether one tool message draws open.
140
+ *
141
+ * A click outranks the key, which outranks the tool's start state: Ctrl+O
142
+ * stays "show me everything", while one clicked message keeps the state the
143
+ * reader gave it.
144
+ */
145
+ expansionOf(entry) {
146
+ const key = toolClickKey(entry.id);
147
+ const clicked = key === undefined ? undefined : this.clicked.get(key);
148
+ return clicked ?? (this.viewState.expandCards || !this.toolDisplay(entry.card.tool).collapsed);
149
+ }
150
+ /** Whether one thought draws its body; a click decides for that thought alone. */
151
+ reasoningOpen(entry) {
152
+ const key = reasoningClickKey(entry.id);
153
+ const clicked = key === undefined ? undefined : this.clicked.get(key);
154
+ return clicked ?? this.viewState.expandReasoning;
155
+ }
156
+ /** Whether one dispatched call draws its argument in full; a click decides for that call. */
157
+ subCallOpen(parentId, id) {
158
+ return this.clicked.get(subCallClickKey(parentId, id)) ?? false;
159
+ }
160
+ /**
161
+ * Answer a click on a message by folding or unfolding that one row.
162
+ *
163
+ * The tightest span under the point wins, so a click on a dispatched call
164
+ * opens that call rather than the card around it. Only a left click is
165
+ * consumed: a press, a drag, and a wheel belong to the surface's own
166
+ * selection and scrolling, and a row that swallowed them would cost the
167
+ * reader the ability to copy the command it just drew.
168
+ */
169
+ handleMouse(event) {
170
+ if (event.type !== 'click' || event.button !== 'left')
171
+ return undefined;
172
+ let span;
173
+ for (const candidate of this.spans) {
174
+ if (event.y < candidate.start || event.y >= candidate.end)
175
+ continue;
176
+ if (span === undefined || candidate.end - candidate.start < span.end - span.start)
177
+ span = candidate;
178
+ }
179
+ if (span === undefined)
180
+ return undefined;
181
+ this.clicked.set(span.key, !span.expanded);
182
+ return { handled: true, render: true };
183
+ }
70
184
  /** The cache's own account of the work it avoided; a test reads this. */
71
185
  rowStats() {
72
186
  return this.rows.stats();
@@ -76,13 +190,20 @@ export class TranscriptView {
76
190
  // anyway; an explicit invalidate means the caller wants them rebuilt.
77
191
  this.rows.clear();
78
192
  }
79
- /** Wrap one text block under a prefix, keeping the prefix's column budget. */
80
- pushWrapped(lines, text, width, prefix, style) {
193
+ /**
194
+ * Wrap one text block under a prefix, keeping the prefix's column budget.
195
+ *
196
+ * The block is drawn before it is wrapped: a sequence a terminal would have
197
+ * acted on has to be gone before pi-tui measures the row, and a tab has to
198
+ * land on the stop the writer saw rather than one a wrapper would guess at.
199
+ * The prefix seeds that stop, because it is already on the row.
200
+ */
201
+ pushWrapped(lines, text, width, prefix, token) {
81
202
  const lead = visibleWidth(prefix);
82
203
  const indent = ' '.repeat(lead);
83
- const wrapped = wrapTextWithAnsi(displayText(text), Math.max(1, width - lead));
204
+ const wrapped = wrapTextWithAnsi(this.theme.rich(text, { token, column: lead }), Math.max(1, width - lead));
84
205
  wrapped.forEach((line, index) => {
85
- lines.push(style(this.theme.cut(`${index === 0 ? prefix : indent}${line}`, width, '')));
206
+ lines.push(this.theme.cut(`${index === 0 ? prefix : indent}${line}`, width, ''));
86
207
  });
87
208
  }
88
209
  /**
@@ -102,19 +223,67 @@ export class TranscriptView {
102
223
  lines.push(this.theme.cut(`${index === 0 ? prefix : indent}${line}`, width, ''));
103
224
  });
104
225
  }
105
- /** Render assistant text as markdown: the model writes structure, the reader reads it. */
106
- pushMarkdown(lines, text, width, live) {
107
- const rendered = this.markdown.render(displayText(text), Math.max(1, width), live);
108
- rendered.forEach(line => {
109
- // The renderer pads to its width for background styling we do not use.
110
- const trimmed = line.replace(/[ \t]+$/u, '');
226
+ /**
227
+ * The rows one markdown message draws, with the padding pi-tui adds for background styling removed.
228
+ *
229
+ * The source is drawn first so the markdown renderer never measures a sequence
230
+ * it cannot see; its own spans are applied around the result afterwards.
231
+ */
232
+ markdownLines(text, width, live, face, column = 0) {
233
+ return this.markdown
234
+ .render(this.theme.rich(text, { column }), Math.max(1, width), live, face)
235
+ .map(line => line.replace(/[ \t]+$/u, ''));
236
+ }
237
+ /**
238
+ * Render one message's text as markdown.
239
+ *
240
+ * A thought passes its own face and an indent, so the row stays visibly a
241
+ * detail of the step that produced it rather than a second answer.
242
+ */
243
+ pushMarkdown(lines, text, width, live, face = ANSWER_FACE, indent = '') {
244
+ const lead = visibleWidth(indent);
245
+ for (const line of this.markdownLines(text, Math.max(1, width - lead), live, face, lead)) {
111
246
  // A blank markdown line stays blank: it shows nothing, so it holds nothing.
112
- if (trimmed === '') {
247
+ if (line === '') {
113
248
  lines.push('');
114
- return;
249
+ continue;
115
250
  }
116
- lines.push(this.theme.cut(trimmed, width, '…'));
117
- });
251
+ lines.push(this.theme.cut(`${indent}${line}`, width, '…'));
252
+ }
253
+ }
254
+ /**
255
+ * One message closed into a frame, with its markdown laid out to what is left inside.
256
+ *
257
+ * A prompt and a reply are the two objects of an exchange, so both are drawn as
258
+ * bars rather than as one more stretch of rows; the face, the border's element,
259
+ * and whether the text is still arriving are the only differences. Markdown lays
260
+ * itself out to the frame's text width, so the frame may only place the rows:
261
+ * wrapping them again would break what it drew.
262
+ */
263
+ pushFramed(lines, text, width, live, face, borderToken) {
264
+ const framed = canFrame(width, this.theme.visible(borderToken));
265
+ const inside = framed ? width - FRAME_COLUMNS : width;
266
+ const body = this.markdownLines(text, textWidth(inside), live, face);
267
+ lines.push(...frameLines(body, width, {
268
+ text: line => line,
269
+ border: rule => this.theme.style(borderToken, rule),
270
+ framed,
271
+ }));
272
+ }
273
+ /**
274
+ * The face a thought is drawn in.
275
+ *
276
+ * Its shade stays the thought body's and it asks for no drawing: a diagram in
277
+ * the middle of a thought would carry the answer's weight, and the answer's
278
+ * colours would make the thinking compete with it.
279
+ */
280
+ reasoningFace() {
281
+ const body = (text) => this.theme.style('transcript.reasoning.body', text);
282
+ return { name: 'reasoning', base: { color: body }, theme: recessiveMarkdownTheme(body, this.theme), transform: false };
283
+ }
284
+ /** The face a submitted prompt is drawn in: its structure is markdown's, its shade stays the prompt's. */
285
+ userFace() {
286
+ return { name: 'user', base: { color: text => this.theme.style('transcript.user', text) } };
118
287
  }
119
288
  /** The live map, or the shipped one for a caller that did not lend one. */
120
289
  keymap() {
@@ -123,62 +292,73 @@ export class TranscriptView {
123
292
  reasoningFoldHint() {
124
293
  return hintKeys(this.keymap(), 'surface.reasoning') || REASONING_FOLD_FALLBACK;
125
294
  }
126
- pushReasoning(lines, entry, width) {
295
+ pushReasoning(lines, entry, width, spans) {
127
296
  if (!this.theme.visible('transcript.reasoning.summary'))
128
297
  return;
298
+ const start = lines.length;
299
+ const open = this.reasoningOpen(entry);
129
300
  const glyph = this.theme.glyph('transcript.reasoning.summary');
130
301
  const lead = glyph === '' ? '' : `${glyph} `;
131
302
  // A folded row carries the key that opens it, because a count with no way to
132
303
  // reach the text reads the same as the text never having arrived. The key
133
304
  // rides the row rather than a line of its own, so naming the hidden body
134
305
  // costs no vertical space.
135
- const suffix = !this.viewState.expandReasoning && entry.body !== '' && this.theme.visible('transcript.reasoning.hint')
306
+ const suffix = !open && entry.body !== '' && this.theme.visible('transcript.reasoning.hint')
136
307
  ? ` (${this.reasoningFoldHint()})`
137
308
  : '';
138
309
  // The key is kept whole: the summary is the part that gives up room.
139
310
  const room = Math.max(1, width - visibleWidth(suffix));
140
- const summary = this.theme.style('transcript.reasoning.summary', this.theme.cut(`${lead}${displayText(entry.summary)}`, room, ''));
141
- lines.push(suffix === '' ? summary : `${summary}${this.theme.style('transcript.reasoning.hint', suffix)}`);
142
- if (!this.viewState.expandReasoning)
143
- return;
144
- if (!this.theme.visible('transcript.reasoning.body'))
145
- return;
146
- for (const line of entry.body.split('\n')) {
147
- this.pushWrapped(lines, line, width, DETAIL_INDENT, text => this.theme.style('transcript.reasoning.body', text));
311
+ const summary = this.theme.cut(this.theme.rich(`${lead}${entry.summary}`, { token: 'transcript.reasoning.summary', column: visibleWidth(lead) }), room, '');
312
+ const lined = suffix === '' ? summary : `${summary}${this.theme.style('transcript.reasoning.hint', suffix)}`;
313
+ // The key is kept whole only while the row has room for it: a row wider than
314
+ // the surface loses its tail to the terminal, and the terminal's clamp is not
315
+ // one this component can count on.
316
+ lines.push(this.theme.cut(lined, width, '…'));
317
+ if (open && this.theme.visible('transcript.reasoning.body')) {
318
+ this.pushMarkdown(lines, entry.body, width, entry.live, this.reasoningFace(), DETAIL_INDENT);
148
319
  }
320
+ const key = reasoningClickKey(entry.id);
321
+ if (key !== undefined)
322
+ spans.push({ key, start, end: lines.length, expanded: open });
149
323
  }
150
- pushCard(lines, card, width) {
151
- const expanded = this.viewState.expandCards;
152
- // A card whose kind declares its call IS a command keeps its output tail
153
- // while folded: that output is the answer the reader asked for, so it
154
- // outranks the one-line rule every other card follows. Its command is drawn
155
- // outside the fold entirely, because what ran is never a detail.
324
+ pushCard(lines, entry, width, spans) {
325
+ const card = entry.card;
326
+ const spec = this.toolDisplay(card.tool);
327
+ const expanded = this.expansionOf(entry);
328
+ const start = lines.length;
329
+ // What a folded card shows of its rows is the reader's choice, so a shell
330
+ // run no longer spends twenty rows on screen just because its output is the
331
+ // answer: the answer is one click away, and the row it costs is one.
156
332
  const preview = expanded
157
333
  ? { expanded: true }
158
- : { expanded: false, preview: card.kind === 'terminal' ? 'shellTail' : 'title' };
334
+ : spec.output === 'tail'
335
+ ? { expanded: false, preview: 'tail', rows: spec.tail }
336
+ : { expanded: false, preview: 'title' };
159
337
  const { lines: detail, hidden } = cardDetailRows(card, preview);
160
338
  const titleToken = card.failed ? 'tool.failed.title' : 'tool.title';
161
339
  const glyphToken = card.failed ? 'tool.failed.glyph' : 'tool.glyph';
162
- const { lead, body } = this.renderHead(card, titleToken, glyphToken);
163
- // A header that folds keeps the argument and its stats; a terminal command
164
- // can be longer than the screen, so it wraps under its own indent.
165
- if (body !== '')
166
- this.pushStyledWrapped(lines, body, width, lead);
340
+ if (expanded) {
341
+ const { lead, body } = this.renderHead(card, titleToken, glyphToken);
342
+ // A header that folds keeps the argument and its stats; a terminal command
343
+ // can be longer than the screen, so it wraps under its own indent.
344
+ if (body !== '')
345
+ this.pushStyledWrapped(lines, body, width, lead);
346
+ }
347
+ else {
348
+ const head = this.renderCollapsedHead(card, hidden, titleToken, glyphToken, width);
349
+ if (head !== '')
350
+ lines.push(this.theme.cut(head, collapsedEdge(width), '…'));
351
+ }
352
+ // The calls a program dispatched sit under the header whether the card is
353
+ // open or folded: one line per call is what the card stands for, and hiding
354
+ // them behind the card's own fold made a program's work invisible.
167
355
  if (this.viewState.expandSubCalls)
168
- this.pushSubCalls(lines, card, width);
169
- if (card.kind === 'terminal' && card.argument !== undefined && card.argument !== '' && this.theme.visible('tool.args')) {
170
- this.pushStyledWrapped(lines, this.theme.style('tool.args', displayText(card.argument)), width, DETAIL_INDENT);
356
+ this.pushSubCalls(lines, entry, width, spans);
357
+ if (expanded && card.kind === 'terminal' && card.argument !== undefined && card.argument !== '' && this.theme.visible('tool.args')) {
358
+ this.pushStyledWrapped(lines, this.theme.rich(card.argument, { token: 'tool.args', column: visibleWidth(DETAIL_INDENT) }), width, DETAIL_INDENT);
171
359
  }
172
360
  for (const row of detail) {
173
- // The row says what it is, so the renderer never guesses from the text:
174
- // a diff line beginning with "+" is an addition because the presenter
175
- // said so, not because of its first character.
176
- const drawn = row.parts
177
- .map(part => {
178
- const token = CARD_ROW_TOKEN[card.kind]?.[part.class] ?? FALLBACK_ROW_TOKEN;
179
- return this.theme.visible(token) ? this.theme.style(token, displayText(part.text)) : '';
180
- })
181
- .join('');
361
+ const drawn = this.detailRow(row, card.kind);
182
362
  // A row whose every part is hidden draws nothing, and nothing must not
183
363
  // cost a line: the indent would read as an empty row the card does not have.
184
364
  if (drawn === '')
@@ -186,114 +366,210 @@ export class TranscriptView {
186
366
  lines.push(this.theme.cut(`${DETAIL_INDENT}${drawn}`, width, ''));
187
367
  }
188
368
  // The pill is not output, so it draws after the preview window rather than
189
- // inside it: a run bounded to its tail still reports how it ended.
190
- if (card.kind === 'terminal' && card.status !== undefined && this.theme.visible('tool.terminal.status')) {
191
- lines.push(this.theme.cut(`${DETAIL_INDENT}${this.theme.style('tool.terminal.status', displayText(card.status))}`, width, ''));
369
+ // inside it: a run bounded to its tail still reports how it ended. Folded,
370
+ // it rides the header instead, where it costs no row of its own.
371
+ if (expanded && card.kind === 'terminal' && card.status !== undefined && this.theme.visible('tool.terminal.status')) {
372
+ lines.push(this.theme.cut(`${DETAIL_INDENT}${this.theme.rich(card.status, { token: 'tool.terminal.status', column: visibleWidth(DETAIL_INDENT) })}`, width, ''));
192
373
  }
193
- if (hidden <= 0 || !this.theme.visible('tool.hint'))
194
- return;
195
374
  // A shell card's rows are kept from the end, so a hidden count always names
196
375
  // the rows *before* what is on screen and the hint has to say so; every
197
- // other card keeps its head, where a neutral count is enough. A folded shell
198
- // card is bounded by its preview window, an opened one by retention, so the
199
- // opened hint promises no more than memory kept.
200
- const hint = card.kind === 'terminal'
201
- ? preview.expanded
202
- ? shellRetentionHint(hidden)
203
- : shellFoldHint(hidden, hintKeys(this.keymap(), 'surface.toolDetail') || CARD_OPEN_FALLBACK)
204
- : preview.expanded ? `${hidden} ${CARD_HINT_RETAINED}` : undefined;
205
- if (hint === undefined)
206
- return;
207
- lines.push(this.theme.style('tool.hint', this.theme.cut(`${DETAIL_INDENT}${hint}`, width, '')));
376
+ // other card keeps its head, where a neutral count is enough. A folded card
377
+ // that shows a tail is bounded by that window, an opened one by retention,
378
+ // so the opened hint promises no more than memory kept.
379
+ if (hidden > 0 && this.theme.visible('tool.hint')) {
380
+ const hint = preview.expanded
381
+ ? card.kind === 'terminal' ? shellRetentionHint(hidden) : `${hidden} ${CARD_HINT_RETAINED}`
382
+ : preview.preview === 'tail'
383
+ ? shellFoldHint(hidden, hintKeys(this.keymap(), 'surface.toolDetail') || CARD_OPEN_FALLBACK)
384
+ : undefined;
385
+ if (hint !== undefined) {
386
+ lines.push(this.theme.style('tool.hint', this.theme.cut(`${DETAIL_INDENT}${hint}`, width, '')));
387
+ }
388
+ }
389
+ const key = toolClickKey(entry.id);
390
+ if (key !== undefined)
391
+ spans.push({ key, start, end: lines.length, expanded });
208
392
  }
209
393
  /**
210
394
  * The calls one card dispatched, one entry each.
211
395
  *
212
396
  * A nested call is a signpost rather than a card of its own, so it keeps one
213
- * entry per call; a long argument still wraps under that entry rather than
214
- * being cut, because the argument is the part a reader scans for and a
215
- * silently shortened path reads as the one that ran.
397
+ * entry per call, clipped to that entry until the reader clicks it: the
398
+ * argument is the part a reader scans for, and one that always wrapped pushed
399
+ * the rest of the program off the screen.
216
400
  */
217
- pushSubCalls(lines, card, width) {
401
+ pushSubCalls(lines, entry, width, spans) {
402
+ const card = entry.card;
218
403
  const subCalls = card.subCalls ?? [];
219
404
  for (const call of subCalls) {
405
+ const start = lines.length;
406
+ const open = this.subCallOpen(entry.id, call.id);
220
407
  const titleToken = call.failed ? 'tool.failed.title' : 'tool.subcall.title';
221
- const title = this.theme.visible(titleToken) ? this.theme.style(titleToken, displayText(call.title)) : '';
408
+ const column = visibleWidth(SUBCALL_INDENT);
409
+ const title = this.theme.visible(titleToken) ? this.theme.rich(call.title, { token: titleToken, column }) : '';
222
410
  const argument = call.argument === undefined || !this.theme.visible('tool.subcall.args')
223
411
  ? ''
224
- : ` ${this.theme.style('tool.subcall.args', displayText(call.argument))}`;
412
+ : ` ${this.theme.rich(call.argument, { token: 'tool.subcall.args', column })}`;
225
413
  const drawn = `${title}${argument}`;
226
414
  // A row whose every part is hidden draws nothing, and nothing must not
227
415
  // cost a line the card does not have.
228
416
  if (drawn === '')
229
417
  continue;
230
- this.pushStyledWrapped(lines, drawn, width, SUBCALL_INDENT);
418
+ if (open) {
419
+ this.pushStyledWrapped(lines, drawn, width, SUBCALL_INDENT);
420
+ this.pushSubCallOutput(lines, call, width);
421
+ }
422
+ else {
423
+ lines.push(this.theme.cut(`${SUBCALL_INDENT}${drawn}`, collapsedEdge(width), '…'));
424
+ }
425
+ spans.push({ key: subCallClickKey(entry.id, call.id), start, end: lines.length, expanded: open });
231
426
  }
232
427
  const hidden = (card.subCallsTotal ?? subCalls.length) - subCalls.length;
233
428
  if (hidden > 0 && this.theme.visible('tool.hint')) {
234
429
  lines.push(this.theme.style('tool.hint', this.theme.cut(`${SUBCALL_INDENT}… ${hidden} more calls`, width, '')));
235
430
  }
236
431
  }
432
+ /**
433
+ * What a dispatched call declared and what it produced, under its argument.
434
+ *
435
+ * The parent card keeps only the program's return value, so both are otherwise
436
+ * gone; a reader who opens the row is asking for them, which is why the call's
437
+ * own rows come first and its outcome under them. Each section keeps the
438
+ * retention its card used, so a long run still says what it dropped.
439
+ */
440
+ pushSubCallOutput(lines, call, width) {
441
+ for (const section of [call.presented, call.output]) {
442
+ if (section === undefined)
443
+ continue;
444
+ for (const row of section.rows) {
445
+ const drawn = this.detailRow(row, section.kind);
446
+ if (drawn === '')
447
+ continue;
448
+ lines.push(this.theme.cut(`${DETAIL_INDENT}${drawn}`, width, ''));
449
+ }
450
+ const hidden = section.totalLines - section.rows.length;
451
+ if (hidden > 0 && this.theme.visible('tool.hint')) {
452
+ // A shell's rows are kept from the end, so its hidden count names the
453
+ // earlier lines; every other kind keeps its head, where the count is of
454
+ // what follows and a neutral wording is what the card uses too.
455
+ const hint = section.kind === 'terminal' ? shellRetentionHint(hidden) : `${hidden} ${CARD_HINT_RETAINED}`;
456
+ lines.push(this.theme.style('tool.hint', this.theme.cut(`${DETAIL_INDENT}${hint}`, width, '')));
457
+ }
458
+ }
459
+ }
460
+ /**
461
+ * A folded card's one row: label, clipped argument, measured facts, outcome.
462
+ *
463
+ * The outcome rides this row rather than a row of its own, because one line
464
+ * per call is what folding promises. A shell card's hidden rows are counted
465
+ * here too: its output is the answer the reader asked for, and a fold that
466
+ * left no trace of it would read as a call that produced nothing.
467
+ *
468
+ * The argument is the part that gives up room: it is clipped to whatever the
469
+ * label, the facts, and the outcome leave before the edge, so a wide terminal
470
+ * shows more of the call while a narrow one still shows how it ended.
471
+ */
472
+ renderCollapsedHead(card, hidden, titleToken, glyphToken, width) {
473
+ const edge = collapsedEdge(width);
474
+ const lead = this.cardLead(titleToken, glyphToken);
475
+ const title = this.cardTitle(card, titleToken);
476
+ const separator = this.statSeparator();
477
+ // The facts carry their own lead; the outcome and the count join whatever
478
+ // precedes them, so the folded line reads as one sentence about the run.
479
+ let tail = this.renderStats(card.stats);
480
+ const status = card.kind === 'terminal' && card.status !== undefined && this.theme.visible('tool.terminal.status')
481
+ ? this.theme.rich(card.status, { token: 'tool.terminal.status' })
482
+ : '';
483
+ if (status !== '')
484
+ tail += `${separator}${status}`;
485
+ if (card.kind === 'terminal' && hidden > 0) {
486
+ const count = this.renderStats([{ kind: 'size', text: `${hidden} line${hidden === 1 ? '' : 's'}` }], '');
487
+ if (count !== '')
488
+ tail += `${separator}${count}`;
489
+ }
490
+ const fixed = visibleWidth(lead) + visibleWidth(title) + visibleWidth(tail);
491
+ const room = Math.max(0, edge - fixed - (title === '' ? 0 : 1));
492
+ const argument = room > 0 ? this.cardArgument(card, room) : '';
493
+ const head = [title, argument].filter(part => part !== '').join(' ');
494
+ // A row with no visible label or argument opens with the tail, and a line
495
+ // that begins with a separator reads as a row that lost its first word.
496
+ if (head === '' && tail.startsWith(separator))
497
+ tail = tail.slice(separator.length);
498
+ return `${lead}${head}${tail}`;
499
+ }
500
+ /**
501
+ * One detail row, drawn with its own kind's colours or nothing at all.
502
+ *
503
+ * The row says what it is, so the renderer never guesses from the text: a diff
504
+ * line beginning with "+" is an addition because the presenter said so, not
505
+ * because of its first character.
506
+ */
507
+ detailRow(row, kind) {
508
+ return row.parts
509
+ .map(part => {
510
+ const token = CARD_ROW_TOKEN[kind]?.[part.class] ?? FALLBACK_ROW_TOKEN;
511
+ return this.theme.visible(token) ? this.theme.rich(part.text, { token, column: visibleWidth(DETAIL_INDENT) }) : '';
512
+ })
513
+ .join('');
514
+ }
515
+ /** The mark that introduces a card, empty when the theme hides its label. */
516
+ cardLead(titleToken, glyphToken) {
517
+ const glyph = this.theme.visible(titleToken) ? this.theme.glyph(glyphToken) : '';
518
+ return glyph === '' ? '' : `${glyph} `;
519
+ }
520
+ /** A card's label, styled, or nothing when the theme hides it. */
521
+ cardTitle(card, titleToken) {
522
+ return this.theme.visible(titleToken) ? this.theme.rich(card.title, { token: titleToken }) : '';
523
+ }
524
+ /** A card's argument, styled and flattened, clipped to `limit` when one is given. */
525
+ cardArgument(card, limit) {
526
+ if (card.argument === undefined || card.argument === '' || !this.theme.visible('tool.args'))
527
+ return '';
528
+ const shown = limit === undefined ? card.argument : clip(oneLine(card.argument), limit);
529
+ return this.theme.rich(shown, { token: 'tool.args' });
530
+ }
237
531
  /**
238
532
  * A card's header: its label, its argument, and its measured facts.
239
533
  *
240
534
  * The glyph is returned apart from the body so a wrapped continuation can
241
- * align under the label rather than under the mark. A terminal's argument is
242
- * left out because it needs a row of its own — it is the one argument that can
243
- * be a whole command rather than a word.
535
+ * align under the label rather than under the mark. An opened terminal's
536
+ * argument is left out because it needs a row of its own — it is the one
537
+ * argument that can be a whole command rather than a word.
244
538
  */
245
539
  renderHead(card, titleToken, glyphToken) {
246
- const glyph = this.theme.visible(titleToken) ? this.theme.glyph(glyphToken) : '';
247
- const lead = glyph === '' ? '' : `${glyph} `;
248
- let body = this.theme.visible(titleToken) ? this.theme.style(titleToken, displayText(card.title)) : '';
249
- if (card.kind !== 'terminal' && card.argument !== undefined && card.argument !== '' && this.theme.visible('tool.args')) {
250
- body += `${body === '' ? '' : ' '}${this.theme.style('tool.args', displayText(card.argument))}`;
251
- }
252
- return { lead, body: body + this.renderStats(card.stats) };
540
+ const lead = this.cardLead(titleToken, glyphToken);
541
+ const title = this.cardTitle(card, titleToken);
542
+ const argument = card.kind === 'terminal' ? '' : this.cardArgument(card);
543
+ const head = [title, argument].filter(part => part !== '').join(' ');
544
+ return { lead, body: head + this.renderStats(card.stats) };
545
+ }
546
+ /** The separator a header uses between its label, its facts, and its outcome. */
547
+ statSeparator() {
548
+ return this.theme.style('tool.stat.separator', STAT_SEPARATOR);
253
549
  }
254
- /** The measured facts, each in its own colour, or nothing when none is visible. */
255
- renderStats(stats) {
550
+ /**
551
+ * The measured facts, each in its own colour, or nothing when none is visible.
552
+ *
553
+ * The lead is a parameter because a header that already carries a title and an
554
+ * argument separates its facts with the same mark as its outcome, while facts
555
+ * on a row of their own stand apart with blank columns.
556
+ */
557
+ renderStats(stats, lead = STAT_LEAD) {
256
558
  if (stats === undefined || stats.length === 0)
257
559
  return '';
258
560
  const drawn = stats
259
561
  .filter(stat => this.theme.visible(STAT_TOKEN[stat.kind]))
260
- .map(stat => this.theme.style(STAT_TOKEN[stat.kind], `${STAT_SYMBOL[stat.kind]}${displayText(stat.text)}`));
562
+ .map(stat => this.theme.rich(`${STAT_SYMBOL[stat.kind]}${stat.text}`, { token: STAT_TOKEN[stat.kind] }));
261
563
  if (drawn.length === 0)
262
564
  return '';
263
- return `${STAT_LEAD}${drawn.join(this.theme.style('tool.stat.separator', STAT_SEPARATOR))}`;
565
+ return `${lead}${drawn.join(this.statSeparator())}`;
264
566
  }
265
567
  pushPicker(lines, picker, width) {
266
568
  lines.push('');
267
- if (this.theme.visible('picker.title')) {
268
- const glyph = this.theme.glyph('picker.glyph');
269
- const lead = glyph === '' ? '' : `${glyph} `;
270
- lines.push(this.theme.style('picker.title', this.theme.cut(`${lead}${displayText(picker.title)}`, width, '')));
271
- }
272
- if (picker.note !== undefined && this.theme.visible('picker.note')) {
273
- this.pushWrapped(lines, picker.note, width, DETAIL_INDENT, text => this.theme.style('picker.note', text));
274
- }
275
- if (picker.filter !== '' && this.theme.visible('picker.filter')) {
276
- lines.push(this.theme.style('picker.filter', this.theme.cut(`${DETAIL_INDENT}filter: ${displayText(picker.filter)}`, width, '')));
277
- }
278
- if (picker.above > 0 && this.theme.visible('picker.scrollNewer')) {
279
- lines.push(this.theme.style('picker.scrollNewer', this.theme.cut(`${OPTION_INDENT}… ${picker.above} newer`, width, '')));
280
- }
281
- for (const row of picker.rows) {
282
- const token = row.current ? 'picker.rowCurrent' : 'picker.row';
283
- if (!this.theme.visible(token))
284
- continue;
285
- const cursor = row.current ? this.theme.glyph('picker.cursor') || CURSOR_MARK : NO_CURSOR;
286
- const text = row.description === undefined
287
- ? `${cursor} ${row.label}`
288
- : `${cursor} ${row.label} — ${row.description}`;
289
- lines.push(this.theme.style(token, this.theme.cut(`${OPTION_INDENT}${displayText(text)}`, width, '')));
290
- }
291
- if (picker.below > 0 && this.theme.visible('picker.scrollOlder')) {
292
- lines.push(this.theme.style('picker.scrollOlder', this.theme.cut(`${OPTION_INDENT}… ${picker.below} older`, width, '')));
293
- }
294
- if (this.theme.visible('picker.hint')) {
295
- lines.push(this.theme.style('picker.hint', this.theme.cut(`${OPTION_INDENT}${displayText(picker.hint)}`, width, '')));
296
- }
569
+ // The rows come from the renderer a popup also draws through, so a card
570
+ // reads the same whether it sits at the end of the transcript or in a box
571
+ // over it, and a field added to a card reaches both at once.
572
+ lines.push(...pickerCardLines(picker, width, this.theme));
297
573
  }
298
574
  pushGate(lines, gate, width) {
299
575
  lines.push('');
@@ -302,11 +578,11 @@ export class TranscriptView {
302
578
  const glyph = this.theme.glyph(glyphToken) || (gate.kind === 'approval' ? APPROVAL_MARK : QUESTION_MARK);
303
579
  // The question is the thing being decided, so it wraps rather than being
304
580
  // cut: a reader cannot answer a sentence they were not shown.
305
- this.pushWrapped(lines, gate.title, width, `${glyph} `, text => this.theme.style('gate.title', text));
581
+ this.pushWrapped(lines, gate.title, width, `${glyph} `, 'gate.title');
306
582
  }
307
583
  if (this.theme.visible('gate.detail')) {
308
584
  for (const detail of gate.detail) {
309
- this.pushWrapped(lines, detail, width, DETAIL_INDENT, text => this.theme.style('gate.detail', text));
585
+ this.pushWrapped(lines, detail, width, DETAIL_INDENT, 'gate.detail');
310
586
  }
311
587
  }
312
588
  gate.options.forEach((option, position) => {
@@ -336,13 +612,19 @@ export class TranscriptView {
336
612
  // its frame, its padding, and its cursor for the width it is given.
337
613
  if (gate.answerInput !== undefined) {
338
614
  const room = Math.max(1, width - visibleWidth(OPTION_INDENT));
339
- for (const row of gate.answerInput.render(room))
340
- lines.push(`${OPTION_INDENT}${row}`);
615
+ // A surface narrower than the indent cannot place the answer: that row is cut
616
+ // here so what is handed out already fits, rather than being trimmed at the
617
+ // terminal past the point where the view could count it. A row that fits is
618
+ // left exactly as the editor drew it, cursor styling included.
619
+ for (const row of gate.answerInput.render(room)) {
620
+ const placed = `${OPTION_INDENT}${row}`;
621
+ lines.push(visibleWidth(placed) <= width ? placed : this.theme.cut(placed, width, ''));
622
+ }
341
623
  }
342
624
  // The keys are how the gate is answered at all, so they wrap rather than
343
625
  // lose their tail at a narrow edge.
344
626
  if (this.theme.visible('gate.hint')) {
345
- this.pushWrapped(lines, gate.hint, width, OPTION_INDENT, text => this.theme.style('gate.hint', text));
627
+ this.pushWrapped(lines, gate.hint, width, OPTION_INDENT, 'gate.hint');
346
628
  }
347
629
  }
348
630
  /**
@@ -360,42 +642,41 @@ export class TranscriptView {
360
642
  const box = row.selected ? CHECKBOX_ON : CHECKBOX_OFF;
361
643
  const lead = `${OPTION_INDENT}${cursor} ${box} ${row.number}. `;
362
644
  const text = row.description === undefined ? row.label : `${row.label} — ${row.description}`;
363
- // The text reaches pushWrapped unescaped: it escapes once, and escaping a
364
- // second time would show the reader the escape instead of the character.
365
- this.pushWrapped(lines, text, width, lead, body => this.theme.style(token, body));
645
+ // The text reaches pushWrapped undrawn: it is read once there, and reading
646
+ // it twice would show the reader the escape instead of the character.
647
+ this.pushWrapped(lines, text, width, lead, token);
366
648
  }
367
649
  /** The rows one transcript entry becomes; `live` marks the entry the turn is still writing. */
368
- renderEntry(entry, lines, width, live) {
650
+ renderEntry(entry, lines, width, live, spans) {
369
651
  switch (entry.kind) {
370
652
  case 'tool':
371
- this.pushCard(lines, entry.card, width);
653
+ this.pushCard(lines, entry, width, spans);
372
654
  return;
373
655
  case 'reasoning':
374
- this.pushReasoning(lines, entry, width);
656
+ this.pushReasoning(lines, entry, width, spans);
375
657
  return;
376
658
  case 'assistant':
377
- this.pushMarkdown(lines, entry.text, width, live);
659
+ // The reply is boxed the way the prompt that asked for it is, so one
660
+ // exchange reads as two objects rather than as a box and then a stream.
661
+ this.pushFramed(lines, entry.text, width, live, ANSWER_FACE, 'transcript.assistant.border');
378
662
  return;
379
- case 'user':
663
+ case 'user': {
380
664
  if (!this.theme.visible('transcript.user'))
381
665
  return;
382
666
  // A prompt is boxed wherever it is read, so the row it left in the queue
383
667
  // and the row it becomes here are recognisably the same object.
384
- lines.push(...frameText(entry.text, width, {
385
- text: text => this.theme.style('transcript.user', text),
386
- border: rule => this.theme.editor.borderColor(rule),
387
- framed: canFrame(width, this.theme.visible('editor.border')),
388
- }));
668
+ this.pushFramed(lines, entry.text, width, false, this.userFace(), 'editor.border');
389
669
  return;
670
+ }
390
671
  case 'notice':
391
672
  if (!this.theme.visible('transcript.notice'))
392
673
  return;
393
- this.pushWrapped(lines, entry.text, width, this.elementLead('transcript.notice'), text => this.theme.style('transcript.notice', text));
674
+ this.pushWrapped(lines, entry.text, width, this.elementLead('transcript.notice'), 'transcript.notice');
394
675
  return;
395
676
  case 'marker':
396
677
  if (!this.theme.visible('transcript.marker'))
397
678
  return;
398
- this.pushWrapped(lines, entry.text, width, this.elementLead('transcript.marker'), text => this.theme.style('transcript.marker', text));
679
+ this.pushWrapped(lines, entry.text, width, this.elementLead('transcript.marker'), 'transcript.marker');
399
680
  return;
400
681
  }
401
682
  }
@@ -411,27 +692,53 @@ export class TranscriptView {
411
692
  // The revision is part of the key: rows drawn under an older theme table
412
693
  // must miss, or a settings change would restyle only the rows that happened
413
694
  // to be redrawn for another reason.
414
- const tag = `${width}|${state.expandCards ? 'c' : '-'}${state.expandReasoning ? 'r' : '-'}${state.expandSubCalls ? 'p' : '-'}|${this.theme.revision}`;
695
+ const baseTag = `${width}|${state.expandCards ? 'c' : '-'}${state.expandReasoning ? 'r' : '-'}${state.expandSubCalls ? 'p' : '-'}|${this.theme.revision}`;
415
696
  const lines = [];
697
+ const spans = [];
416
698
  const settled = this.model.settledCount();
417
699
  const entries = this.model.entries();
418
700
  for (const [index, entry] of entries.entries()) {
701
+ const start = lines.length;
702
+ // Every fold this entry can answer to is part of the tag, so clicking one
703
+ // message — or one call inside it — rebuilds that message alone while the
704
+ // rows around it stay cached.
705
+ const open = entry.kind === 'tool' ? this.expansionOf(entry) : entry.kind === 'reasoning' ? this.reasoningOpen(entry) : undefined;
706
+ const marks = entry.kind === 'tool'
707
+ ? `${open === true ? '+' : '-'}${(entry.card.subCalls ?? []).map(call => (this.subCallOpen(entry.id, call.id) ? '1' : '0')).join('')}`
708
+ : open === true ? '+' : '-';
709
+ const tag = `${baseTag}|${marks}`;
710
+ const local = [];
419
711
  // The in-flight rows change on every frame, so caching them would only
420
712
  // fill the cache with objects nobody will ask for again.
421
713
  if (index >= settled) {
422
- this.renderEntry(entry, lines, width, true);
423
- continue;
714
+ this.renderEntry(entry, lines, width, true, local);
715
+ // An in-flight entry draws straight into the transcript, so the spans it
716
+ // recorded already name transcript rows; offsetting them again would move
717
+ // every hit target as many rows down as the entry's own start.
718
+ spans.push(...local);
424
719
  }
425
- const cached = this.rows.lookup(entry, tag);
426
- if (cached !== undefined) {
427
- lines.push(...cached);
428
- continue;
720
+ else {
721
+ const cached = this.rows.lookup(entry, tag);
722
+ const saved = this.entrySpans.get(entry);
723
+ if (cached !== undefined && saved !== undefined) {
724
+ lines.push(...cached);
725
+ local.push(...saved);
726
+ }
727
+ else {
728
+ const rendered = [];
729
+ this.renderEntry(entry, rendered, width, false, local);
730
+ this.rows.store(entry, tag, rendered);
731
+ this.entrySpans.set(entry, local);
732
+ lines.push(...rendered);
733
+ }
734
+ // A cached span is kept relative to its entry so the entry can hand it
735
+ // back; a click needs it in transcript rows.
736
+ for (const span of local) {
737
+ spans.push({ key: span.key, start: span.start + start, end: span.end + start, expanded: span.expanded });
738
+ }
429
739
  }
430
- const rendered = [];
431
- this.renderEntry(entry, rendered, width, false);
432
- this.rows.store(entry, tag, rendered);
433
- lines.push(...rendered);
434
740
  }
741
+ this.spans = spans;
435
742
  const picker = this.options.picker?.();
436
743
  if (picker !== undefined)
437
744
  this.pushPicker(lines, picker, width);