@causyne/agent 0.0.0-stage → 1.10.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 (274) hide show
  1. package/LICENSE +102 -0
  2. package/README.md +31 -2
  3. package/SECURITY.md +24 -0
  4. package/bin/causyne.js +13 -0
  5. package/docs/ARCHITECTURE.md +63 -0
  6. package/docs/PROVENANCE.md +29 -0
  7. package/docs/SECURITY_REVIEW.md +89 -0
  8. package/docs/THIRD_PARTY_NOTICES.md +49 -0
  9. package/native-skills/ai-engineering/context-window-management/SKILL.md +43 -0
  10. package/native-skills/ai-engineering/llm-evaluation/SKILL.md +46 -0
  11. package/native-skills/ai-engineering/model-cost-and-latency/SKILL.md +46 -0
  12. package/native-skills/ai-engineering/prompt-construction/SKILL.md +42 -0
  13. package/native-skills/ai-engineering/retrieval-augmented-generation/SKILL.md +42 -0
  14. package/native-skills/ai-engineering/structured-output-and-schemas/SKILL.md +45 -0
  15. package/native-skills/ai-engineering/tool-calling-agents/SKILL.md +43 -0
  16. package/native-skills/android/app-performance-profiling/SKILL.md +42 -0
  17. package/native-skills/android/background-work-scheduling/SKILL.md +47 -0
  18. package/native-skills/android/compose-state-and-recomposition/SKILL.md +43 -0
  19. package/native-skills/android/gradle-build-configuration/SKILL.md +41 -0
  20. package/native-skills/android/lifecycle-and-leaks/SKILL.md +46 -0
  21. package/native-skills/android/play-store-release/SKILL.md +43 -0
  22. package/native-skills/android/room-and-persistence/SKILL.md +40 -0
  23. package/native-skills/backend/authentication-and-sessions/SKILL.md +45 -0
  24. package/native-skills/backend/background-jobs-and-queues/SKILL.md +42 -0
  25. package/native-skills/backend/caching-strategy/SKILL.md +44 -0
  26. package/native-skills/backend/graceful-shutdown/SKILL.md +42 -0
  27. package/native-skills/backend/rate-limiting-and-quotas/SKILL.md +40 -0
  28. package/native-skills/backend/rest-api-design/SKILL.md +40 -0
  29. package/native-skills/backend/webhook-delivery-and-receipt/SKILL.md +43 -0
  30. package/native-skills/cloud/cost-optimisation/SKILL.md +46 -0
  31. package/native-skills/cloud/iam-least-privilege/SKILL.md +43 -0
  32. package/native-skills/cloud/infrastructure-as-code/SKILL.md +43 -0
  33. package/native-skills/cloud/multi-region-and-failover/SKILL.md +43 -0
  34. package/native-skills/cloud/serverless-patterns/SKILL.md +47 -0
  35. package/native-skills/data/data-quality-monitoring/SKILL.md +51 -0
  36. package/native-skills/data/etl-pipeline-reliability/SKILL.md +43 -0
  37. package/native-skills/data/exploratory-analysis/SKILL.md +43 -0
  38. package/native-skills/data/metric-definition/SKILL.md +42 -0
  39. package/native-skills/data/notebook-to-production/SKILL.md +43 -0
  40. package/native-skills/database/backup-and-recovery/SKILL.md +43 -0
  41. package/native-skills/database/connection-pooling/SKILL.md +42 -0
  42. package/native-skills/database/data-integrity-and-constraints/SKILL.md +42 -0
  43. package/native-skills/database/query-optimisation/SKILL.md +38 -0
  44. package/native-skills/database/safe-migrations/SKILL.md +47 -0
  45. package/native-skills/database/schema-design/SKILL.md +38 -0
  46. package/native-skills/database/transactions-and-isolation/SKILL.md +41 -0
  47. package/native-skills/design/colour-system-construction/SKILL.md +38 -0
  48. package/native-skills/design/design-direction-brief/SKILL.md +41 -0
  49. package/native-skills/design/motion-and-transitions/SKILL.md +38 -0
  50. package/native-skills/design/responsive-strategy/SKILL.md +39 -0
  51. package/native-skills/design/spacing-and-rhythm/SKILL.md +38 -0
  52. package/native-skills/design/type-scale-and-hierarchy/SKILL.md +39 -0
  53. package/native-skills/design/visual-qa-loop/SKILL.md +42 -0
  54. package/native-skills/devops/docker-multi-stage-builds/SKILL.md +60 -0
  55. package/native-skills/devops/github-actions-ci/SKILL.md +60 -0
  56. package/native-skills/devops/incident-triage/SKILL.md +38 -0
  57. package/native-skills/devops/kubernetes-deployment-hardening/SKILL.md +64 -0
  58. package/native-skills/devops/observability-baseline/SKILL.md +38 -0
  59. package/native-skills/devops/secret-management/SKILL.md +40 -0
  60. package/native-skills/devops/terraform-state-and-modules/SKILL.md +36 -0
  61. package/native-skills/devops/zero-downtime-deploys/SKILL.md +40 -0
  62. package/native-skills/git/bisect-regression-hunt/SKILL.md +43 -0
  63. package/native-skills/git/branch-strategy/SKILL.md +39 -0
  64. package/native-skills/git/commit-hygiene/SKILL.md +41 -0
  65. package/native-skills/git/history-recovery/SKILL.md +44 -0
  66. package/native-skills/git/large-repository-surgery/SKILL.md +46 -0
  67. package/native-skills/git/merge-conflict-resolution/SKILL.md +42 -0
  68. package/native-skills/media/cinematic-3d-shot/SKILL.md +104 -0
  69. package/native-skills/mobile/app-startup-optimisation/SKILL.md +41 -0
  70. package/native-skills/mobile/cross-platform-tradeoffs/SKILL.md +42 -0
  71. package/native-skills/mobile/deep-linking/SKILL.md +39 -0
  72. package/native-skills/mobile/mobile-storage-security/SKILL.md +45 -0
  73. package/native-skills/mobile/offline-first-sync/SKILL.md +42 -0
  74. package/native-skills/mobile/push-notifications/SKILL.md +38 -0
  75. package/native-skills/performance/algorithmic-complexity/SKILL.md +43 -0
  76. package/native-skills/performance/concurrency-and-parallelism/SKILL.md +42 -0
  77. package/native-skills/performance/load-testing/SKILL.md +42 -0
  78. package/native-skills/performance/memory-and-leak-hunting/SKILL.md +41 -0
  79. package/native-skills/performance/network-and-payload-efficiency/SKILL.md +42 -0
  80. package/native-skills/performance/profiling-methodology/SKILL.md +46 -0
  81. package/native-skills/python/async-correctness/SKILL.md +40 -0
  82. package/native-skills/python/cli-tool-construction/SKILL.md +44 -0
  83. package/native-skills/python/data-processing-pipelines/SKILL.md +38 -0
  84. package/native-skills/python/environment-isolation/SKILL.md +41 -0
  85. package/native-skills/python/packaging-and-distribution/SKILL.md +56 -0
  86. package/native-skills/python/pytest-suite-design/SKILL.md +39 -0
  87. package/native-skills/python/type-hints-and-static-checking/SKILL.md +42 -0
  88. package/native-skills/security/authorization-model/SKILL.md +42 -0
  89. package/native-skills/security/crypto-usage/SKILL.md +48 -0
  90. package/native-skills/security/dependency-supply-chain/SKILL.md +42 -0
  91. package/native-skills/security/input-validation-and-injection/SKILL.md +44 -0
  92. package/native-skills/security/secure-file-uploads/SKILL.md +46 -0
  93. package/native-skills/security/security-headers-and-csp/SKILL.md +44 -0
  94. package/native-skills/security/threat-modelling/SKILL.md +46 -0
  95. package/native-skills/testing/end-to-end-test-stability/SKILL.md +42 -0
  96. package/native-skills/testing/property-based-testing/SKILL.md +46 -0
  97. package/native-skills/testing/regression-test-from-a-bug/SKILL.md +41 -0
  98. package/native-skills/testing/snapshot-and-visual-regression/SKILL.md +42 -0
  99. package/native-skills/testing/test-doubles-and-mocking/SKILL.md +43 -0
  100. package/native-skills/testing/test-strategy-and-pyramid/SKILL.md +39 -0
  101. package/native-skills/testing/writing-good-assertions/SKILL.md +42 -0
  102. package/native-skills/web/accessible-components/SKILL.md +45 -0
  103. package/native-skills/web/api-client-resilience/SKILL.md +39 -0
  104. package/native-skills/web/bundle-size-reduction/SKILL.md +38 -0
  105. package/native-skills/web/core-web-vitals/SKILL.md +40 -0
  106. package/native-skills/web/css-layout-systems/SKILL.md +41 -0
  107. package/native-skills/web/form-validation-ux/SKILL.md +39 -0
  108. package/native-skills/web/react-state-architecture/SKILL.md +41 -0
  109. package/native-skills/web/ssr-hydration-correctness/SKILL.md +45 -0
  110. package/package.json +68 -4
  111. package/src/agentLoop.js +2458 -0
  112. package/src/appChannel.js +109 -0
  113. package/src/approvalGrants.js +66 -0
  114. package/src/auditLog.js +52 -0
  115. package/src/autoUpdate.js +12 -0
  116. package/src/capabilities.js +36 -0
  117. package/src/checkpoints.js +1199 -0
  118. package/src/cliMode.js +638 -0
  119. package/src/commands.js +805 -0
  120. package/src/compiledSkills.js +540 -0
  121. package/src/config.js +130 -0
  122. package/src/contextManager.js +498 -0
  123. package/src/cortex/autoCondenser.js +192 -0
  124. package/src/cortex/provisioner.js +27 -0
  125. package/src/cortex/vectorDb.js +447 -0
  126. package/src/designPalettes.js +99 -0
  127. package/src/devMode.js +6 -0
  128. package/src/embodimentSession.js +180 -0
  129. package/src/entrypoint.js +194 -0
  130. package/src/execSafe.js +21 -0
  131. package/src/gitSnapshot.js +378 -0
  132. package/src/mcp/jsonRpc.js +165 -0
  133. package/src/mcp/mcpClient.js +136 -0
  134. package/src/mcp/mcpRegistry.js +207 -0
  135. package/src/memory.js +62 -0
  136. package/src/memoryManager.js +124 -0
  137. package/src/modelCatalogue.js +132 -0
  138. package/src/modelIds.js +42 -0
  139. package/src/modelRouter.js +145 -0
  140. package/src/outputStore.js +246 -0
  141. package/src/perception.js +124 -0
  142. package/src/planCache.js +437 -0
  143. package/src/procSignal.js +32 -0
  144. package/src/providers/anthropic.js +408 -0
  145. package/src/providers/base.js +328 -0
  146. package/src/providers/dashscope-byok.js +86 -0
  147. package/src/providers/deepseek.js +80 -0
  148. package/src/providers/factory.js +37 -0
  149. package/src/providers/gemini.js +346 -0
  150. package/src/providers/moonshot.js +73 -0
  151. package/src/providers/muse.js +169 -0
  152. package/src/providers/ollama.js +104 -0
  153. package/src/providers/openai-compatible.js +84 -0
  154. package/src/providers/openai.js +20 -0
  155. package/src/providers/qwen-byok.js +76 -0
  156. package/src/providers/xai.js +73 -0
  157. package/src/provisioning/capabilityConsent.js +24 -0
  158. package/src/provisioning/capabilityProvisioner.js +271 -0
  159. package/src/provisioning/capabilityTools.js +29 -0
  160. package/src/reporting.js +373 -0
  161. package/src/sandbox.js +529 -0
  162. package/src/sessionStore.js +279 -0
  163. package/src/skillsManager.js +288 -0
  164. package/src/slopDetector.js +307 -0
  165. package/src/taskBuffer.js +45 -0
  166. package/src/taskShape.js +241 -0
  167. package/src/textDiff.js +150 -0
  168. package/src/tokenTracker.js +560 -0
  169. package/src/tool-scripts/ax_snapshot.applescript +39 -0
  170. package/src/tool-scripts/ax_snapshot.ps1 +10 -0
  171. package/src/tool-scripts/desktop_act.applescript +58 -0
  172. package/src/tool-scripts/resolve_automator.py +119 -0
  173. package/src/toolPacks.js +524 -0
  174. package/src/tools/archive.js +63 -0
  175. package/src/tools/astryxOracle.js +78 -0
  176. package/src/tools/axSnapshot.js +165 -0
  177. package/src/tools/blenderAutomator.js +349 -0
  178. package/src/tools/browserAutomator.js +252 -0
  179. package/src/tools/checkSystemSpecs.js +64 -0
  180. package/src/tools/clipboard.js +36 -0
  181. package/src/tools/codeSearchAst.js +101 -0
  182. package/src/tools/codeSearcher.js +230 -0
  183. package/src/tools/codeTools.js +262 -0
  184. package/src/tools/codebaseSearch.js +75 -0
  185. package/src/tools/createSkill.js +129 -0
  186. package/src/tools/deferredMcpSearch.js +246 -0
  187. package/src/tools/desktopAct.js +208 -0
  188. package/src/tools/desktopOpen.js +34 -0
  189. package/src/tools/dynamicLoader.js +44 -0
  190. package/src/tools/executeProceduralMedia.js +93 -0
  191. package/src/tools/fsDelete.js +51 -0
  192. package/src/tools/fsEdit.js +105 -0
  193. package/src/tools/fsFind.js +128 -0
  194. package/src/tools/fsList.js +77 -0
  195. package/src/tools/fsMove.js +57 -0
  196. package/src/tools/fsRead.js +87 -0
  197. package/src/tools/fsSearch.js +198 -0
  198. package/src/tools/fsWrite.js +41 -0
  199. package/src/tools/gameAiBehaviorTree.js +208 -0
  200. package/src/tools/gameAnimationRig.js +194 -0
  201. package/src/tools/gameAssetFetcher.js +372 -0
  202. package/src/tools/gameAssetHarvester.js +210 -0
  203. package/src/tools/gameAudioSynth.js +267 -0
  204. package/src/tools/gameBuilder.js +886 -0
  205. package/src/tools/gameCanvasAudit.js +101 -0
  206. package/src/tools/gameCinematicSequencer.js +151 -0
  207. package/src/tools/gameDataForge.js +124 -0
  208. package/src/tools/gameDialogueTreeGen.js +166 -0
  209. package/src/tools/gameEconomyBalancer.js +161 -0
  210. package/src/tools/gameEntitySpawner.js +207 -0
  211. package/src/tools/gameLoreForge.js +153 -0
  212. package/src/tools/gameQuestArcBuilder.js +152 -0
  213. package/src/tools/gameSceneBuilder.js +158 -0
  214. package/src/tools/gameShaderForge.js +181 -0
  215. package/src/tools/gameTilemapGenerator.js +164 -0
  216. package/src/tools/gameUiHudBuilder.js +292 -0
  217. package/src/tools/gameVfxEmitterGen.js +108 -0
  218. package/src/tools/gameWebRuntimeInspector.js +91 -0
  219. package/src/tools/generateMediaViaApi.js +358 -0
  220. package/src/tools/gitExec.js +54 -0
  221. package/src/tools/godotAutomator.js +437 -0
  222. package/src/tools/manageLocalAiDependencies.js +220 -0
  223. package/src/tools/memoryWrite.js +99 -0
  224. package/src/tools/multiEdit.js +107 -0
  225. package/src/tools/palmierAutomator.js +104 -0
  226. package/src/tools/pdfReader.js +112 -0
  227. package/src/tools/persistentShell.js +148 -0
  228. package/src/tools/printer.js +316 -0
  229. package/src/tools/procTerm.js +91 -0
  230. package/src/tools/processList.js +52 -0
  231. package/src/tools/readNativeSkill.js +39 -0
  232. package/src/tools/registry.js +277 -0
  233. package/src/tools/resolveAutomator.js +102 -0
  234. package/src/tools/screenshot.js +75 -0
  235. package/src/tools/subagentOrchestrator.js +305 -0
  236. package/src/tools/sysRun.js +125 -0
  237. package/src/tools/todoWrite.js +75 -0
  238. package/src/tools/toolDispatch.js +127 -0
  239. package/src/tools/toolForge.js +124 -0
  240. package/src/tools/updatePlan.js +88 -0
  241. package/src/tools/viewImage.js +82 -0
  242. package/src/tools/visionInspector.js +154 -0
  243. package/src/tools/webFetch.js +74 -0
  244. package/src/tools/webSearch.js +82 -0
  245. package/src/tui/ask.js +164 -0
  246. package/src/tui/banner.js +158 -0
  247. package/src/tui/block.js +152 -0
  248. package/src/tui/chatRenderer.js +953 -0
  249. package/src/tui/completion.js +424 -0
  250. package/src/tui/dialogs.js +606 -0
  251. package/src/tui/diff.js +296 -0
  252. package/src/tui/history.js +135 -0
  253. package/src/tui/home.js +217 -0
  254. package/src/tui/invocation.js +105 -0
  255. package/src/tui/keys.js +443 -0
  256. package/src/tui/lineEditor.js +256 -0
  257. package/src/tui/overlay.js +256 -0
  258. package/src/tui/prompt.js +1258 -0
  259. package/src/tui/selectList.js +335 -0
  260. package/src/tui/stage.js +330 -0
  261. package/src/tui/stageSurface.js +609 -0
  262. package/src/tui/statusLine.js +254 -0
  263. package/src/tui/text.js +345 -0
  264. package/src/tui/theme.js +393 -0
  265. package/src/tui/toolDisplay.js +176 -0
  266. package/src/tui/transcript.js +235 -0
  267. package/src/tui/viewport.js +337 -0
  268. package/src/uncertainty.js +271 -0
  269. package/src/userSkills.js +265 -0
  270. package/src/utils/cli_ui.js +0 -0
  271. package/src/utils/inquirer.js +12 -0
  272. package/src/utils/paths.js +37 -0
  273. package/src/version.js +38 -0
  274. package/src/vfeTracker.js +220 -0
@@ -0,0 +1,235 @@
1
+ // The retained transcript — the model the Stage scrolls over.
2
+ //
3
+ // ChatRenderer was a pure formatter into stdout: every transcript row was
4
+ // `stdout.write(line + '\n')` and nothing was kept. That is exactly right for a
5
+ // scrollback surface, where the terminal IS the model — and it makes a viewport
6
+ // impossible, because a viewport has to answer "what are rows 400..440 at 96
7
+ // columns" long after those rows were produced.
8
+ //
9
+ // FRAMES, NOT ROWS. A frame is (kind, produce(width) -> string[]). Storing the
10
+ // rendered rows instead would mean a resize could not reflow: that is the
11
+ // failure opencode's --mini mode actually has, where the only way to handle a
12
+ // resize is to clear the saved lines and re-emit the entire transcript
13
+ // (packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts:373-398). Storing the
14
+ // producer makes a resize a re-render, which is both cheaper and correct.
15
+ //
16
+ // The producer must be a pure function of width. That is a real constraint on
17
+ // the caller: every value that depends on wall-clock time or on renderer state
18
+ // (a step number, an elapsed duration, the model name at the time) has to be
19
+ // resolved BEFORE the closure is built, not read from inside it. ChatRenderer
20
+ // separates its state mutations from its rendering for exactly this reason.
21
+ //
22
+ // Pure JS over nothing at all. No dependency, no shell.
23
+
24
+ // Frames kept re-renderable. A frame is small — a closure plus the message it
25
+ // closed over — so this is a bound on pathological sessions, not a working
26
+ // limit. Overflow is not lost: it goes to onEvict, which the Stage wires to
27
+ // real terminal scrollback, so eviction degrades to today's behaviour instead
28
+ // of dropping history.
29
+ export const MAX_FRAMES = 4000;
30
+
31
+ // A frame kind is a semantic label, not a style. The rail (and, later, the
32
+ // jump bindings) index on it, so it must survive a change of theme.
33
+ export const KINDS = Object.freeze([
34
+ 'session', 'user', 'assistant', 'reasoning', 'plan', 'step', 'result',
35
+ 'approval', 'notice', 'error', 'skill', 'turn', 'checkpoint'
36
+ ]);
37
+
38
+ function normalizeRows(produced) {
39
+ if (!Array.isArray(produced)) return [];
40
+ const rows = [];
41
+ for (const row of produced) {
42
+ const text = row == null ? '' : String(row);
43
+ // A frame may never contain a newline: the Stage paints with relative
44
+ // cursor motion only, and one embedded '\n' would scroll the terminal out
45
+ // from under the whole block. Split rather than drop, so nothing is lost.
46
+ if (text.includes('\n')) rows.push(...text.split('\n'));
47
+ else rows.push(text);
48
+ }
49
+ return rows;
50
+ }
51
+
52
+ export class Transcript {
53
+ constructor({ maxFrames = MAX_FRAMES, onEvict = null, onChange = null } = {}) {
54
+ this.frames = [];
55
+ this.maxFrames = Math.max(1, maxFrames);
56
+ this.onEvict = typeof onEvict === 'function' ? onEvict : null;
57
+ // Fired after any structural change. In scrollback mode the renderer's
58
+ // onAfterWrite hook is what tells the prompt to repaint; in stage mode no
59
+ // byte reaches the terminal, so the model has to say so itself.
60
+ this.onChange = typeof onChange === 'function' ? onChange : null;
61
+
62
+ this.nextId = 1;
63
+ // Bumped on every structural change. The Stage compares it to decide
64
+ // whether a repaint is needed at all; opencode's renderer makes the same
65
+ // bet (a frame with nothing to say emits zero bytes).
66
+ this.revision = 0;
67
+ this.evictedFrames = 0;
68
+ this.evictedRows = 0;
69
+
70
+ // Flattened view, memoised per width.
71
+ this.cache = null;
72
+ // The width the most recent flatten used, so eviction can render a frame
73
+ // one last time on its way out.
74
+ this.lastWidth = 80;
75
+ }
76
+
77
+ get length() {
78
+ return this.frames.length;
79
+ }
80
+
81
+ isEmpty() {
82
+ return this.frames.length === 0;
83
+ }
84
+
85
+ /**
86
+ * Append one frame.
87
+ *
88
+ * `produce` is called lazily and may be called again at a different width.
89
+ * It must not mutate anything the caller will read later.
90
+ */
91
+ append({ kind = 'notice', produce, meta = null } = {}) {
92
+ if (typeof produce !== 'function') return null;
93
+ const frame = {
94
+ id: this.nextId++,
95
+ kind: KINDS.includes(kind) ? kind : 'notice',
96
+ ts: Date.now(),
97
+ meta,
98
+ produce,
99
+ rows: null,
100
+ renderedWidth: -1
101
+ };
102
+ this.frames.push(frame);
103
+ this.cache = null;
104
+ this.revision++;
105
+ this.evictOverflow();
106
+ this.changed();
107
+ return frame.id;
108
+ }
109
+
110
+ /**
111
+ * Replace the newest frame in place.
112
+ *
113
+ * Streaming needs this: the tail of an assistant reply is re-rendered as
114
+ * tokens arrive, and appending a frame per token would make the rail useless
115
+ * and the flatten quadratic.
116
+ */
117
+ replaceTail({ kind, produce, meta } = {}) {
118
+ const frame = this.frames[this.frames.length - 1];
119
+ if (!frame || typeof produce !== 'function') return this.append({ kind, produce, meta });
120
+ if (kind && KINDS.includes(kind)) frame.kind = kind;
121
+ if (meta !== undefined) frame.meta = meta;
122
+ frame.produce = produce;
123
+ frame.rows = null;
124
+ frame.renderedWidth = -1;
125
+ this.cache = null;
126
+ this.revision++;
127
+ this.changed();
128
+ return frame.id;
129
+ }
130
+
131
+ changed() {
132
+ if (!this.onChange) return;
133
+ // A listener that throws must not take the transcript down with it: the
134
+ // model is the session's memory and a repaint is only a picture of it.
135
+ try { this.onChange(this); } catch { /* the surface will catch up next frame */ }
136
+ }
137
+
138
+ evictOverflow() {
139
+ while (this.frames.length > this.maxFrames) {
140
+ const frame = this.frames.shift();
141
+ const rows = this.renderFrame(frame, this.lastWidth);
142
+ this.evictedFrames++;
143
+ this.evictedRows += rows.length;
144
+ this.cache = null;
145
+ // Eviction is not deletion. The Stage hands these to the terminal, where
146
+ // they become permanent scrollback exactly as they would have in the
147
+ // scrollback renderer.
148
+ if (this.onEvict) {
149
+ try { this.onEvict(rows, frame); } catch { /* a sink that throws must not kill the session */ }
150
+ }
151
+ }
152
+ }
153
+
154
+ renderFrame(frame, width) {
155
+ if (frame.rows && frame.renderedWidth === width) return frame.rows;
156
+ let produced = [];
157
+ try {
158
+ produced = frame.produce(width);
159
+ } catch {
160
+ // A formatter that throws must cost one frame, not the session. The frame
161
+ // stays in place so the rail and the row count remain stable.
162
+ produced = [];
163
+ }
164
+ frame.rows = normalizeRows(produced);
165
+ frame.renderedWidth = width;
166
+ return frame.rows;
167
+ }
168
+
169
+ /**
170
+ * Every row of the transcript at this width, plus the marker index.
171
+ *
172
+ * Memoised on (width, revision): a repaint at an unchanged width and an
173
+ * unchanged transcript costs one comparison.
174
+ */
175
+ flatten(width) {
176
+ const w = Math.max(1, Math.floor(width) || 1);
177
+ if (this.cache && this.cache.width === w && this.cache.revision === this.revision) {
178
+ return this.cache;
179
+ }
180
+ this.lastWidth = w;
181
+
182
+ const rows = [];
183
+ const markers = [];
184
+ for (const frame of this.frames) {
185
+ markers.push({ id: frame.id, kind: frame.kind, row: rows.length, meta: frame.meta, ts: frame.ts });
186
+ const produced = this.renderFrame(frame, w);
187
+ for (const row of produced) rows.push(row);
188
+ }
189
+
190
+ this.cache = { width: w, revision: this.revision, rows, markers };
191
+ return this.cache;
192
+ }
193
+
194
+ rows(width) {
195
+ return this.flatten(width).rows;
196
+ }
197
+
198
+ /**
199
+ * Frame start rows — what the rail draws and what [ / ] jump between.
200
+ */
201
+ markers(width) {
202
+ return this.flatten(width).markers;
203
+ }
204
+
205
+ height(width) {
206
+ return this.flatten(width).rows.length;
207
+ }
208
+
209
+ /**
210
+ * A window of rows, clamped. The viewport asks for exactly what it can show.
211
+ */
212
+ slice(width, start, count) {
213
+ const rows = this.flatten(width).rows;
214
+ const from = Math.max(0, Math.min(Math.floor(start) || 0, rows.length));
215
+ return rows.slice(from, from + Math.max(0, Math.floor(count) || 0));
216
+ }
217
+
218
+ /**
219
+ * Everything, for print-through on exit.
220
+ *
221
+ * This is the half opencode cannot have in either mode: its alternate-screen
222
+ * TUI leaves nothing behind, and its split-footer mode leaves everything but
223
+ * cannot scroll. Flushing the model on the way out gives both.
224
+ */
225
+ snapshot(width) {
226
+ return this.flatten(width).rows.slice();
227
+ }
228
+
229
+ clear() {
230
+ this.frames = [];
231
+ this.cache = null;
232
+ this.revision++;
233
+ this.changed();
234
+ }
235
+ }
@@ -0,0 +1,337 @@
1
+ // The scroll/follow state machine over a Transcript.
2
+ //
3
+ // This behavior is specified by the viewport contract: the wheel and the keyboard move
4
+ // the box, the terminal does not scroll, and while a task runs the view
5
+ // auto-scrolls — until you scroll up, at which point it stops and lets the
6
+ // content grow underneath you.
7
+ //
8
+ // THE ONE ALGORITHM WORTH PORTING VERBATIM, and the reason to port it rather
9
+ // than write the obvious version:
10
+ //
11
+ // The obvious version sets a "user scrolled away" flag when the user presses a
12
+ // key or turns the wheel. That is wrong in both directions — a programmatic
13
+ // scroll looks like user intent, and returning to the bottom by wheel does not
14
+ // clear it. @opentui/core's ScrollBox (ScrollBox.ts:224-242) instead DERIVES the
15
+ // flag from position on every scroll mutation:
16
+ //
17
+ // hasManualScroll = hasScrollableContent && !isAtStickyPosition()
18
+ //
19
+ // with a reentrancy guard around the auto-follow's own writes, so the follow
20
+ // logic cannot mistake itself for the user. Any scroll that leaves the bottom
21
+ // edge sets it; any scroll that lands on the bottom edge clears it; nobody has
22
+ // to enumerate the ways a scroll can happen. Re-engagement has a one-row
23
+ // tolerance (ScrollBox.ts:497-512), which is what makes "scroll back down and
24
+ // it starts following again" feel forgiving instead of pixel-precise.
25
+ //
26
+ // Wheel deltas: the wire always reports 1 (lib/parse.mouse.ts:166-171), so all
27
+ // speed lives here. Three rows per notch is opencode's default and it is
28
+ // genuinely the right number. The fractional accumulator is what allows a
29
+ // non-integer speed without drifting.
30
+ //
31
+ // Pure JS. No dependency, no shell.
32
+
33
+ // Rows per wheel notch. opencode's default, and its acceleration is opt-in for
34
+ // good reason: an accelerating wheel in a transcript overshoots the thing you
35
+ // were trying to read.
36
+ export const DEFAULT_SCROLL_SPEED = 3;
37
+
38
+ /**
39
+ * Opt-in wheel acceleration, the same shape opencode uses.
40
+ *
41
+ * (`@opentui/core` `lib/scroll-acceleration.ts:34-97`.) A streak of notches
42
+ * arriving close together multiplies the distance; a gap resets it. It stays
43
+ * OPT-IN because an accelerating wheel in a transcript overshoots the thing you
44
+ * were trying to read, which is worse than a wheel that is merely slow.
45
+ *
46
+ * `minTickInterval` is not a nicety: some terminals emit a wheel notch twice in
47
+ * the same few milliseconds, and counting both makes the curve run away.
48
+ */
49
+ export class ScrollAcceleration {
50
+ constructor({ history = 3, streakTimeout = 150, minTickInterval = 6 } = {}) {
51
+ this.historySize = history;
52
+ this.streakTimeout = streakTimeout;
53
+ this.minTickInterval = minTickInterval;
54
+ this.intervals = [];
55
+ this.lastTick = 0;
56
+ }
57
+
58
+ reset() {
59
+ this.intervals = [];
60
+ this.lastTick = 0;
61
+ }
62
+
63
+ tick(now = Date.now()) {
64
+ const A = 0.8;
65
+ const tau = 3;
66
+ const maxMultiplier = 6;
67
+
68
+ const gap = this.lastTick ? now - this.lastTick : Infinity;
69
+ if (gap === Infinity || gap > this.streakTimeout) {
70
+ this.lastTick = now;
71
+ this.intervals = [];
72
+ return 1;
73
+ }
74
+ if (gap < this.minTickInterval) return 1;
75
+
76
+ this.lastTick = now;
77
+ this.intervals.push(gap);
78
+ if (this.intervals.length > this.historySize) this.intervals.shift();
79
+
80
+ const average = this.intervals.reduce((sum, v) => sum + v, 0) / this.intervals.length;
81
+ const velocity = 100 / average;
82
+ return Math.min(1 + A * (Math.exp(velocity / tau) - 1), maxMultiplier);
83
+ }
84
+ }
85
+
86
+ // How close to the bottom counts as "back at the bottom" for the purposes of
87
+ // resuming follow.
88
+ const REENGAGE_TOLERANCE = 1;
89
+
90
+ export class Viewport {
91
+ constructor({ transcript, height = 20, width = 80, speed = DEFAULT_SCROLL_SPEED, acceleration = null } = {}) {
92
+ this.transcript = transcript;
93
+ this.viewHeight = Math.max(1, Math.floor(height));
94
+ this.viewWidth = Math.max(1, Math.floor(width));
95
+ this.speed = speed > 0 ? speed : DEFAULT_SCROLL_SPEED;
96
+ // null means a flat speed, which is the default and the right one.
97
+ this.acceleration = acceleration || null;
98
+
99
+ this.scrollTop = 0;
100
+ // Derived, never assigned from an event. See the header.
101
+ this.manual = false;
102
+ this.applyingSticky = false;
103
+ this.accumulator = 0;
104
+ // Rows that arrived while the user was reading somewhere else.
105
+ this.unseen = 0;
106
+ }
107
+
108
+ get height() { return this.viewHeight; }
109
+ get width() { return this.viewWidth; }
110
+
111
+ /**
112
+ * Geometry changed — a resize, or the input box growing under the viewport.
113
+ *
114
+ * Resizing must not silently unfollow: growing the viewport reduces
115
+ * maxScrollTop, which would leave scrollTop above it and read as "the user
116
+ * scrolled away". refresh() re-derives from the new geometry instead.
117
+ */
118
+ resize({ height, width } = {}) {
119
+ if (Number.isFinite(height) && height > 0) this.viewHeight = Math.floor(height);
120
+ if (Number.isFinite(width) && width > 0) this.viewWidth = Math.floor(width);
121
+ this.refresh();
122
+ }
123
+
124
+ contentHeight() {
125
+ return this.transcript ? this.transcript.height(this.viewWidth) : 0;
126
+ }
127
+
128
+ maxScrollTop() {
129
+ return Math.max(0, this.contentHeight() - this.viewHeight);
130
+ }
131
+
132
+ /**
133
+ * Is there enough content for scrolling to mean anything?
134
+ *
135
+ * The `> 1` rather than `> 0` is deliberate and comes from the reference: one
136
+ * row of overflow is not a scroll, and treating it as one makes the follow
137
+ * flag flicker on every wrapped line during streaming.
138
+ */
139
+ scrollable() {
140
+ return this.maxScrollTop() > 1;
141
+ }
142
+
143
+ atBottom() {
144
+ return this.scrollTop >= this.maxScrollTop();
145
+ }
146
+
147
+ get following() {
148
+ return !this.manual;
149
+ }
150
+
151
+ /**
152
+ * Re-derive the follow flag from where we now are. Called after EVERY scroll
153
+ * mutation, whoever caused it.
154
+ */
155
+ sync() {
156
+ if (this.applyingSticky) {
157
+ // A move we made ourselves. It is only allowed to CLEAR the flag — by
158
+ // arriving back at the bottom — never to set it.
159
+ if (this.manual && this.scrollable() && this.atBottom()) {
160
+ this.manual = false;
161
+ this.unseen = 0;
162
+ }
163
+ return;
164
+ }
165
+ this.manual = this.scrollable() && !this.atBottom();
166
+ if (!this.manual) this.unseen = 0;
167
+ }
168
+
169
+ /**
170
+ * The content changed height — a token arrived, a tool printed, a frame was
171
+ * evicted. This is where auto-scroll actually happens.
172
+ */
173
+ refresh(grownBy = 0) {
174
+ const wasApplying = this.applyingSticky;
175
+ this.applyingSticky = true;
176
+ try {
177
+ const max = this.maxScrollTop();
178
+
179
+ // The tolerance applies to GROWTH only, and this is where Causyne departs
180
+ // from the reference on purpose.
181
+ //
182
+ // opencode applies it on any size change (ScrollBox.ts:766-774), and
183
+ // refresh() runs here on every frame because the viewport is re-measured
184
+ // whenever the input box changes height. With a flat tolerance, scrolling
185
+ // up exactly one row leaves scrollTop at max - 1, and the very next
186
+ // keystroke re-engages and snaps it back — so a one-line scroll is a key
187
+ // that visibly does nothing. "You came back to the bottom while content
188
+ // was streaming" is a statement about growth; without growth, the bottom
189
+ // means the bottom.
190
+ const tolerance = grownBy > 0 ? REENGAGE_TOLERANCE : 0;
191
+
192
+ if (!this.manual) {
193
+ this.scrollTop = max;
194
+ this.unseen = 0;
195
+ } else if (max > 0 && this.scrollTop >= max - tolerance) {
196
+ // The user came back to the bottom while content was streaming.
197
+ this.manual = false;
198
+ this.scrollTop = max;
199
+ this.unseen = 0;
200
+ } else {
201
+ // Reading somewhere else: hold position, but never let a shrinking
202
+ // transcript strand the view past the end of it.
203
+ this.scrollTop = Math.min(this.scrollTop, max);
204
+ if (grownBy > 0) this.unseen += grownBy;
205
+ }
206
+ } finally {
207
+ this.applyingSticky = wasApplying;
208
+ }
209
+ }
210
+
211
+ scrollTo(position, { snap = false } = {}) {
212
+ const max = this.maxScrollTop();
213
+ let next = Math.max(0, Math.min(Math.round(position), max));
214
+
215
+ // THE TOLERANCE BELONGS TO DOWNWARD MOTION, not to the repaint.
216
+ //
217
+ // The reference puts it in the growth handler (ScrollBox.ts:497-512), where
218
+ // it covers the case that actually annoys people: a wheel of three rows per
219
+ // notch lands you one row short of the bottom, and follow never resumes.
220
+ // But that handler runs on every repaint here, so a flat tolerance also
221
+ // undoes a deliberate one-row scroll UP — a key that visibly does nothing.
222
+ //
223
+ // The two are mechanically identical and differ only in intent, and the
224
+ // direction of travel is the one piece of intent that IS available. So the
225
+ // snap is applied to downward motion only: scrolling down to within a row
226
+ // of the end means the end; scrolling up by one row means one row.
227
+ if (snap && next > this.scrollTop && max - next <= REENGAGE_TOLERANCE) next = max;
228
+
229
+ if (next !== this.scrollTop) this.scrollTop = next;
230
+ this.sync();
231
+ return this.scrollTop;
232
+ }
233
+
234
+ scrollBy(delta) {
235
+ return this.scrollTo(this.scrollTop + delta, { snap: delta > 0 });
236
+ }
237
+
238
+ /**
239
+ * One wheel notch. `direction` is 'up' or 'down'.
240
+ *
241
+ * The accumulator carries the fraction so a speed of, say, 1.5 does not
242
+ * quietly round to 1 or 2 forever.
243
+ */
244
+ wheel(direction, notches = 1, now = Date.now()) {
245
+ const before = this.scrollTop;
246
+ const multiplier = this.acceleration ? this.acceleration.tick(now) : 1;
247
+ const amount = Math.abs(notches) * this.speed * multiplier;
248
+ this.accumulator += direction === 'up' ? -amount : amount;
249
+ const whole = Math.trunc(this.accumulator);
250
+ if (whole !== 0) {
251
+ this.accumulator -= whole;
252
+ this.scrollBy(whole);
253
+ }
254
+ // `moved` is what lets the caller tell "scrolled" from "already at the
255
+ // end". Without it the wheel was swallowed unconditionally, so reaching the
256
+ // top of the band meant the terminal's own scrollback — which holds the
257
+ // rest of the conversation — could not be reached by wheel at all.
258
+ this.lastWheelMoved = this.scrollTop !== before;
259
+ return this.scrollTop;
260
+ }
261
+
262
+ // A page keeps one row of overlap, which is what every pager does and what
263
+ // lets the eye carry across the jump. (opencode calls height/2 a "page" and
264
+ // height/4 a "half page"; both are half of what their names claim, and there
265
+ // is no reason to inherit that.)
266
+ pageUp() { return this.scrollBy(-Math.max(1, this.viewHeight - 1)); }
267
+ pageDown() { return this.scrollBy(Math.max(1, this.viewHeight - 1)); }
268
+ halfPageUp() { return this.scrollBy(-Math.max(1, Math.floor(this.viewHeight / 2))); }
269
+ halfPageDown() { return this.scrollBy(Math.max(1, Math.floor(this.viewHeight / 2))); }
270
+ lineUp(n = 1) { return this.scrollBy(-n); }
271
+ lineDown(n = 1) { return this.scrollBy(n); }
272
+ toTop() { return this.scrollTo(0); }
273
+
274
+ /**
275
+ * Jump to the bottom and resume following.
276
+ *
277
+ * scrollTo() alone would land at the bottom and sync() would clear the flag,
278
+ * but only if the content is scrollable; going through the sticky path makes
279
+ * "follow again" unconditional, which is what the user means by pressing it.
280
+ */
281
+ toBottom() {
282
+ this.manual = false;
283
+ this.unseen = 0;
284
+ this.refresh();
285
+ return this.scrollTop;
286
+ }
287
+
288
+ /**
289
+ * Frame boundaries, for step-to-step and turn-to-turn jumps.
290
+ */
291
+ jump(direction, kinds = null) {
292
+ if (!this.transcript) return this.scrollTop;
293
+ const markers = this.transcript.markers(this.viewWidth)
294
+ .filter((m) => !kinds || kinds.includes(m.kind));
295
+ if (markers.length === 0) return this.scrollTop;
296
+
297
+ if (direction < 0) {
298
+ const previous = markers.filter((m) => m.row < this.scrollTop).pop();
299
+ return this.scrollTo(previous ? previous.row : 0);
300
+ }
301
+ const next = markers.find((m) => m.row > this.scrollTop);
302
+ return next ? this.scrollTo(next.row) : this.toBottom();
303
+ }
304
+
305
+ /**
306
+ * The rows on screen, top-aligned and padded to the full height.
307
+ *
308
+ * Top-aligned because that is what the reference does: content starts at the
309
+ * top of the box and the gap sits above the prompt, not below the first
310
+ * message.
311
+ */
312
+ rows() {
313
+ const window = this.transcript
314
+ ? this.transcript.slice(this.viewWidth, this.scrollTop, this.viewHeight)
315
+ : [];
316
+ const out = window.slice(0, this.viewHeight);
317
+ while (out.length < this.viewHeight) out.push('');
318
+ return out;
319
+ }
320
+
321
+ /**
322
+ * Where we are, for the rail and the "N new below" affordance.
323
+ */
324
+ state() {
325
+ const max = this.maxScrollTop();
326
+ return {
327
+ scrollTop: this.scrollTop,
328
+ max,
329
+ height: this.viewHeight,
330
+ contentHeight: this.contentHeight(),
331
+ following: this.following,
332
+ unseen: this.unseen,
333
+ // 0..1, or null when there is nothing to scroll.
334
+ progress: max > 0 ? this.scrollTop / max : null
335
+ };
336
+ }
337
+ }