@sagmans/dsh-tui 0.5.2 → 0.7.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 (285) hide show
  1. package/.agents/skills/dsh-tui-dogfood/SKILL.md +83 -0
  2. package/.agents/skills/dsh-tui-dogfood/references/home-state.md +78 -0
  3. package/.agents/skills/dsh-tui-dogfood/scripts/clone-links.mjs +133 -0
  4. package/.agents/skills/dsh-tui-dogfood/scripts/run-plugin-from-worktree.sh +417 -0
  5. package/.agents/skills/dsh-tui-update-models/SKILL.md +121 -0
  6. package/.agents/skills/dsh-tui-update-models/references/model-wiring.md +162 -0
  7. package/.agents/skills/dsh-tui-update-models/scripts/dump-model-catalog.mjs +178 -0
  8. package/README.md +131 -17
  9. package/lib/agent/host.d.ts +11 -0
  10. package/lib/agent/host.d.ts.map +1 -1
  11. package/lib/agent/host.js +16 -2
  12. package/lib/agent/host.js.map +1 -1
  13. package/lib/agent/present.d.ts.map +1 -1
  14. package/lib/agent/present.js +2 -1
  15. package/lib/agent/present.js.map +1 -1
  16. package/lib/agent/undo.d.ts +52 -0
  17. package/lib/agent/undo.d.ts.map +1 -0
  18. package/lib/agent/undo.js +106 -0
  19. package/lib/agent/undo.js.map +1 -0
  20. package/lib/cards/composition.d.ts +68 -0
  21. package/lib/cards/composition.d.ts.map +1 -0
  22. package/lib/cards/composition.js +117 -0
  23. package/lib/cards/composition.js.map +1 -0
  24. package/lib/cards/presenter.d.ts +23 -0
  25. package/lib/cards/presenter.d.ts.map +1 -0
  26. package/lib/cards/presenter.js +394 -0
  27. package/lib/cards/presenter.js.map +1 -0
  28. package/lib/cards/preview.d.ts +42 -0
  29. package/lib/cards/preview.d.ts.map +1 -0
  30. package/lib/cards/preview.js +51 -0
  31. package/lib/cards/preview.js.map +1 -0
  32. package/lib/cards.d.ts +42 -95
  33. package/lib/cards.d.ts.map +1 -1
  34. package/lib/cards.js +4 -526
  35. package/lib/cards.js.map +1 -1
  36. package/lib/compat/section.d.ts +29 -0
  37. package/lib/compat/section.d.ts.map +1 -0
  38. package/lib/compat/section.js +82 -0
  39. package/lib/compat/section.js.map +1 -0
  40. package/lib/config.d.ts +27 -2
  41. package/lib/config.d.ts.map +1 -1
  42. package/lib/config.js +61 -1
  43. package/lib/config.js.map +1 -1
  44. package/lib/contracts.d.ts +28 -0
  45. package/lib/contracts.d.ts.map +1 -1
  46. package/lib/fold-cursor.d.ts +8 -0
  47. package/lib/fold-cursor.d.ts.map +1 -1
  48. package/lib/fold-cursor.js +18 -0
  49. package/lib/fold-cursor.js.map +1 -1
  50. package/lib/gates/question-card.d.ts +77 -0
  51. package/lib/gates/question-card.d.ts.map +1 -0
  52. package/lib/gates/question-card.js +113 -0
  53. package/lib/gates/question-card.js.map +1 -0
  54. package/lib/gates/questions.d.ts +127 -0
  55. package/lib/gates/questions.d.ts.map +1 -0
  56. package/lib/gates/questions.js +418 -0
  57. package/lib/gates/questions.js.map +1 -0
  58. package/lib/gates.d.ts +1 -113
  59. package/lib/gates.d.ts.map +1 -1
  60. package/lib/gates.js +1 -484
  61. package/lib/gates.js.map +1 -1
  62. package/lib/herdr/reporter.d.ts +9 -4
  63. package/lib/herdr/reporter.d.ts.map +1 -1
  64. package/lib/herdr/reporter.js +5 -9
  65. package/lib/herdr/reporter.js.map +1 -1
  66. package/lib/herdr/state.d.ts +35 -4
  67. package/lib/herdr/state.d.ts.map +1 -1
  68. package/lib/herdr/state.js +37 -4
  69. package/lib/herdr/state.js.map +1 -1
  70. package/lib/index.d.ts +1 -0
  71. package/lib/index.d.ts.map +1 -1
  72. package/lib/index.js +343 -2267
  73. package/lib/index.js.map +1 -1
  74. package/lib/input/action-catalog.d.ts +74 -0
  75. package/lib/input/action-catalog.d.ts.map +1 -0
  76. package/lib/input/action-catalog.js +137 -0
  77. package/lib/input/action-catalog.js.map +1 -0
  78. package/lib/input/actions.d.ts +5 -81
  79. package/lib/input/actions.d.ts.map +1 -1
  80. package/lib/input/actions.js +4 -621
  81. package/lib/input/actions.js.map +1 -1
  82. package/lib/input/completion.d.ts +11 -1
  83. package/lib/input/completion.d.ts.map +1 -1
  84. package/lib/input/completion.js +17 -1
  85. package/lib/input/completion.js.map +1 -1
  86. package/lib/input/file-index.d.ts +40 -0
  87. package/lib/input/file-index.d.ts.map +1 -0
  88. package/lib/input/file-index.js +141 -0
  89. package/lib/input/file-index.js.map +1 -0
  90. package/lib/input/file-search.d.ts +1 -38
  91. package/lib/input/file-search.d.ts.map +1 -1
  92. package/lib/input/file-search.js +1 -487
  93. package/lib/input/file-search.js.map +1 -1
  94. package/lib/input/key-press.d.ts +57 -0
  95. package/lib/input/key-press.d.ts.map +1 -0
  96. package/lib/input/key-press.js +218 -0
  97. package/lib/input/key-press.js.map +1 -0
  98. package/lib/input/keymap-conflicts.d.ts +59 -0
  99. package/lib/input/keymap-conflicts.d.ts.map +1 -0
  100. package/lib/input/keymap-conflicts.js +292 -0
  101. package/lib/input/keymap-conflicts.js.map +1 -0
  102. package/lib/input/keymap-settings.js +1 -1
  103. package/lib/input/keymap-settings.js.map +1 -1
  104. package/lib/input/keymap.d.ts.map +1 -1
  105. package/lib/input/keymap.js +5 -1
  106. package/lib/input/keymap.js.map +1 -1
  107. package/lib/input/submission.d.ts +11 -1
  108. package/lib/input/submission.d.ts.map +1 -1
  109. package/lib/input/submission.js +10 -1
  110. package/lib/input/submission.js.map +1 -1
  111. package/lib/input/workspace-files.d.ts +10 -0
  112. package/lib/input/workspace-files.d.ts.map +1 -0
  113. package/lib/input/workspace-files.js +359 -0
  114. package/lib/input/workspace-files.js.map +1 -0
  115. package/lib/install-skills.d.ts +20 -0
  116. package/lib/install-skills.d.ts.map +1 -0
  117. package/lib/install-skills.js +198 -0
  118. package/lib/install-skills.js.map +1 -0
  119. package/lib/keys-command.d.ts +2 -1
  120. package/lib/keys-command.d.ts.map +1 -1
  121. package/lib/keys-command.js +2 -1
  122. package/lib/keys-command.js.map +1 -1
  123. package/lib/model-list.d.ts +37 -0
  124. package/lib/model-list.d.ts.map +1 -0
  125. package/lib/model-list.js +60 -0
  126. package/lib/model-list.js.map +1 -0
  127. package/lib/startup.d.ts +8 -0
  128. package/lib/startup.d.ts.map +1 -1
  129. package/lib/startup.js +120 -24
  130. package/lib/startup.js.map +1 -1
  131. package/lib/subagents.d.ts +7 -3
  132. package/lib/subagents.d.ts.map +1 -1
  133. package/lib/subagents.js +28 -4
  134. package/lib/subagents.js.map +1 -1
  135. package/lib/surface/appearance-preferences.d.ts +26 -0
  136. package/lib/surface/appearance-preferences.d.ts.map +1 -0
  137. package/lib/surface/appearance-preferences.js +139 -0
  138. package/lib/surface/appearance-preferences.js.map +1 -0
  139. package/lib/surface/appearance.d.ts +87 -0
  140. package/lib/surface/appearance.d.ts.map +1 -0
  141. package/lib/surface/appearance.js +353 -0
  142. package/lib/surface/appearance.js.map +1 -0
  143. package/lib/surface/background-work.d.ts +49 -0
  144. package/lib/surface/background-work.d.ts.map +1 -0
  145. package/lib/surface/background-work.js +146 -0
  146. package/lib/surface/background-work.js.map +1 -0
  147. package/lib/surface/commands.d.ts +80 -0
  148. package/lib/surface/commands.d.ts.map +1 -0
  149. package/lib/surface/commands.js +364 -0
  150. package/lib/surface/commands.js.map +1 -0
  151. package/lib/surface/modal-input.d.ts +58 -0
  152. package/lib/surface/modal-input.d.ts.map +1 -0
  153. package/lib/surface/modal-input.js +237 -0
  154. package/lib/surface/modal-input.js.map +1 -0
  155. package/lib/surface/model-choice.d.ts +54 -0
  156. package/lib/surface/model-choice.d.ts.map +1 -0
  157. package/lib/surface/model-choice.js +325 -0
  158. package/lib/surface/model-choice.js.map +1 -0
  159. package/lib/surface/preset-choice.d.ts +78 -0
  160. package/lib/surface/preset-choice.d.ts.map +1 -0
  161. package/lib/surface/preset-choice.js +163 -0
  162. package/lib/surface/preset-choice.js.map +1 -0
  163. package/lib/surface/prompt-input.d.ts +97 -0
  164. package/lib/surface/prompt-input.d.ts.map +1 -0
  165. package/lib/surface/prompt-input.js +215 -0
  166. package/lib/surface/prompt-input.js.map +1 -0
  167. package/lib/surface/prompt-memory.d.ts +76 -0
  168. package/lib/surface/prompt-memory.d.ts.map +1 -0
  169. package/lib/surface/prompt-memory.js +167 -0
  170. package/lib/surface/prompt-memory.js.map +1 -0
  171. package/lib/surface/session-lifecycle.d.ts +120 -0
  172. package/lib/surface/session-lifecycle.d.ts.map +1 -0
  173. package/lib/surface/session-lifecycle.js +321 -0
  174. package/lib/surface/session-lifecycle.js.map +1 -0
  175. package/lib/surface/session-picker.d.ts +39 -0
  176. package/lib/surface/session-picker.d.ts.map +1 -0
  177. package/lib/surface/session-picker.js +107 -0
  178. package/lib/surface/session-picker.js.map +1 -0
  179. package/lib/surface/session-view.d.ts +86 -0
  180. package/lib/surface/session-view.d.ts.map +1 -0
  181. package/lib/surface/session-view.js +215 -0
  182. package/lib/surface/session-view.js.map +1 -0
  183. package/lib/surface/staged-turns.d.ts +81 -0
  184. package/lib/surface/staged-turns.d.ts.map +1 -0
  185. package/lib/surface/staged-turns.js +206 -0
  186. package/lib/surface/staged-turns.js.map +1 -0
  187. package/lib/surface/terminal-lifecycle.d.ts +76 -0
  188. package/lib/surface/terminal-lifecycle.d.ts.map +1 -0
  189. package/lib/surface/terminal-lifecycle.js +222 -0
  190. package/lib/surface/terminal-lifecycle.js.map +1 -0
  191. package/lib/terminal/warning-screen.d.ts +8 -1
  192. package/lib/terminal/warning-screen.d.ts.map +1 -1
  193. package/lib/terminal/warning-screen.js +9 -0
  194. package/lib/terminal/warning-screen.js.map +1 -1
  195. package/lib/terminal-text/scan.d.ts +44 -0
  196. package/lib/terminal-text/scan.d.ts.map +1 -0
  197. package/lib/terminal-text/scan.js +195 -0
  198. package/lib/terminal-text/scan.js.map +1 -0
  199. package/lib/terminal-text/sgr.d.ts +32 -0
  200. package/lib/terminal-text/sgr.d.ts.map +1 -0
  201. package/lib/terminal-text/sgr.js +203 -0
  202. package/lib/terminal-text/sgr.js.map +1 -0
  203. package/lib/terminal-text.d.ts +1 -1
  204. package/lib/terminal-text.d.ts.map +1 -1
  205. package/lib/terminal-text.js +2 -379
  206. package/lib/terminal-text.js.map +1 -1
  207. package/lib/theme-command.js +2 -1
  208. package/lib/theme-command.js.map +1 -1
  209. package/lib/theme-defaults.d.ts +64 -0
  210. package/lib/theme-defaults.d.ts.map +1 -0
  211. package/lib/theme-defaults.js +248 -0
  212. package/lib/theme-defaults.js.map +1 -0
  213. package/lib/theme-resolver.d.ts +36 -0
  214. package/lib/theme-resolver.d.ts.map +1 -0
  215. package/lib/theme-resolver.js +112 -0
  216. package/lib/theme-resolver.js.map +1 -0
  217. package/lib/theme-schema.d.ts +6 -6
  218. package/lib/theme-schema.d.ts.map +1 -1
  219. package/lib/theme-schema.js +3 -2
  220. package/lib/theme-schema.js.map +1 -1
  221. package/lib/theme-settings.d.ts +13 -15
  222. package/lib/theme-settings.d.ts.map +1 -1
  223. package/lib/theme-settings.js +27 -19
  224. package/lib/theme-settings.js.map +1 -1
  225. package/lib/theme-tokens.d.ts +1 -90
  226. package/lib/theme-tokens.d.ts.map +1 -1
  227. package/lib/theme-tokens.js +11 -341
  228. package/lib/theme-tokens.js.map +1 -1
  229. package/lib/theme.d.ts +1 -1
  230. package/lib/theme.d.ts.map +1 -1
  231. package/lib/theme.js +2 -1
  232. package/lib/theme.js.map +1 -1
  233. package/lib/tool-display.d.ts.map +1 -1
  234. package/lib/tool-display.js +2 -1
  235. package/lib/tool-display.js.map +1 -1
  236. package/lib/transcript/message-content.d.ts +21 -0
  237. package/lib/transcript/message-content.d.ts.map +1 -0
  238. package/lib/transcript/message-content.js +61 -0
  239. package/lib/transcript/message-content.js.map +1 -0
  240. package/lib/transcript/tool-calls.d.ts +106 -0
  241. package/lib/transcript/tool-calls.d.ts.map +1 -0
  242. package/lib/transcript/tool-calls.js +254 -0
  243. package/lib/transcript/tool-calls.js.map +1 -0
  244. package/lib/transcript.d.ts +38 -43
  245. package/lib/transcript.d.ts.map +1 -1
  246. package/lib/transcript.js +58 -245
  247. package/lib/transcript.js.map +1 -1
  248. package/lib/ui/copy.d.ts +17 -0
  249. package/lib/ui/copy.d.ts.map +1 -0
  250. package/lib/ui/copy.js +104 -0
  251. package/lib/ui/copy.js.map +1 -0
  252. package/lib/ui/dock.d.ts +35 -3
  253. package/lib/ui/dock.d.ts.map +1 -1
  254. package/lib/ui/dock.js +124 -23
  255. package/lib/ui/dock.js.map +1 -1
  256. package/lib/ui/editor.d.ts.map +1 -1
  257. package/lib/ui/editor.js +2 -1
  258. package/lib/ui/editor.js.map +1 -1
  259. package/lib/ui/frame.d.ts +31 -0
  260. package/lib/ui/frame.d.ts.map +1 -1
  261. package/lib/ui/frame.js +27 -4
  262. package/lib/ui/frame.js.map +1 -1
  263. package/lib/ui/keymap-picker.d.ts +2 -1
  264. package/lib/ui/keymap-picker.d.ts.map +1 -1
  265. package/lib/ui/keymap-picker.js +1 -0
  266. package/lib/ui/keymap-picker.js.map +1 -1
  267. package/lib/ui/view/gate-card.d.ts +21 -0
  268. package/lib/ui/view/gate-card.d.ts.map +1 -0
  269. package/lib/ui/view/gate-card.js +104 -0
  270. package/lib/ui/view/gate-card.js.map +1 -0
  271. package/lib/ui/view/tool-card.d.ts +138 -0
  272. package/lib/ui/view/tool-card.d.ts.map +1 -0
  273. package/lib/ui/view/tool-card.js +413 -0
  274. package/lib/ui/view/tool-card.js.map +1 -0
  275. package/lib/ui/view/transcript-message.d.ts +62 -0
  276. package/lib/ui/view/transcript-message.d.ts.map +1 -0
  277. package/lib/ui/view/transcript-message.js +158 -0
  278. package/lib/ui/view/transcript-message.js.map +1 -0
  279. package/lib/ui/view.d.ts +87 -156
  280. package/lib/ui/view.d.ts.map +1 -1
  281. package/lib/ui/view.js +122 -556
  282. package/lib/ui/view.js.map +1 -1
  283. package/package.json +3 -1
  284. package/themes/deepseek-blue.yaml +9 -0
  285. package/themes/violet-orbit.yaml +7 -0
package/lib/ui/view.js CHANGED
@@ -1,92 +1,22 @@
1
- import { visibleWidth, wrapTextWithAnsi, } from '@earendil-works/pi-tui';
2
- import { cardDetailRows, clip, oneLine, shellFoldHint, shellRetentionHint } from "../cards.js";
3
- import { defaultKeymap, hintKeys } from "../input/actions.js";
4
- import { CUSTOM_ROW_NUMBER } from "../gates.js";
5
- import { DEFAULT_TOOL_DISPLAY } from "../tool-display.js";
6
- import { CARD_ROW_TOKEN } from "../theme-tokens.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";
11
1
  import { RowCache } from "./rows.js";
12
- const DETAIL_INDENT = ' ';
13
- const OPTION_INDENT = ' ';
14
- /** A nested call is a signpost under its card, so it sits one step shallower than that card's detail. */
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;
24
- /** The mark a cursor falls back to when the reader has not set one, so no literal lives in a template. */
25
- const CURSOR_MARK = '❯';
26
- const APPROVAL_MARK = '⚠';
27
- const QUESTION_MARK = '?';
28
- const CHECKBOX_ON = '[x]';
29
- const CHECKBOX_OFF = '[ ]';
30
- /** A row that a card's kind does not map draws as generic detail. */
31
- const FALLBACK_ROW_TOKEN = 'tool.detail';
32
- /** An unselected row has no cursor, and a blank column is not a value to configure. */
33
- const NO_CURSOR = ' ';
34
- /** The words an opened card uses when retention, not the fold, dropped rows. */
35
- const CARD_HINT_RETAINED = 'more lines not shown';
2
+ import {} from "./picker.js";
3
+ import { pickerCardLines } from "./picker-card.js";
4
+ import { ANSWER_FACE } from "./markdown.js";
5
+ import {} from "./frame.js";
6
+ import {} from "../theme.js";
7
+ import {} from "../theme-tokens.js";
8
+ import { DEFAULT_TOOL_DISPLAY } from "../tool-display.js";
9
+ import { SECOND_MS } from "../transcript/tool-calls.js";
10
+ import {} from "../transcript.js";
11
+ import {} from "../gates.js";
12
+ import { defaultKeymap, hintKeys } from "../input/actions.js";
13
+ import { visibleWidth, wrapTextWithAnsi } from '@earendil-works/pi-tui';
14
+ import { GateCards } from "./view/gate-card.js";
15
+ import { Messages } from "./view/transcript-message.js";
16
+ import { ToolCards } from "./view/tool-card.js";
36
17
  /** What a hint names when the reader has unbound the key it would advertise. */
37
18
  const REASONING_FOLD_FALLBACK = 'shift+tab';
38
19
  const CARD_OPEN_FALLBACK = 'ctrl+o';
39
- /** What separates a card's header from its measured facts, and the facts from each other. */
40
- const STAT_LEAD = ' ';
41
- const STAT_SEPARATOR = ' · ';
42
- /** The symbol that says what a fact counts; a size needs none. */
43
- const STAT_SYMBOL = {
44
- added: '+',
45
- changed: '~',
46
- removed: '-',
47
- size: '',
48
- };
49
- /** Which colour draws each fact, so added, changed, and removed never share one. */
50
- const STAT_TOKEN = {
51
- added: 'tool.stat.added',
52
- changed: 'tool.stat.changed',
53
- removed: 'tool.stat.removed',
54
- size: 'tool.stat.size',
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
- }
90
20
  /**
91
21
  * The state a reader gets before opening anything, and the view's own fallback.
92
22
  *
@@ -100,8 +30,6 @@ export const DEFAULT_VIEW_STATE = { expandCards: false, expandReasoning: false,
100
30
  const toolClickKey = (id) => (id === '' ? undefined : `tool:${id}`);
101
31
  const reasoningClickKey = (id) => (id === '' ? undefined : `reason:${id}`);
102
32
  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);
105
33
  export class TranscriptView {
106
34
  model;
107
35
  theme;
@@ -109,23 +37,45 @@ export class TranscriptView {
109
37
  options;
110
38
  rows;
111
39
  /**
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
- */
40
+ * Fold choices the reader made by clicking, per message.
41
+ *
42
+ * Absent means the key or the tool's own policy decides, so a settings edit
43
+ * stays in charge of messages nobody clicked. Keyed by the call or thought id
44
+ * rather than by the entry, because a result replaces the entry a click was
45
+ * made on and a thought outlives the live row that streamed it.
46
+ */
119
47
  clicked = new Map();
120
48
  /** The rows each clickable message drew last render, for mapping a click back to it. */
121
49
  spans = [];
122
50
  /** The same spans relative to their entry, so a cached entry still answers clicks. */
123
51
  entrySpans = new WeakMap();
52
+ /**
53
+ * The framed rows each entry drew last render, for reading a copy back out.
54
+ *
55
+ * Relative to the entry for the same reason the spans are: a cached entry draws
56
+ * no rows this frame and still has to answer for what it drew before.
57
+ */
58
+ entryCopy = new WeakMap();
59
+ /**
60
+ * The framed rows the in-flight entries drew this frame.
61
+ *
62
+ * They cannot be kept by entry the way the settled ones are: every frame builds
63
+ * a live entry as a fresh object, so a map keyed by it would never answer. A copy
64
+ * is read against the last frame, so it is the render that keeps them.
65
+ */
66
+ liveCopy = [];
67
+ cards;
68
+ messages;
69
+ gates;
70
+ /** The row renderers, each told only what it draws, so the view stays the one owner of view state and row caches. */
124
71
  constructor(model, theme, markdown, options = {}) {
125
72
  this.model = model;
126
73
  this.theme = theme;
127
74
  this.markdown = markdown;
128
75
  this.options = options;
76
+ this.cards = new ToolCards({ theme: this.theme, state: () => this.viewState, keymap: () => this.keymap(), toolDisplay: tool => this.toolDisplay(tool), expansionOf: entry => this.expansionOf(entry), liveCall: callId => this.model.liveCall(callId), cardOpenHint: () => hintKeys(this.keymap(), 'surface.toolDetail') || CARD_OPEN_FALLBACK, toolKey: id => toolClickKey(id), subCallKey: (parentId, id) => subCallClickKey(parentId, id), subCallOpen: (parentId, id) => this.subCallOpen(parentId, id) });
77
+ this.messages = new Messages({ theme: this.theme, markdown: this.markdown, reasoningOpen: entry => this.reasoningOpen(entry), reasoningFoldHint: () => this.reasoningFoldHint(), reasoningKey: id => reasoningClickKey(id), pushWrapped: (lines, text, width, prefix, token) => this.pushWrapped(lines, text, width, prefix, token) });
78
+ this.gates = new GateCards({ theme: this.theme, pushWrapped: (lines, text, width, prefix, token) => this.pushWrapped(lines, text, width, prefix, token) });
129
79
  this.rows = options.rows ?? new RowCache();
130
80
  }
131
81
  get viewState() {
@@ -136,12 +86,12 @@ export class TranscriptView {
136
86
  return this.options.toolDisplay?.(tool) ?? DEFAULT_TOOL_DISPLAY;
137
87
  }
138
88
  /**
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
- */
89
+ * Whether one tool message draws open.
90
+ *
91
+ * A click outranks the key, which outranks the tool's start state: Ctrl+O
92
+ * stays "show me everything", while one clicked message keeps the state the
93
+ * reader gave it.
94
+ */
145
95
  expansionOf(entry) {
146
96
  const key = toolClickKey(entry.id);
147
97
  const clicked = key === undefined ? undefined : this.clicked.get(key);
@@ -158,14 +108,14 @@ export class TranscriptView {
158
108
  return this.clicked.get(subCallClickKey(parentId, id)) ?? false;
159
109
  }
160
110
  /**
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
- */
111
+ * Answer a click on a message by folding or unfolding that one row.
112
+ *
113
+ * The tightest span under the point wins, so a click on a dispatched call
114
+ * opens that call rather than the card around it. Only a left click is
115
+ * consumed: a press, a drag, and a wheel belong to the surface's own
116
+ * selection and scrolling, and a row that swallowed them would cost the
117
+ * reader the ability to copy the command it just drew.
118
+ */
169
119
  handleMouse(event) {
170
120
  if (event.type !== 'click' || event.button !== 'left')
171
121
  return undefined;
@@ -191,13 +141,13 @@ export class TranscriptView {
191
141
  this.rows.clear();
192
142
  }
193
143
  /**
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
- */
144
+ * Wrap one text block under a prefix, keeping the prefix's column budget.
145
+ *
146
+ * The block is drawn before it is wrapped: a sequence a terminal would have
147
+ * acted on has to be gone before pi-tui measures the row, and a tab has to
148
+ * land on the stop the writer saw rather than one a wrapper would guess at.
149
+ * The prefix seeds that stop, because it is already on the row.
150
+ */
201
151
  pushWrapped(lines, text, width, prefix, token) {
202
152
  const lead = visibleWidth(prefix);
203
153
  const indent = ' '.repeat(lead);
@@ -206,85 +156,6 @@ export class TranscriptView {
206
156
  lines.push(this.theme.cut(`${index === 0 ? prefix : indent}${line}`, width, ''));
207
157
  });
208
158
  }
209
- /**
210
- * Wrap one already-styled block under a prefix.
211
- *
212
- * A card header mixes tokens — title, argument, stats — in a single string, so
213
- * it cannot go through {@link pushWrapped}, which escapes and restyles plain
214
- * text. Its width still has to fold at the screen edge rather than be cut,
215
- * because the argument is the part a reader scans for and a silently dropped
216
- * command is worse than a taller card.
217
- */
218
- pushStyledWrapped(lines, content, width, prefix) {
219
- const lead = visibleWidth(prefix);
220
- const indent = ' '.repeat(lead);
221
- const wrapped = wrapTextWithAnsi(content, Math.max(1, width - lead));
222
- wrapped.forEach((line, index) => {
223
- lines.push(this.theme.cut(`${index === 0 ? prefix : indent}${line}`, width, ''));
224
- });
225
- }
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)) {
246
- // A blank markdown line stays blank: it shows nothing, so it holds nothing.
247
- if (line === '') {
248
- lines.push('');
249
- continue;
250
- }
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) } };
287
- }
288
159
  /** The live map, or the shipped one for a caller that did not lend one. */
289
160
  keymap() {
290
161
  return this.options.keys?.() ?? defaultKeymap();
@@ -292,277 +163,21 @@ export class TranscriptView {
292
163
  reasoningFoldHint() {
293
164
  return hintKeys(this.keymap(), 'surface.reasoning') || REASONING_FOLD_FALLBACK;
294
165
  }
295
- pushReasoning(lines, entry, width, spans) {
296
- if (!this.theme.visible('transcript.reasoning.summary'))
297
- return;
298
- const start = lines.length;
299
- const open = this.reasoningOpen(entry);
300
- const glyph = this.theme.glyph('transcript.reasoning.summary');
301
- const lead = glyph === '' ? '' : `${glyph} `;
302
- // A folded row carries the key that opens it, because a count with no way to
303
- // reach the text reads the same as the text never having arrived. The key
304
- // rides the row rather than a line of its own, so naming the hidden body
305
- // costs no vertical space.
306
- const suffix = !open && entry.body !== '' && this.theme.visible('transcript.reasoning.hint')
307
- ? ` (${this.reasoningFoldHint()})`
308
- : '';
309
- // The key is kept whole: the summary is the part that gives up room.
310
- const room = Math.max(1, width - visibleWidth(suffix));
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);
319
- }
320
- const key = reasoningClickKey(entry.id);
321
- if (key !== undefined)
322
- spans.push({ key, start, end: lines.length, expanded: open });
323
- }
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.
332
- const preview = expanded
333
- ? { expanded: true }
334
- : spec.output === 'tail'
335
- ? { expanded: false, preview: 'tail', rows: spec.tail }
336
- : { expanded: false, preview: 'title' };
337
- const { lines: detail, hidden } = cardDetailRows(card, preview);
338
- const titleToken = card.failed ? 'tool.failed.title' : 'tool.title';
339
- const glyphToken = card.failed ? 'tool.failed.glyph' : 'tool.glyph';
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.
355
- if (this.viewState.expandSubCalls)
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);
359
- }
360
- for (const row of detail) {
361
- const drawn = this.detailRow(row, card.kind);
362
- // A row whose every part is hidden draws nothing, and nothing must not
363
- // cost a line: the indent would read as an empty row the card does not have.
364
- if (drawn === '')
365
- continue;
366
- lines.push(this.theme.cut(`${DETAIL_INDENT}${drawn}`, width, ''));
367
- }
368
- // The pill is not output, so it draws after the preview window rather than
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, ''));
373
- }
374
- // A shell card's rows are kept from the end, so a hidden count always names
375
- // the rows *before* what is on screen and the hint has to say so; every
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 });
392
- }
393
- /**
394
- * The calls one card dispatched, one entry each.
395
- *
396
- * A nested call is a signpost rather than a card of its own, so it keeps one
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.
400
- */
401
- pushSubCalls(lines, entry, width, spans) {
402
- const card = entry.card;
403
- const subCalls = card.subCalls ?? [];
404
- for (const call of subCalls) {
405
- const start = lines.length;
406
- const open = this.subCallOpen(entry.id, call.id);
407
- const titleToken = call.failed ? 'tool.failed.title' : 'tool.subcall.title';
408
- const column = visibleWidth(SUBCALL_INDENT);
409
- const title = this.theme.visible(titleToken) ? this.theme.rich(call.title, { token: titleToken, column }) : '';
410
- const argument = call.argument === undefined || !this.theme.visible('tool.subcall.args')
411
- ? ''
412
- : ` ${this.theme.rich(call.argument, { token: 'tool.subcall.args', column })}`;
413
- const drawn = `${title}${argument}`;
414
- // A row whose every part is hidden draws nothing, and nothing must not
415
- // cost a line the card does not have.
416
- if (drawn === '')
417
- continue;
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 });
426
- }
427
- const hidden = (card.subCallsTotal ?? subCalls.length) - subCalls.length;
428
- if (hidden > 0 && this.theme.visible('tool.hint')) {
429
- lines.push(this.theme.style('tool.hint', this.theme.cut(`${SUBCALL_INDENT}… ${hidden} more calls`, width, '')));
430
- }
431
- }
432
166
  /**
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
- }
531
- /**
532
- * A card's header: its label, its argument, and its measured facts.
533
- *
534
- * The glyph is returned apart from the body so a wrapped continuation can
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.
538
- */
539
- renderHead(card, titleToken, glyphToken) {
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);
549
- }
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) {
558
- if (stats === undefined || stats.length === 0)
559
- return '';
560
- const drawn = stats
561
- .filter(stat => this.theme.visible(STAT_TOKEN[stat.kind]))
562
- .map(stat => this.theme.rich(`${STAT_SYMBOL[stat.kind]}${stat.text}`, { token: STAT_TOKEN[stat.kind] }));
563
- if (drawn.length === 0)
564
- return '';
565
- return `${lead}${drawn.join(this.statSeparator())}`;
167
+ * Whether any part of one card is still in flight.
168
+ *
169
+ * Only a card with work outstanding has a clock attached to its cache, so the
170
+ * moment the last call settles the row stops being rebuilt: a program that
171
+ * came back must not keep spending the cache its result is now stored in.
172
+ */
173
+ isLive(entry) {
174
+ if (entry.kind !== 'tool')
175
+ return false;
176
+ // The card's own call is live only while the fold still has it pending: the
177
+ // flag is put on a copy at draw time, so the entry alone cannot answer this.
178
+ // A dispatched call carries its flag on the fold itself, where a settle
179
+ // clears it.
180
+ return this.model.liveCall(entry.id).running || (entry.card.subCalls ?? []).some(call => call.running);
566
181
  }
567
182
  pushPicker(lines, picker, width) {
568
183
  lines.push('');
@@ -571,120 +186,40 @@ export class TranscriptView {
571
186
  // over it, and a field added to a card reaches both at once.
572
187
  lines.push(...pickerCardLines(picker, width, this.theme));
573
188
  }
574
- pushGate(lines, gate, width) {
575
- lines.push('');
576
- if (this.theme.visible('gate.title')) {
577
- const glyphToken = gate.kind === 'approval' ? 'gate.glyphApproval' : 'gate.glyphQuestion';
578
- const glyph = this.theme.glyph(glyphToken) || (gate.kind === 'approval' ? APPROVAL_MARK : QUESTION_MARK);
579
- // The question is the thing being decided, so it wraps rather than being
580
- // cut: a reader cannot answer a sentence they were not shown.
581
- this.pushWrapped(lines, gate.title, width, `${glyph} `, 'gate.title');
582
- }
583
- if (this.theme.visible('gate.detail')) {
584
- for (const detail of gate.detail) {
585
- this.pushWrapped(lines, detail, width, DETAIL_INDENT, 'gate.detail');
586
- }
587
- }
588
- gate.options.forEach((option, position) => {
589
- this.pushGateRow(lines, width, {
590
- number: gate.optionOffset + position + 1,
591
- label: option.label,
592
- description: option.description,
593
- current: option.current,
594
- selected: option.selected,
595
- });
596
- });
597
- // The free-text row is drawn under the window rather than inside it: it is
598
- // the one row that must never scroll out of reach, and the window's own
599
- // numbering is left running 1..n above it.
600
- if (gate.custom !== undefined) {
601
- this.pushGateRow(lines, width, {
602
- number: CUSTOM_ROW_NUMBER,
603
- label: gate.custom.label,
604
- description: gate.custom.description,
605
- current: gate.custom.current,
606
- selected: gate.custom.selected,
607
- });
608
- }
609
- // The answer belongs to the row it fills: the free-text row, or the question
610
- // itself when typing is the only way to answer it. The rows come from the
611
- // surface's own editor, so they are placed rather than restyled — it draws
612
- // its frame, its padding, and its cursor for the width it is given.
613
- if (gate.answerInput !== undefined) {
614
- const room = Math.max(1, width - visibleWidth(OPTION_INDENT));
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
- }
623
- }
624
- // The keys are how the gate is answered at all, so they wrap rather than
625
- // lose their tail at a narrow edge.
626
- if (this.theme.visible('gate.hint')) {
627
- this.pushWrapped(lines, gate.hint, width, OPTION_INDENT, 'gate.hint');
628
- }
629
- }
630
- /**
631
- * One row a reader can choose: the cursor, the box, the number, the label.
632
- *
633
- * The row wraps under the label it belongs to rather than at the screen edge,
634
- * because the label and its description together are what tells two rows
635
- * apart, and a mark on a continuation line reads as another row.
636
- */
637
- pushGateRow(lines, width, row) {
638
- const token = row.current ? 'gate.optionCurrent' : 'gate.option';
639
- if (!this.theme.visible(token))
640
- return;
641
- const cursor = row.current ? this.theme.glyph('gate.cursor') || CURSOR_MARK : NO_CURSOR;
642
- const box = row.selected ? CHECKBOX_ON : CHECKBOX_OFF;
643
- const lead = `${OPTION_INDENT}${cursor} ${box} ${row.number}. `;
644
- const text = row.description === undefined ? row.label : `${row.label} — ${row.description}`;
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);
648
- }
649
189
  /** The rows one transcript entry becomes; `live` marks the entry the turn is still writing. */
650
- renderEntry(entry, lines, width, live, spans) {
190
+ renderEntry(entry, lines, width, live, spans, copy) {
651
191
  switch (entry.kind) {
652
192
  case 'tool':
653
- this.pushCard(lines, entry, width, spans);
193
+ this.cards.pushCard(lines, entry, width, spans);
654
194
  return;
655
195
  case 'reasoning':
656
- this.pushReasoning(lines, entry, width, spans);
196
+ this.messages.pushReasoning(lines, entry, width, spans);
657
197
  return;
658
198
  case 'assistant':
659
199
  // The reply is boxed the way the prompt that asked for it is, so one
660
200
  // 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');
201
+ this.messages.pushFramed(lines, copy, entry.text, width, live, ANSWER_FACE, 'transcript.assistant.border');
662
202
  return;
663
203
  case 'user': {
664
204
  if (!this.theme.visible('transcript.user'))
665
205
  return;
666
206
  // A prompt is boxed wherever it is read, so the row it left in the queue
667
207
  // and the row it becomes here are recognisably the same object.
668
- this.pushFramed(lines, entry.text, width, false, this.userFace(), 'editor.border');
208
+ this.messages.pushFramed(lines, copy, entry.text, width, false, this.messages.userFace(), 'editor.border');
669
209
  return;
670
210
  }
671
211
  case 'notice':
672
212
  if (!this.theme.visible('transcript.notice'))
673
213
  return;
674
- this.pushWrapped(lines, entry.text, width, this.elementLead('transcript.notice'), 'transcript.notice');
214
+ this.pushWrapped(lines, entry.text, width, this.messages.elementLead('transcript.notice'), 'transcript.notice');
675
215
  return;
676
216
  case 'marker':
677
217
  if (!this.theme.visible('transcript.marker'))
678
218
  return;
679
- this.pushWrapped(lines, entry.text, width, this.elementLead('transcript.marker'), 'transcript.marker');
219
+ this.pushWrapped(lines, entry.text, width, this.messages.elementLead('transcript.marker'), 'transcript.marker');
680
220
  return;
681
221
  }
682
222
  }
683
- /** The mark and the space that introduce an element, empty when it has none. */
684
- elementLead(token) {
685
- const glyph = this.theme.visible(token) ? this.theme.glyph(token) : '';
686
- return glyph === '' ? '' : `${glyph} `;
687
- }
688
223
  render(width) {
689
224
  if (width <= 0)
690
225
  return [];
@@ -695,6 +230,7 @@ export class TranscriptView {
695
230
  const baseTag = `${width}|${state.expandCards ? 'c' : '-'}${state.expandReasoning ? 'r' : '-'}${state.expandSubCalls ? 'p' : '-'}|${this.theme.revision}`;
696
231
  const lines = [];
697
232
  const spans = [];
233
+ const liveCopy = [];
698
234
  const settled = this.model.settledCount();
699
235
  const entries = this.model.entries();
700
236
  for (const [index, entry] of entries.entries()) {
@@ -706,12 +242,20 @@ export class TranscriptView {
706
242
  const marks = entry.kind === 'tool'
707
243
  ? `${open === true ? '+' : '-'}${(entry.card.subCalls ?? []).map(call => (this.subCallOpen(entry.id, call.id) ? '1' : '0')).join('')}`
708
244
  : open === true ? '+' : '-';
709
- const tag = `${baseTag}|${marks}`;
245
+ // A running row is drawn from a clock the rest of the row is not, so its
246
+ // cache is spent every second: the entries after it stay put, and a card
247
+ // waiting out a two-minute run keeps saying so instead of freezing at the
248
+ // first second it was drawn.
249
+ const tag = `${baseTag}|${marks}${this.isLive(entry) ? `|${Math.floor(this.model.now() / SECOND_MS)}` : ''}`;
710
250
  const local = [];
251
+ const copy = [];
711
252
  // The in-flight rows change on every frame, so caching them would only
712
253
  // fill the cache with objects nobody will ask for again.
713
254
  if (index >= settled) {
714
- this.renderEntry(entry, lines, width, true, local);
255
+ this.renderEntry(entry, lines, width, true, local, copy);
256
+ // A row still arriving is drawn every frame, so it is never cached: its
257
+ // copy account goes to the frame rather than to an entry nobody can name.
258
+ liveCopy.push(...copy);
715
259
  // An in-flight entry draws straight into the transcript, so the spans it
716
260
  // recorded already name transcript rows; offsetting them again would move
717
261
  // every hit target as many rows down as the entry's own start.
@@ -720,15 +264,17 @@ export class TranscriptView {
720
264
  else {
721
265
  const cached = this.rows.lookup(entry, tag);
722
266
  const saved = this.entrySpans.get(entry);
723
- if (cached !== undefined && saved !== undefined) {
267
+ const savedCopy = this.entryCopy.get(entry);
268
+ if (cached !== undefined && saved !== undefined && savedCopy !== undefined) {
724
269
  lines.push(...cached);
725
270
  local.push(...saved);
726
271
  }
727
272
  else {
728
273
  const rendered = [];
729
- this.renderEntry(entry, rendered, width, false, local);
274
+ this.renderEntry(entry, rendered, width, false, local, copy);
730
275
  this.rows.store(entry, tag, rendered);
731
276
  this.entrySpans.set(entry, local);
277
+ this.entryCopy.set(entry, copy);
732
278
  lines.push(...rendered);
733
279
  }
734
280
  // A cached span is kept relative to its entry so the entry can hand it
@@ -739,13 +285,33 @@ export class TranscriptView {
739
285
  }
740
286
  }
741
287
  this.spans = spans;
288
+ this.liveCopy = liveCopy;
742
289
  const picker = this.options.picker?.();
743
290
  if (picker !== undefined)
744
291
  this.pushPicker(lines, picker, width);
745
292
  const gate = this.options.gate?.();
746
293
  if (gate !== undefined)
747
- this.pushGate(lines, gate, width);
294
+ this.gates.pushGate(lines, gate, width);
748
295
  return lines;
749
296
  }
297
+ /**
298
+ * The framed rows the last render drew, in the order the transcript reads.
299
+ *
300
+ * A copy of a selection is read off the screen, so it carries the frame the
301
+ * surface drew around a message — the sides, the padding beside them, and the
302
+ * rules above and below. This is the account that lets the surface take its own
303
+ * frame back out of a copy without ever touching a character the reader wrote.
304
+ */
305
+ copyRows() {
306
+ const rows = [];
307
+ for (const entry of this.model.entries()) {
308
+ const saved = this.entryCopy.get(entry);
309
+ if (saved !== undefined)
310
+ rows.push(...saved);
311
+ }
312
+ // The rows still arriving are not kept by entry, so they come from the frame.
313
+ rows.push(...this.liveCopy);
314
+ return rows;
315
+ }
750
316
  }
751
317
  //# sourceMappingURL=view.js.map