@iowarp/clio-coder 0.3.2 → 0.3.4

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 (259) hide show
  1. package/CHANGELOG.md +269 -458
  2. package/CONTRIBUTING.md +1 -1
  3. package/README.md +3 -3
  4. package/dist/{acp-BIYHVZIM.js → acp-S5R4RR5B.js} +7 -6
  5. package/dist/{agents-YT6SSRIT.js → agents-P6DMMVZY.js} +24 -21
  6. package/dist/assets/codewiki.json +1 -1
  7. package/dist/{auth-5TWEIYDN.js → auth-2XCZLPKS.js} +12 -8
  8. package/dist/{chunk-GGXXDWE4.js → chunk-22NAGB7X.js} +2 -2
  9. package/dist/{chunk-WMSVI4G2.js → chunk-2LZI5CAG.js} +133 -13
  10. package/dist/{chunk-OAO4GE4M.js → chunk-2TZWSW76.js} +2 -2
  11. package/dist/{chunk-OOJYHWRB.js → chunk-34475P3I.js} +2 -2
  12. package/dist/{chunk-WVO7V2QY.js → chunk-35MKKU5R.js} +4 -4
  13. package/dist/{chunk-LBNRH5WM.js → chunk-3HZ5RWN2.js} +5 -5
  14. package/dist/{chunk-AGYYIBLL.js → chunk-3JLKSKD7.js} +2 -2
  15. package/dist/{chunk-MBS4V7ZP.js → chunk-4JUF2NNX.js} +7 -7
  16. package/dist/{chunk-ZDOOVTXZ.js → chunk-4OC57DA6.js} +27 -4
  17. package/dist/chunk-5M54SPOL.js +926 -0
  18. package/dist/{chunk-STBPMHSX.js → chunk-7RXG6QRZ.js} +51 -11
  19. package/dist/{chunk-77VKQEHF.js → chunk-A2GZF7DC.js} +5 -5
  20. package/dist/{chunk-A3CYT5EX.js → chunk-AD2SYQYC.js} +55 -2
  21. package/dist/chunk-AOCYTWAV.js +449 -0
  22. package/dist/chunk-BEY543CS.js +258 -0
  23. package/dist/{chunk-6N5PTWMY.js → chunk-BP4OYD6A.js} +32 -13
  24. package/dist/chunk-BPGS2WCQ.js +612 -0
  25. package/dist/{chunk-J5HN4RYU.js → chunk-BRXQQJFP.js} +8 -8
  26. package/dist/chunk-CFGTUFWB.js +67 -0
  27. package/dist/{chunk-CBCAPZAA.js → chunk-E25LMLRW.js} +2 -2
  28. package/dist/{chunk-G2DE3C7R.js → chunk-EDRHSCIE.js} +4 -4
  29. package/dist/{chunk-4KLWL3UC.js → chunk-EFADSJET.js} +2 -2
  30. package/dist/{chunk-M6SHUN7Q.js → chunk-FO5ZOVUY.js} +2 -2
  31. package/dist/chunk-FYYLNIL5.js +313 -0
  32. package/dist/{chunk-EPVUXGXG.js → chunk-HV5X7OR2.js} +14 -12
  33. package/dist/{chunk-TZTZS7QK.js → chunk-HXG4IURW.js} +5 -3
  34. package/dist/{chunk-IGLFWIYI.js → chunk-K6WL7QZT.js} +3 -3
  35. package/dist/chunk-K7VKOLQQ.js +15 -0
  36. package/dist/{chunk-BMEMKKIT.js → chunk-KOHPCX4K.js} +2 -2
  37. package/dist/{chunk-V4RXGQ5Q.js → chunk-KRPY7NTG.js} +10 -7
  38. package/dist/chunk-LL4KHSZI.js +22 -0
  39. package/dist/{chunk-KJ5LWLOE.js → chunk-MEQ45TQ4.js} +15 -9
  40. package/dist/{chunk-5UUP6MWO.js → chunk-MV3K5QF2.js} +5 -436
  41. package/dist/{chunk-AO4RKG4M.js → chunk-N4CZJQRK.js} +5 -5
  42. package/dist/{chunk-ARBGF5F7.js → chunk-NILBFAPG.js} +14 -8
  43. package/dist/chunk-OZNBF4L3.js +23 -0
  44. package/dist/{verify-G6V4D2G7.js → chunk-PCZJO5TI.js} +127 -42
  45. package/dist/chunk-QQK64KLB.js +1360 -0
  46. package/dist/{chunk-6EJV5X2W.js → chunk-QQL5RT5M.js} +979 -1619
  47. package/dist/{chunk-LZSJBIVT.js → chunk-QWU7ZBO7.js} +70 -720
  48. package/dist/{chunk-2EHAIA3X.js → chunk-RD5U66HV.js} +3 -3
  49. package/dist/{chunk-OKGUZO2U.js → chunk-SPULKLCF.js} +4 -3
  50. package/dist/{chunk-OQ33BKR3.js → chunk-TTNYS3EA.js} +3 -60
  51. package/dist/chunk-TW3WDMVS.js +677 -0
  52. package/dist/chunk-TZSKNMZG.js +434 -0
  53. package/dist/{chunk-7MNJORFF.js → chunk-UL3WSD3F.js} +6 -1
  54. package/dist/{chunk-7EYHLWU7.js → chunk-UZHIZC5S.js} +7 -7
  55. package/dist/{chunk-QTYWRVRA.js → chunk-VAWWTKDP.js} +8 -8
  56. package/dist/{chunk-X75S7HFS.js → chunk-VEZEGCGW.js} +214 -20
  57. package/dist/{chunk-OHHN2SO4.js → chunk-VMNQ6OZA.js} +98 -202
  58. package/dist/chunk-VSNATDE6.js +122 -0
  59. package/dist/chunk-W6GROXXM.js +69 -0
  60. package/dist/chunk-WPQLXFOZ.js +375 -0
  61. package/dist/{chunk-ORBHGJC5.js → chunk-WR67VIZY.js} +3 -3
  62. package/dist/{chunk-3ZXDFGR5.js → chunk-X6COSD2O.js} +5 -5
  63. package/dist/chunk-ZGVHUX3M.js +66 -0
  64. package/dist/{chunk-MAW544W2.js → chunk-ZWMF7253.js} +4 -4
  65. package/dist/{chunk-MQSRRFWA.js → chunk-ZYKPLLNQ.js} +563 -546
  66. package/dist/cli/index.js +27 -23
  67. package/dist/{clio-4LY5K2AC.js → clio-J5JIOIDS.js} +7 -6
  68. package/dist/{code-nav-7AX6FYE6.js → code-nav-AXCXSBHX.js} +5 -3
  69. package/dist/{config-GTLUW2PR.js → config-OEBMIN2U.js} +37 -27
  70. package/dist/{configure-R6A64DHX.js → configure-PUQOSIXQ.js} +16 -13
  71. package/dist/{context-5VKGUVJJ.js → context-EKDCKUUZ.js} +82 -7
  72. package/dist/{context-RW5HC47S.js → context-MGSE4Z2T.js} +33 -23
  73. package/dist/{context-JFZEJ7W5.js → context-URSXPBCK.js} +17 -9
  74. package/dist/{context-clear-6ZHBAZZT.js → context-clear-KDAJRNUK.js} +33 -23
  75. package/dist/context-working-set-SBKMPPI2.js +1552 -0
  76. package/dist/{dispatch-runner-VKBRCWQC.js → dispatch-runner-MSWN72NK.js} +43 -29
  77. package/dist/{doctor-KI767GSN.js → doctor-7BSE27PJ.js} +10 -10
  78. package/dist/{eval-XSSNATB4.js → eval-IZGDOO4H.js} +9 -8
  79. package/dist/{evidence-UA6AWDQQ.js → evidence-SR7WXB5B.js} +51 -23
  80. package/dist/{evolve-QNTFGV6Z.js → evolve-K7VE2CBX.js} +30 -20
  81. package/dist/{fleet-Q7UOMUSG.js → fleet-7XMJNQNF.js} +48 -38
  82. package/dist/{fleet-preflight-DDN536IT.js → fleet-preflight-AQNAH644.js} +3 -3
  83. package/dist/{init-WBB65ZHQ.js → init-JGNPAYXT.js} +41 -31
  84. package/dist/{memory-MD3O64RI.js → memory-4ALKDJ4Q.js} +32 -22
  85. package/dist/{models-BZU34YWD.js → models-ZMMLFJNN.js} +22 -19
  86. package/dist/{monitor-MEQA5C3I.js → monitor-2F3T5KHP.js} +55 -43
  87. package/dist/{orchestrator-CGFKEP27.js → orchestrator-ORHT43JB.js} +2507 -1896
  88. package/dist/{reset-L2FQEE3E.js → reset-NXGTYNUO.js} +4 -3
  89. package/dist/{run-IV4Q6RLN.js → run-RF4WJGMT.js} +51 -41
  90. package/dist/{share-S5BZQC5I.js → share-UT3W6E4M.js} +5 -4
  91. package/dist/{skills-LQEKRDTN.js → skills-PSACKC5Q.js} +2 -2
  92. package/dist/{skills-eval-3DC4HEWS.js → skills-eval-WJSI55RZ.js} +34 -24
  93. package/dist/{targets-C4SSGQOB.js → targets-PIIRAOYS.js} +23 -20
  94. package/dist/{terminal-lease-IT5JW2NR.js → terminal-lease-ULWXWNVY.js} +5 -4
  95. package/dist/{upgrade-7TT7SQ3G.js → upgrade-346TZ6AV.js} +18 -17
  96. package/dist/{usage-GV4PKT3M.js → usage-6KKXR32N.js} +34 -24
  97. package/dist/verifiers-4UUM6TEE.js +1214 -0
  98. package/dist/verify-X5HDROLA.js +25 -0
  99. package/dist/{wiki-generate-DQF6Z66B.js → wiki-generate-7STOCIFZ.js} +42 -31
  100. package/dist/worker/entry.js +33 -24
  101. package/docs/README.md +8 -7
  102. package/docs/acp.md +1 -1
  103. package/docs/alcf-provider.md +1 -1
  104. package/docs/architecture.md +2 -2
  105. package/docs/artifact-versions.md +1 -1
  106. package/docs/built-in-agents.md +1 -1
  107. package/docs/capacity-and-scheduling.md +1 -1
  108. package/docs/commands-and-modes.md +53 -21
  109. package/docs/config-knobs-audit.md +1 -2
  110. package/docs/configuration-and-targets.md +15 -1
  111. package/docs/context-engine.md +64 -12
  112. package/docs/context-working-set.md +194 -0
  113. package/docs/development-pipeline.md +1 -1
  114. package/docs/documentation-coverage.md +5 -5
  115. package/docs/documentation-guide.md +6 -5
  116. package/docs/environment-variables.md +2 -1
  117. package/docs/eval-runner.md +1 -1
  118. package/docs/evals-internal.md +14 -1
  119. package/docs/evidence-and-memory.md +74 -2
  120. package/docs/evolution.md +1 -1
  121. package/docs/exit-codes-and-output.md +1 -1
  122. package/docs/extensions-and-sharing.md +2 -2
  123. package/docs/fleet-dispatch.md +22 -7
  124. package/docs/glossary.md +21 -1
  125. package/docs/installation-and-lifecycle.md +6 -6
  126. package/docs/middleware-and-components.md +1 -1
  127. package/docs/model-catalog.md +7 -9
  128. package/docs/observability.md +4 -4
  129. package/docs/performance-methodology.md +2 -2
  130. package/docs/proactive-memory.md +1 -1
  131. package/docs/prompt-envelope-and-tools.md +4 -4
  132. package/docs/provider-adapter-cookbook.md +1 -1
  133. package/docs/release-cut-checklist.md +35 -35
  134. package/docs/safety-model.md +23 -4
  135. package/docs/scientific-validation.md +21 -3
  136. package/docs/session-lifecycle.md +3 -3
  137. package/docs/skills-marketplace.md +1 -1
  138. package/docs/tool-usage.md +79 -12
  139. package/docs/trace-store.md +1 -1
  140. package/docs/troubleshooting.md +1 -1
  141. package/docs/tui-design.md +2 -2
  142. package/docs/worker-dispatch-mechanics.md +11 -1
  143. package/package.json +8 -11
  144. package/skills/meta/clio-test/SKILL.md +20 -17
  145. package/skills/meta/clio-test/evals.md +3 -3
  146. package/skills/meta/clio-test/references/harness.md +35 -6
  147. package/skills/meta/clio-test/references/test-map.md +20 -10
  148. package/skills/registry.yaml +2 -2
  149. package/skills/skill-marketplace.json +1 -1
  150. package/src/cli/context-working-set.ts +513 -0
  151. package/src/cli/context.ts +8 -0
  152. package/src/cli/evidence.ts +20 -2
  153. package/src/cli/index.ts +4 -0
  154. package/src/cli/verifiers.ts +325 -0
  155. package/src/core/bash-exec.ts +39 -14
  156. package/src/core/bus-events.ts +19 -4
  157. package/src/core/config.ts +54 -0
  158. package/src/core/defaults.ts +50 -3
  159. package/src/core/git-commit-attribution.ts +46 -21
  160. package/src/core/verification-scripts.ts +6 -0
  161. package/src/domains/agents/builtins/verifier.md +3 -0
  162. package/src/domains/config/classify.ts +1 -0
  163. package/src/domains/config/keybindings.ts +3 -3
  164. package/src/domains/context/working-set/contract.ts +161 -0
  165. package/src/domains/context/working-set/defaults.ts +28 -0
  166. package/src/domains/context/working-set/engine.ts +203 -0
  167. package/src/domains/context/working-set/fold.ts +62 -0
  168. package/src/domains/context/working-set/horizon.ts +38 -0
  169. package/src/domains/context/working-set/marker.ts +103 -0
  170. package/src/domains/context/working-set/path-index.ts +436 -0
  171. package/src/domains/context/working-set/payload.ts +152 -0
  172. package/src/domains/context/working-set/policies/age-horizon.ts +55 -0
  173. package/src/domains/context/working-set/policies/index.ts +21 -0
  174. package/src/domains/context/working-set/policies/structural.ts +160 -0
  175. package/src/domains/context/working-set/project.ts +132 -0
  176. package/src/domains/context/working-set/protect.ts +109 -0
  177. package/src/domains/context/working-set/recall.ts +177 -0
  178. package/src/domains/context/working-set/replay/controls.ts +112 -0
  179. package/src/domains/context/working-set/replay/load-clio.ts +199 -0
  180. package/src/domains/context/working-set/replay/metrics.ts +185 -0
  181. package/src/domains/context/working-set/replay/reference-graph.ts +79 -0
  182. package/src/domains/context/working-set/replay/report.ts +139 -0
  183. package/src/domains/context/working-set/replay/runner.ts +325 -0
  184. package/src/domains/context/working-set/replay/synthetic.ts +422 -0
  185. package/src/domains/context/working-set/replay/trace.ts +21 -0
  186. package/src/domains/context/working-set/visible.ts +54 -0
  187. package/src/domains/evidence/build.ts +112 -45
  188. package/src/domains/evidence/eval.ts +24 -7
  189. package/src/domains/evidence/index.ts +53 -0
  190. package/src/domains/evidence/ordering.ts +12 -0
  191. package/src/domains/evidence/run-trust.ts +221 -0
  192. package/src/domains/evidence/store.ts +46 -6
  193. package/src/domains/evidence/trust-status.ts +854 -0
  194. package/src/domains/evidence/types.ts +26 -0
  195. package/src/domains/middleware/memory-intervention.ts +3 -0
  196. package/src/domains/middleware/stalled-turn.ts +165 -4
  197. package/src/domains/safety/autonomy.ts +1 -1
  198. package/src/domains/safety/default-path-policy.ts +8 -0
  199. package/src/domains/safety/finish-contract.ts +4 -3
  200. package/src/domains/safety/policy-engine.ts +48 -6
  201. package/src/domains/session/compaction/compact.ts +23 -1
  202. package/src/domains/session/compaction/cut-point.ts +2 -0
  203. package/src/domains/session/compaction/tokens.ts +16 -1
  204. package/src/domains/session/context-ledger.ts +2 -0
  205. package/src/domains/session/entries.ts +107 -1
  206. package/src/domains/session/manager.ts +9 -2
  207. package/src/domains/session/migrations/index.ts +22 -3
  208. package/src/engine/acp/server.ts +3 -0
  209. package/src/engine/agent.ts +18 -1
  210. package/src/engine/session.ts +9 -3
  211. package/src/entry/orchestrator.ts +16 -4
  212. package/src/interactive/chat-loop-messages.ts +18 -6
  213. package/src/interactive/chat-panel.ts +571 -244
  214. package/src/interactive/chat-renderer.ts +79 -39
  215. package/src/interactive/context-meter.ts +10 -0
  216. package/src/interactive/context-overlay.ts +81 -6
  217. package/src/interactive/context-recall-command.ts +110 -0
  218. package/src/interactive/editor-submit.ts +26 -1
  219. package/src/interactive/footer/widgets.ts +22 -20
  220. package/src/interactive/footer-panel.ts +6 -1
  221. package/src/interactive/interactive-application.ts +2 -0
  222. package/src/interactive/interactive-event-projection.ts +12 -0
  223. package/src/interactive/interactive-slash-runtime.ts +49 -8
  224. package/src/interactive/model-session-replay.ts +21 -0
  225. package/src/interactive/overlay-general-openers.ts +6 -0
  226. package/src/interactive/overlay-session-lifecycle.ts +8 -4
  227. package/src/interactive/overlays/ask-user.ts +146 -24
  228. package/src/interactive/renderers/tool-execution.ts +167 -56
  229. package/src/interactive/session-transcript.ts +2 -2
  230. package/src/interactive/slash-commands.ts +29 -2
  231. package/src/interactive/status/index.ts +12 -1
  232. package/src/interactive/status/reasoning.ts +87 -0
  233. package/src/interactive/status/summary.ts +13 -2
  234. package/src/interactive/transcript-detail.ts +120 -0
  235. package/src/interactive/turn-context.ts +238 -88
  236. package/src/interactive/turn-middleware.ts +6 -6
  237. package/src/tools/agent-tools.ts +11 -4
  238. package/src/tools/bash.ts +144 -82
  239. package/src/tools/builtin-tool-catalog.ts +18 -6
  240. package/src/tools/context/index.ts +105 -3
  241. package/src/tools/context/surface.ts +3 -2
  242. package/src/tools/core-bootstrap.ts +21 -0
  243. package/src/tools/dispatch-runner.ts +9 -7
  244. package/src/tools/monitor.ts +28 -20
  245. package/src/tools/presentation.ts +107 -0
  246. package/src/tools/registry.ts +65 -7
  247. package/src/tools/result-disposition.ts +550 -0
  248. package/src/tools/result-shaping.ts +262 -19
  249. package/src/tools/safe-exec.ts +2 -0
  250. package/src/tools/verify/authoring.ts +1119 -0
  251. package/src/tools/verify/catalog.ts +346 -0
  252. package/src/tools/verify/index.ts +13 -3
  253. package/src/tools/verify/scripts.ts +135 -37
  254. package/src/tools/verify/surface.ts +9 -5
  255. package/src/tools/worker-evidence.ts +35 -12
  256. package/dist/chunk-MNA4JGU4.js +0 -255
  257. package/dist/chunk-SRF2PJNW.js +0 -184
  258. package/dist/chunk-T6YILFSB.js +0 -80
  259. package/dist/chunk-VAKQQHWR.js +0 -434
@@ -1,6 +1,7 @@
1
1
  import type { TokenThroughputSnapshot, UsageBreakdown } from "../domains/observability/index.js";
2
2
  import { type Text, truncateToWidth, visibleWidth } from "../engine/tui.js";
3
3
  import type { DispatchBoardRow, DispatchBoardStatus } from "./dispatch-board.js";
4
+ import { formatReasoningChip } from "./status/reasoning.js";
4
5
  import { type ClioTheme, formatCompactMs, formatContextPercent, GLYPH } from "./theme/index.js";
5
6
 
6
7
  /**
@@ -37,7 +38,11 @@ export function tokensSegment(usage: UsageBreakdown | null | undefined): string
37
38
  const reasoning = Math.max(0, usage.reasoningTokens ?? 0);
38
39
  const total = Math.max(0, usage.totalTokens ?? input + output);
39
40
  if (input + output + reasoning + total === 0) return null;
40
- const reasoningPart = reasoning > 0 ? ` r${formatFooterTokens(reasoning)}` : "";
41
+ // Session totals are summed from provider usage payloads, so the chip carries
42
+ // no `≈`. It is built by the same formatter every other reasoning surface
43
+ // uses rather than by a fourth copy of the marker rule.
44
+ const reasoningChip = formatReasoningChip({ tokens: reasoning, provenance: "provider" }, formatFooterTokens);
45
+ const reasoningPart = reasoningChip === null ? "" : ` ${reasoningChip}`;
41
46
  const totalPart = total > 0 ? ` Σ${formatFooterTokens(total)}` : "";
42
47
  return `${GLYPH.up}${formatFooterTokens(input)} ${GLYPH.down}${formatFooterTokens(output)}${reasoningPart}${totalPart}`;
43
48
  }
@@ -560,6 +560,7 @@ export async function createInteractiveApplication(deps: InteractiveDeps): Promi
560
560
  recordToolEnd: (_toolName, toolCallId, isError, truncated) =>
561
561
  presentation.recordToolEnd({ toolCallId, isError, truncated }),
562
562
  setStatusLine: (line) => chatPanel.setStatusLine(line),
563
+ setLiveReasoning: (view) => chatPanel.setLiveReasoning(view),
563
564
  setLastTurnSummary: (summary) => presentation.setLastTurnSummary(summary),
564
565
  startTerminalProgress: () => agentProgress.start(),
565
566
  stopTerminalProgress: () => agentProgress.stop(),
@@ -591,6 +592,7 @@ export async function createInteractiveApplication(deps: InteractiveDeps): Promi
591
592
  chatPanel: {
592
593
  appendReplayBlock: (...args) => chatRenderer.mutate(() => chatPanel.appendReplayBlock(...args), "slash-output"),
593
594
  appendUser: (text) => chatRenderer.mutate(() => chatPanel.appendUser(text), "user-submit"),
595
+ clearFoldOverrides: () => chatRenderer.mutate(() => chatPanel.clearFoldOverrides(), "output-detail"),
594
596
  },
595
597
  beforeSemanticSubmit: () => chatRenderer.flush(),
596
598
  settleVisibleFrame: (reason) => chatRenderer.flushAndCommit(reason),
@@ -28,6 +28,8 @@ import {
28
28
  import {
29
29
  type AgentStatus,
30
30
  INLINE_STATUS_INDENT_COLS,
31
+ type ReasoningUsageView,
32
+ reasoningFromTally,
31
33
  resolveInlineVerb,
32
34
  type StatusPhase,
33
35
  spinnerFrame,
@@ -67,6 +69,11 @@ export interface InteractiveEventProjectionDeps {
67
69
  recordToolStart: (toolName: string, toolCallId: string) => void;
68
70
  recordToolEnd: (toolName: string, toolCallId: string, isError: boolean, truncated: boolean) => void;
69
71
  setStatusLine: (line: InteractiveStatusLine | null) => void;
72
+ /**
73
+ * Publish the live turn's reasoning projection to the transcript. Optional so
74
+ * a host without a chat panel still gets every other projection.
75
+ */
76
+ setLiveReasoning?: (view: ReasoningUsageView | null) => void;
70
77
  setLastTurnSummary: (summary: TurnSummary) => void;
71
78
  startTerminalProgress: () => void;
72
79
  stopTerminalProgress: () => void;
@@ -177,6 +184,11 @@ export function createInteractiveEventProjection(deps: InteractiveEventProjectio
177
184
  let statusInlineFrame = 0;
178
185
  primaryUnsubscribers.push(
179
186
  deps.status.subscribe((status) => {
187
+ // One projection of the run tally reaches the transcript, so the live
188
+ // line, the turn receipt, and the footer state the same number.
189
+ deps.setLiveReasoning?.(
190
+ status.phase === "idle" || status.phase === "ended" ? null : reasoningFromTally(status.runTally),
191
+ );
180
192
  if (status.phase === "idle") {
181
193
  deps.setStatusLine(null);
182
194
  } else if (status.phase === "ended") {
@@ -1,5 +1,6 @@
1
1
  import { mkdirSync, writeFileSync } from "node:fs";
2
2
  import { join, resolve } from "node:path";
3
+ import { BusChannels } from "../core/bus-events.js";
3
4
  import type { ClioSettings } from "../core/config.js";
4
5
  import type { SafeEventBus } from "../core/event-bus.js";
5
6
  import type { PendingSkillRequest } from "../core/skill-activation.js";
@@ -28,6 +29,7 @@ import { type ChatPanel, createChatPanel } from "./chat-panel.js";
28
29
  import { rehydrateChatPanelFromTurns } from "./chat-renderer.js";
29
30
  import { runCompactWithNotice } from "./command-fallbacks.js";
30
31
  import { appendNotice, appendOperatorCommand } from "./command-output.js";
32
+ import { runOperatorRecall } from "./context-recall-command.js";
31
33
  import { renderSessionHtml } from "./export-html/index.js";
32
34
  import { dateLocal } from "./format-time.js";
33
35
  import type { SettingsCenterRowId, SettingsSectionId } from "./overlays/settings.js";
@@ -54,7 +56,7 @@ export interface InteractiveSlashSubmitExpansion {
54
56
  }
55
57
 
56
58
  type SlashChat = Pick<ChatLoop, "getSessionId" | "isStreaming" | "submit">;
57
- type SlashChatPanel = Pick<ChatPanel, "appendReplayBlock" | "appendUser">;
59
+ type SlashChatPanel = Pick<ChatPanel, "appendReplayBlock" | "appendUser" | "clearFoldOverrides">;
58
60
  type SlashResources = Pick<ResourcesContract, "prompts" | "expandPromptTemplate" | "reload">;
59
61
  type SlashExtensions = Pick<ExtensionsContract, "list">;
60
62
  type SlashAgents = Pick<AgentsContract, "getSpec" | "listSpecs">;
@@ -100,8 +102,10 @@ export interface InteractiveSlashRuntimeDeps {
100
102
  * Leaf lookup for `/export`, so the export follows the branch the session
101
103
  * is actually on. current.jsonl still holds the abandoned turns after a
102
104
  * `/tree` pin, and an unscoped rehydrate reproduced them (issue #109).
105
+ * `/context recall` uses the same leaf for its fold, plus `appendEntry` for
106
+ * the `contextRecall` record.
103
107
  */
104
- session?: Pick<SessionContract, "tree">;
108
+ session?: Pick<SessionContract, "tree" | "current" | "appendEntry">;
105
109
  expandSubmit: (text: string) => Promise<InteractiveSlashSubmitExpansion>;
106
110
  openAskUser: AskUserHandler;
107
111
  openSkillsHub: () => void;
@@ -398,6 +402,11 @@ export function createInteractiveSlashRuntime(deps: InteractiveSlashRuntimeDeps)
398
402
  // alone. Saving it as the default is what Settings → Terminal is for.
399
403
  if (deps.commitSetting) deps.commitSetting("terminal.outputVerbosity", next, "session");
400
404
  else deps.writeSettings?.(next);
405
+ // `/output` is also the explicit reset action for transcript folds. Do
406
+ // this even when the requested level already matches the current one;
407
+ // the render-time change detector cannot observe a same-value command.
408
+ deps.chatPanel.clearFoldOverrides();
409
+ deps.refreshFooter();
401
410
  return { status: "applied", verbosity: match };
402
411
  },
403
412
  openModel: deps.openModel,
@@ -434,11 +443,12 @@ export function createInteractiveSlashRuntime(deps: InteractiveSlashRuntimeDeps)
434
443
  try {
435
444
  const turns = deps.readStructuredEntries(sessionId);
436
445
  // Same pure render pipeline as the live panel and /resume replay: a
437
- // throwaway panel is rehydrated from the ledger, every tool segment is
438
- // expanded, and the transcript is rendered at a stable width. HTML
439
- // converts the resulting ANSI presentation to inline styles; Markdown
440
- // keeps the prior plain-text fenced transcript.
441
- const exportPanel = createChatPanel({ unboundedToolBodies: true });
446
+ // throwaway panel is rehydrated from the ledger under the verbose
447
+ // transcript detail policy (every body open, full receipts) with no
448
+ // operator overrides, and rendered at a stable width. HTML converts
449
+ // the resulting ANSI presentation to inline styles; Markdown keeps the
450
+ // prior plain-text fenced transcript.
451
+ const exportPanel = createChatPanel({ unboundedToolBodies: true, getOutputVerbosity: () => "verbose" });
442
452
  // Scoped to the leaf the session is on, the same way /resume replays
443
453
  // (issue #107): with a /tree pin persisted and not yet extended, the
444
454
  // file still holds the abandoned branch after the pin, and an unscoped
@@ -449,7 +459,6 @@ export function createInteractiveSlashRuntime(deps: InteractiveSlashRuntimeDeps)
449
459
  unboundedToolBodies: true,
450
460
  ...(leafTurnId ? { activeLeafTurnId: leafTurnId } : {}),
451
461
  });
452
- exportPanel.toggleAllToolsExpanded();
453
462
  const ansiLines = exportPanel.render(EXPORT_RENDER_WIDTH);
454
463
  // The operator names this file by the day they ran the export, so the
455
464
  // date in it is their calendar date. The header below keeps the ISO
@@ -507,6 +516,38 @@ export function createInteractiveSlashRuntime(deps: InteractiveSlashRuntimeDeps)
507
516
  }
508
517
  deps.openContextReset();
509
518
  },
519
+ ...(deps.session
520
+ ? {
521
+ runContextRecall: (ref: string) => {
522
+ const session = deps.session;
523
+ if (!session) return;
524
+ const outcome = runOperatorRecall(ref, {
525
+ hasSession: () => session.current() !== null,
526
+ readEntries: () => {
527
+ const sessionId = deps.chat.getSessionId();
528
+ return sessionId ? deps.readStructuredEntries(sessionId) : [];
529
+ },
530
+ activeLeafTurnId: () => {
531
+ const meta = session.current();
532
+ return meta ? (session.tree(meta.id).leafId ?? undefined) : undefined;
533
+ },
534
+ appendEntry: (entry) => session.appendEntry(entry),
535
+ onRecalled: (payload) => deps.bus.emit(BusChannels.ContextRecalled, payload),
536
+ ...(deps.now ? { now: () => (deps.now?.() ?? new Date()).getTime() } : {}),
537
+ });
538
+ if (!outcome.ok) {
539
+ appendCommandNotice("error", outcome.message);
540
+ return;
541
+ }
542
+ appendCommandNotice("success", outcome.headline);
543
+ // The body is transcript output, not a notice: notices collapse
544
+ // newlines, and a recalled build log is worth nothing on one line.
545
+ // It is a replay block either way, so it never enters model context.
546
+ deps.io.stdout(`${outcome.body}\n`);
547
+ deps.requestRender();
548
+ },
549
+ }
550
+ : {}),
510
551
  runContextRefresh: () => {
511
552
  if (!deps.onContextRefresh) {
512
553
  deps.io.stderr("[/context refresh] context refresh not wired; pass onContextRefresh to startInteractive\n");
@@ -0,0 +1,21 @@
1
+ import { foldWorkingSet } from "../domains/context/working-set/fold.js";
2
+ import { projectWorkingSet } from "../domains/context/working-set/project.js";
3
+ import type { SessionEntry } from "../domains/session/entries.js";
4
+ import type { AgentMessage } from "../engine/types.js";
5
+ import { buildReplayAgentMessagesFromTurns, type RehydrateChatPanelOptions } from "./chat-renderer.js";
6
+
7
+ /**
8
+ * Build provider-facing replay messages from the durable session ledger.
9
+ * Projection always honors existing eviction and recall entries; the enabled
10
+ * setting gates creation of new evictions, not replay of durable state.
11
+ * Visible transcript and export callers intentionally keep using the raw
12
+ * rehydration helpers so eviction remains a model projection, not data loss.
13
+ */
14
+ export function buildModelReplayAgentMessagesFromTurns(
15
+ entries: ReadonlyArray<SessionEntry>,
16
+ options: RehydrateChatPanelOptions = {},
17
+ ): AgentMessage[] {
18
+ const activeLeafTurnId = options.activeLeafTurnId ?? options.uptoTurnId;
19
+ const projected = projectWorkingSet(entries, foldWorkingSet(entries, activeLeafTurnId));
20
+ return buildReplayAgentMessagesFromTurns(projected, options);
21
+ }
@@ -1,4 +1,5 @@
1
1
  import type { SafeEventBus } from "../core/event-bus.js";
2
+ import { foldWorkingSet } from "../domains/context/working-set/fold.js";
2
3
  import type { DispatchContract } from "../domains/dispatch/index.js";
3
4
  import { loadMemoryRecordsSync, type MemoryRecord } from "../domains/memory/index.js";
4
5
  import type { ObservabilityContract } from "../domains/observability/index.js";
@@ -107,6 +108,11 @@ export function createOverlayGeneralOpeners(deps: OverlayGeneralOpenersDeps): Ov
107
108
  deps.transitions.handle = openContextOverlayFactory(deps.tui, deps.getContextLedger, {
108
109
  bus: deps.bus,
109
110
  chat: deps.contextChat,
111
+ getWorkingSet: () => {
112
+ const readSessionEntries = deps.readSessionEntries;
113
+ if (!readSessionEntries) return null;
114
+ return foldWorkingSet(readSessionEntries(), deps.getSessionMeta()?.pinnedLeafTurnId ?? undefined);
115
+ },
110
116
  });
111
117
  deps.requestRender();
112
118
  };
@@ -3,9 +3,10 @@ import type { SessionContract, SessionEntry } from "../domains/session/index.js"
3
3
  import type { TUI } from "../engine/tui.js";
4
4
  import type { ChatLoop } from "./chat-loop.js";
5
5
  import type { ChatPanel } from "./chat-panel.js";
6
- import { buildReplayAgentMessagesFromTurns, rehydrateChatPanelFromTurns } from "./chat-renderer.js";
6
+ import { rehydrateChatPanelFromTurns } from "./chat-renderer.js";
7
7
  import { emitCommandNotice } from "./command-fallbacks.js";
8
8
  import type { InteractiveNoticeLevel } from "./interactive-subscriptions.js";
9
+ import { buildModelReplayAgentMessagesFromTurns } from "./model-session-replay.js";
9
10
  import type { OverlayTransitions } from "./overlay-transitions.js";
10
11
  import { openCwdFallbackOverlay } from "./overlays/cwd-fallback.js";
11
12
  import { openMessagePickerOverlay } from "./overlays/message-picker.js";
@@ -159,7 +160,7 @@ export function createOverlaySessionLifecycle(deps: OverlaySessionLifecycleDeps)
159
160
  const replayOptions = leafTurnId ? { activeLeafTurnId: leafTurnId } : {};
160
161
  deps.resetTranscript();
161
162
  rehydrateChatPanelFromTurns(deps.chatPanel, turns, replayOptions);
162
- const replayMessages = buildReplayAgentMessagesFromTurns(turns, replayOptions);
163
+ const replayMessages = buildModelReplayAgentMessagesFromTurns(turns, replayOptions);
163
164
  deps.chat.resetForSession(leafTurnId, replayMessages);
164
165
  rescopeToBranch(session, turns, leafTurnId);
165
166
  } catch (error) {
@@ -209,7 +210,7 @@ export function createOverlaySessionLifecycle(deps: OverlaySessionLifecycleDeps)
209
210
  const turns = deps.readStructuredEntries(sessionId);
210
211
  deps.resetTranscript();
211
212
  rehydrateChatPanelFromTurns(deps.chatPanel, turns, { uptoTurnId: turnId });
212
- const replayMessages = buildReplayAgentMessagesFromTurns(turns, { uptoTurnId: turnId });
213
+ const replayMessages = buildModelReplayAgentMessagesFromTurns(turns, { uptoTurnId: turnId });
213
214
  deps.chat.resetForSession(turnId, replayMessages);
214
215
  // The same branch the transcript above was just scoped to. Without the
215
216
  // leaf, /cost, the footer Σ, and the last-turn line kept reporting the
@@ -287,8 +288,11 @@ export function createOverlaySessionLifecycle(deps: OverlaySessionLifecycleDeps)
287
288
  try {
288
289
  const turns = deps.readStructuredEntries(forkedSessionId);
289
290
  rehydrateChatPanelFromTurns(deps.chatPanel, turns);
290
- const replayMessages = buildReplayAgentMessagesFromTurns(turns);
291
291
  const leafTurnId = session.tree(forkedSessionId).leafId ?? parentTurnId;
292
+ const replayMessages = buildModelReplayAgentMessagesFromTurns(
293
+ turns,
294
+ leafTurnId ? { activeLeafTurnId: leafTurnId } : {},
295
+ );
292
296
  deps.chat.resetForSession(leafTurnId, replayMessages);
293
297
  rescopeToBranch(session, turns, leafTurnId);
294
298
  } catch (error) {
@@ -13,8 +13,9 @@ import {
13
13
  } from "../../engine/tui.js";
14
14
  import type { AskUserAnswer, AskUserQuestion, AskUserResult } from "../../tools/ask-user.js";
15
15
  import { ASK_USER_OTHER_LABEL, cancelledAskUserResult } from "../../tools/ask-user.js";
16
- import { buildHint, DEFAULT_SELECT_THEME, showClioOverlayFrame } from "../overlay-frame.js";
16
+ import { buildHint, DEFAULT_SELECT_THEME, type HintEntry, showClioOverlayFrame } from "../overlay-frame.js";
17
17
  import { type ClioToken, clioTheme, dotSep, GLYPH, screenTitle } from "../theme/index.js";
18
+ import { nextContentScrollOffset, type ViewScrollAction } from "../view/view-overlay.js";
18
19
 
19
20
  /**
20
21
  * The border and title token while a decision is pending.
@@ -54,7 +55,7 @@ export const ASK_USER_DEFAULT_SURFACE: AskUserSurface = "panel";
54
55
  interface AskUserSurfaceGeometry {
55
56
  /** Box width in columns; zero fills the row. The frame clamps to the terminal. */
56
57
  width: number;
57
- /** Rows the body may use, before the borders. */
58
+ /** Rows the body may use, before the borders. Zero means every row the frame has. */
58
59
  maxInnerRows: number;
59
60
  anchor: NonNullable<OverlayOptions["anchor"]>;
60
61
  margin: NonNullable<OverlayOptions["margin"]>;
@@ -74,12 +75,18 @@ interface AskUserSurfaceGeometry {
74
75
  export const ASK_USER_SURFACE_GEOMETRY: Record<AskUserSurface, AskUserSurfaceGeometry> = {
75
76
  compact: { width: 68, maxInnerRows: 10, anchor: "bottom-center", margin: { top: 1, right: 2, bottom: 1, left: 2 } },
76
77
  panel: { width: 88, maxInnerRows: 18, anchor: "center", margin: 1 },
77
- interview: { width: 0, maxInnerRows: 42, anchor: "center", margin: 1 },
78
+ // The interview is a workspace, not a modal. A cap of 42 turned a 66-row
79
+ // terminal into a centered box with twenty blank rows around it, so the
80
+ // interview takes every row the frame has and lays its own chrome out inside
81
+ // them. The one-row margin keeps the border off the top row and the footer.
82
+ interview: { width: 0, maxInnerRows: 0, anchor: "center", margin: 1 },
78
83
  };
79
84
 
80
85
  const ASK_USER_FRAME_ROWS = 2;
81
86
  const ASK_USER_FRAME_AND_MARGIN_ROWS = 4;
82
87
  const MIN_VISIBLE_OPTIONS = 3;
88
+ /** Interview rows spent on chrome before an option row: strip, header, blanks, status, one question row. */
89
+ const INTERVIEW_CHROME_ROWS = 7;
83
90
  const MAX_VISIBLE_OPTIONS = 12;
84
91
  const ELLIPSIS = "…";
85
92
 
@@ -227,6 +234,14 @@ class AskUserOverlayView implements Component {
227
234
  private list: SelectList | null = null;
228
235
  private input: Input | null = null;
229
236
  private resolveCurrent: ((result: AskUserResult) => void) | null = null;
237
+ /** Rows of the interview's scrollable middle already scrolled past. */
238
+ private contentScrollOffset = 0;
239
+ /** Whether the last interview render had more content than the region held. */
240
+ private contentOverflows = false;
241
+ /** Rows the scroll region drew on the last render, for a page-sized jump. */
242
+ private contentRegionHeight = 1;
243
+ /** Rows the scroll region wanted on the last render. */
244
+ private contentTotalRows = 0;
230
245
 
231
246
  constructor(private readonly deps: AskUserOverlayViewDeps) {}
232
247
 
@@ -240,6 +255,7 @@ class AskUserOverlayView implements Component {
240
255
  this.states = this.questions.map((question) => createQuestionState(question));
241
256
  this.list = null;
242
257
  this.input = null;
258
+ this.resetContentScroll();
243
259
  // Before the controls, because how many option rows fit is a property of
244
260
  // the box this round is about to be drawn in.
245
261
  this.setSurface(askUserSurface(this.questions, this.history.length));
@@ -283,6 +299,10 @@ class AskUserOverlayView implements Component {
283
299
  const state = this.currentState();
284
300
  if (!question || !state) return;
285
301
 
302
+ // Scrolling the transcript must not cost the operator their place in the
303
+ // options, so the region has keys of its own that neither control claims.
304
+ if (this.handleScrollInput(data, state.mode)) return;
305
+
286
306
  if (state.mode === "text") {
287
307
  if (this.isTextModePreviousKey(data)) {
288
308
  this.goToRelativeQuestion(-1);
@@ -331,21 +351,106 @@ class AskUserOverlayView implements Component {
331
351
  const question = this.currentQuestion();
332
352
  if (!question) return [clioTheme().fg("muted", "No questions.")];
333
353
 
334
- const interview = this.surface === "interview";
354
+ if (this.surface === "interview") return this.renderInterview(question, safeWidth, maxRows);
355
+
356
+ this.contentOverflows = false;
335
357
  const controlLines = this.renderControlLines(safeWidth);
336
- const ledgerLines = interview ? this.renderAnswerLedger(safeWidth) : [];
337
358
  const statusLines = this.status.length > 0 ? ["", fitLine(clioTheme().fg("dim", this.status), safeWidth)] : [];
338
- const stripLines = interview ? [this.renderQuestionStrip(safeWidth), ""] : [];
339
359
  const headerLine = this.renderQuestionHeader(question, safeWidth);
340
360
  const headerLines = headerLine.length > 0 ? [headerLine] : [];
341
- const spent =
342
- stripLines.length + headerLines.length + statusLines.length + 1 + controlLines.length + ledgerLines.length;
361
+ const spent = headerLines.length + statusLines.length + 1 + controlLines.length;
343
362
  const questionLines = wrapTextWithAnsi(question.question, safeWidth).slice(0, Math.max(1, maxRows - spent));
344
363
 
345
- return [...stripLines, ...headerLines, ...questionLines, ...statusLines, "", ...controlLines, ...ledgerLines].slice(
346
- 0,
347
- maxRows,
364
+ return [...headerLines, ...questionLines, ...statusLines, "", ...controlLines].slice(0, maxRows);
365
+ }
366
+
367
+ /**
368
+ * The interview, as a workspace with fixed chrome and a scrolling middle.
369
+ *
370
+ * The round strip and the question header hold the top; the status line and
371
+ * the active control hold the bottom, at their full row cost, so the thing the
372
+ * operator has to act on is never the thing that gets clipped. The question
373
+ * text and the answer ledger share whatever is left and scroll through it.
374
+ * Everything is re-derived per render, so a resize is just the next frame.
375
+ */
376
+ private renderInterview(question: AskUserQuestion, width: number, maxRows: number): string[] {
377
+ const theme = clioTheme();
378
+ const controlLines = this.renderControlLines(width);
379
+ const statusLines = this.status.length > 0 ? ["", fitLine(theme.fg("dim", this.status), width)] : [];
380
+ const headerLine = this.renderQuestionHeader(question, width);
381
+ const headerLines = headerLine.length > 0 ? [headerLine] : [];
382
+ const stripLines = [this.renderQuestionStrip(width), ""];
383
+ const content = [...wrapTextWithAnsi(question.question, width), ...this.renderAnswerLedger(width)];
384
+
385
+ // Chrome sheds from the outside in when the terminal is too short to carry
386
+ // all of it: the round strip first, then the header, then the status, and
387
+ // the control only if a terminal is short enough that nothing else is left.
388
+ let top = [...stripLines, ...headerLines];
389
+ let bottom = [...statusLines, "", ...controlLines];
390
+ const region = (): number => maxRows - top.length - bottom.length;
391
+ if (region() < 1) top = [...headerLines];
392
+ if (region() < 1) top = [];
393
+ if (region() < 1) bottom = ["", ...controlLines];
394
+ if (region() < 1) bottom = [...controlLines];
395
+ const regionRows = Math.max(1, region());
396
+
397
+ const overflows = content.length > regionRows;
398
+ this.contentOverflows = overflows;
399
+ this.contentTotalRows = content.length;
400
+ // One row of the region pays for the indicator when there is more than the
401
+ // region holds, so the marker never covers a row it is reporting on.
402
+ const viewRows = overflows ? Math.max(1, regionRows - 1) : regionRows;
403
+ this.contentRegionHeight = viewRows;
404
+ this.contentScrollOffset = Math.max(0, Math.min(this.contentScrollOffset, Math.max(0, content.length - viewRows)));
405
+
406
+ const visible = content.slice(this.contentScrollOffset, this.contentScrollOffset + viewRows);
407
+ const middle = [...visible];
408
+ if (overflows) middle.push(fitLine(theme.fg("dim", this.scrollIndicator(content.length, viewRows)), width));
409
+ while (middle.length < regionRows) middle.push("");
410
+
411
+ return [...top, ...middle, ...bottom].slice(0, maxRows);
412
+ }
413
+
414
+ private scrollIndicator(total: number, viewRows: number): string {
415
+ const above = this.contentScrollOffset;
416
+ const below = Math.max(0, total - viewRows - above);
417
+ const parts: string[] = [];
418
+ if (above > 0) parts.push(`↑ ${above} more`);
419
+ if (below > 0) parts.push(`↓ ${below} more`);
420
+ parts.push(`(${Math.min(total, above + viewRows)}/${total})`);
421
+ return parts.join(" ");
422
+ }
423
+
424
+ /** True when the key belonged to the scroll region rather than to a control. */
425
+ private handleScrollInput(data: string, mode: Mode): boolean {
426
+ if (this.surface !== "interview") return false;
427
+ const action = this.scrollAction(data, mode);
428
+ if (!action) return false;
429
+ const next = nextContentScrollOffset(
430
+ this.contentScrollOffset,
431
+ this.contentTotalRows,
432
+ this.contentRegionHeight,
433
+ action,
348
434
  );
435
+ if (next !== this.contentScrollOffset) {
436
+ this.contentScrollOffset = next;
437
+ this.deps.requestRender();
438
+ }
439
+ return true;
440
+ }
441
+
442
+ private scrollAction(data: string, mode: Mode): ViewScrollAction | null {
443
+ if (matchesKey(data, "pageUp")) return "page-up";
444
+ if (matchesKey(data, "pageDown")) return "page-down";
445
+ // Ctrl+U and Ctrl+D are line-kill and half-page in a text field, so they
446
+ // stay with the input while one is focused.
447
+ if (mode === "select" && matchesKey(data, "ctrl+u")) return "half-up";
448
+ if (mode === "select" && matchesKey(data, "ctrl+d")) return "half-down";
449
+ return null;
450
+ }
451
+
452
+ private resetContentScroll(): void {
453
+ this.contentScrollOffset = 0;
349
454
  }
350
455
 
351
456
  footerHint(): string {
@@ -357,11 +462,11 @@ class AskUserOverlayView implements Component {
357
462
  if (!question || !state) return buildHint([]);
358
463
  if (state.mode === "text") {
359
464
  return this.questions.length > 1
360
- ? buildHint([
465
+ ? this.withScrollHint([
361
466
  { key: "Enter", verb: "submit" },
362
467
  { key: "Alt+Left/Right", verb: "question" },
363
468
  ])
364
- : buildHint([{ key: "Enter", verb: "submit" }]);
469
+ : this.withScrollHint([{ key: "Enter", verb: "submit" }]);
365
470
  }
366
471
  // `accept` rather than `select` or `commit`: the footer of a decision prompt
367
472
  // has one job, which is to name the key that answers it. Esc keeps the
@@ -369,22 +474,32 @@ class AskUserOverlayView implements Component {
369
474
  // interview together.
370
475
  if (question.multi_select === true) {
371
476
  return this.questions.length > 1
372
- ? buildHint([
477
+ ? this.withScrollHint([
373
478
  { key: "Left/Right", verb: "question" },
374
479
  { key: "Space", verb: "toggle" },
375
480
  { key: "Enter", verb: "accept" },
376
481
  ])
377
- : buildHint([
482
+ : this.withScrollHint([
378
483
  { key: "Space", verb: "toggle" },
379
484
  { key: "Enter", verb: "accept" },
380
485
  ]);
381
486
  }
382
487
  return this.questions.length > 1
383
- ? buildHint([
488
+ ? this.withScrollHint([
384
489
  { key: "Left/Right", verb: "question" },
385
490
  { key: "Enter", verb: "accept" },
386
491
  ])
387
- : buildHint([{ key: "Enter", verb: "accept" }]);
492
+ : this.withScrollHint([{ key: "Enter", verb: "accept" }]);
493
+ }
494
+
495
+ /**
496
+ * The keys for this question, plus the scroll keys when there is something to
497
+ * scroll. A footer that always advertised PgUp would be advertising a key that
498
+ * does nothing on most rounds.
499
+ */
500
+ private withScrollHint(entries: ReadonlyArray<HintEntry>): string {
501
+ if (this.surface !== "interview" || !this.contentOverflows) return buildHint(entries);
502
+ return buildHint([...entries, { key: "PgUp/PgDn", verb: "scroll" }]);
388
503
  }
389
504
 
390
505
  private setSurface(next: AskUserSurface): void {
@@ -443,17 +558,20 @@ class AskUserOverlayView implements Component {
443
558
  private maxInnerRows(): number {
444
559
  const cap = ASK_USER_SURFACE_GEOMETRY[this.surface].maxInnerRows;
445
560
  const rows = this.deps.getTerminalRows();
446
- if (!Number.isFinite(rows) || rows <= 0) return cap;
447
- return Math.max(1, Math.min(cap, Math.floor(rows) - ASK_USER_FRAME_AND_MARGIN_ROWS));
561
+ // A zero cap is the interview's "every row the frame has", which is the
562
+ // same number the frame budgets itself when the mount passes no maxHeight.
563
+ if (!Number.isFinite(rows) || rows <= 0) return cap > 0 ? cap : 1;
564
+ const available = Math.max(1, Math.floor(rows) - ASK_USER_FRAME_AND_MARGIN_ROWS);
565
+ return cap > 0 ? Math.min(cap, available) : available;
448
566
  }
449
567
 
450
568
  private maxVisibleOptions(): number {
451
569
  const inner = this.maxInnerRows();
452
- // The interview surface spends rows on the round strip and the ledger
453
- // before an option ever renders. The other two spend them on a header, a
454
- // question, and a separator, so the options get nearly everything.
570
+ // The interview's option list is budgeted off the live row count rather
571
+ // than a fixed heuristic: the strip, header, blank, status, blank and one
572
+ // row of question are what the control shares the workspace with.
455
573
  if (this.surface === "interview") {
456
- return Math.max(MIN_VISIBLE_OPTIONS, Math.min(MAX_VISIBLE_OPTIONS, inner - 12));
574
+ return Math.max(MIN_VISIBLE_OPTIONS, Math.min(MAX_VISIBLE_OPTIONS, inner - INTERVIEW_CHROME_ROWS));
457
575
  }
458
576
  return Math.max(1, Math.min(MAX_VISIBLE_OPTIONS, inner - 4));
459
577
  }
@@ -809,6 +927,7 @@ class AskUserOverlayView implements Component {
809
927
  const next = this.nextUnansweredIndex();
810
928
  if (next !== null) {
811
929
  this.index = next;
930
+ this.resetContentScroll();
812
931
  const nextState = this.currentState();
813
932
  const nextQuestion = this.currentQuestion();
814
933
  if (nextState && nextQuestion && !nextState.answer.trim()) nextState.mode = initialMode(nextQuestion);
@@ -844,6 +963,7 @@ class AskUserOverlayView implements Component {
844
963
  this.syncActiveControl();
845
964
  this.index = (this.index + delta + this.questions.length) % this.questions.length;
846
965
  this.status = "";
966
+ this.resetContentScroll();
847
967
  this.rebuildControl();
848
968
  this.deps.requestRender();
849
969
  }
@@ -888,7 +1008,9 @@ export function openAskUserOverlay(tui: TUI, deps: OpenAskUserOverlayDeps): AskU
888
1008
  handle = showClioOverlayFrame(tui, view, {
889
1009
  anchor: geometry.anchor,
890
1010
  width: geometry.width,
891
- maxHeight: geometry.maxInnerRows + ASK_USER_FRAME_ROWS,
1011
+ // A zero cap means the frame keeps its own budget, which is every row
1012
+ // the terminal has left after the margins.
1013
+ ...(geometry.maxInnerRows > 0 ? { maxHeight: geometry.maxInnerRows + ASK_USER_FRAME_ROWS } : {}),
892
1014
  margin: geometry.margin,
893
1015
  title: () => (view.isDecisionPending() ? ASK_USER_DECISION_TITLE : ASK_USER_WAITING_TITLE),
894
1016
  tone: () => (view.isDecisionPending() ? ASK_USER_DECISION_TONE : undefined),