@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,609 @@
1
+ // StageSurface — what ChatPrompt draws into when the Stage is running.
2
+ //
3
+ // It owns the three pieces and nothing else owns any of them: the Transcript
4
+ // (the model), the Viewport (where we are in it), and the Stage (the band of
5
+ // terminal rows). ChatPrompt keeps composing exactly the rows it always
6
+ // composed; instead of writing them to stdout it hands them here, and this
7
+ // decides how much of the band is left over for the transcript.
8
+ //
9
+ // The layout of the band, top to bottom:
10
+ //
11
+ // [ viewport rows ] height - promptRows.length, app-scrolled
12
+ // [ prompt block ] the input box, picker, notice and status footer
13
+ //
14
+ // So the viewport shrinks as the input box grows, which is the behaviour you
15
+ // want and which falls out of the arithmetic rather than needing a rule.
16
+ //
17
+ // THE EXIT CONTRACT lives here (UX-01 rev 2 clause d): close() collapses the
18
+ // band and flushes the whole session into real terminal scrollback. opencode's
19
+ // alternate-screen TUI leaves nothing behind and its split-footer mode leaves
20
+ // everything but cannot scroll; doing both is the entire point.
21
+ //
22
+ // Pure Node: no dependency, no shell.
23
+
24
+ import { Stage, stageHeight } from './stage.js';
25
+ import { Transcript } from './transcript.js';
26
+ import { Viewport, ScrollAcceleration, DEFAULT_SCROLL_SPEED } from './viewport.js';
27
+ import { paint, GLYPHS } from './theme.js';
28
+ import { mouseTracking } from './keys.js';
29
+ import { truncate, padEnd, visibleWidth } from './text.js';
30
+
31
+ // Rows of evicted transcript held for the exit log. Eviction only happens after
32
+ // MAX_FRAMES, so this is a bound on a pathological session, not a working limit.
33
+ const MAX_EVICTED_ROWS = 40000;
34
+
35
+ // Below this the prompt block gets the whole band and the transcript is not
36
+ // shown at all — which is a broken screen, so the prompt is trimmed instead.
37
+ const MIN_VIEWPORT_ROWS = 2;
38
+
39
+ // Fallback measure for the opening composition, used only when the caller does
40
+ // not hand over the banner's real width.
41
+ //
42
+ // The session banner comes out ~82 columns wide (the wordmark beside its facts
43
+ // column, and the workspace path under it). While the input box stretched the
44
+ // full width of a large terminal, the two read as unrelated objects — a thin line
45
+ // pulled across a wide emptiness under a block that stops two-thirds of the way.
46
+ // Ninety sits just above the banner and just below the 100-column prose measure
47
+ // theme.js already uses, so the opening reads as ONE panel.
48
+ //
49
+ // It applies ONLY while the transcript is empty. The session input box keeps
50
+ // filling the window, which is an established layout invariant — tuiWidth
51
+ // asserts "the input box fills a stretched terminal" after a hardcoded 120-column
52
+ // ceiling was torn out of three modules for leaving a wide terminal two-thirds
53
+ // covered. This does not reopen that: it is a different rule for a different
54
+ // moment, and it lifts the instant a session begins.
55
+ const OPENING_FALLBACK_WIDTH = 100;
56
+
57
+ // The rail costs a column plus a column of air. On a narrow terminal those two
58
+ // are worth more as text, so it turns itself off rather than crowding the
59
+ // transcript it exists to help you navigate.
60
+ //
61
+ // It costs THREE columns of content, not two: a composed row is
62
+ // contentWidth + air + rail, and the Stage clamps every row to width - 1 so a
63
+ // wrap can never desynchronise the diff. Budgeting only two put the rail in the
64
+ // column that gets truncated away, which is a rail that renders and is then
65
+ // silently cut off — the tests caught it, which is what they are for.
66
+ const RAIL_MIN_WIDTH = 60;
67
+ const RAIL_WIDTH = 2;
68
+ const RAIL_BUDGET = RAIL_WIDTH + 1;
69
+
70
+ // Which marker wins when several frames fall in the same rail row. A proportional
71
+ // rail on a long session compresses dozens of frames per row, and what you want
72
+ // to see at a glance is the thing that went wrong or the thing you can roll back
73
+ // to — not the forty step rows around them.
74
+ // Inside the thumb, only a marker this loud overrides the position indicator.
75
+ //
76
+ // Two passes to get this right. The first draft let ANY marker win, and a dense
77
+ // session put a marker in every slot — so the thumb, the one thing on the rail
78
+ // that says WHERE YOU ARE, was invisible exactly when the rail mattered most.
79
+ // The second draft set the bar at checkpoints, and a real session on a pty
80
+ // showed why that is still wrong: a restore point is taken on every file write,
81
+ // so the thumb came out as `▌▌▪▪▪▌` — shredded by the most routine event there
82
+ // is. Only what is rare AND urgent is worth a row of position: an error, or an
83
+ // approval waiting on the user.
84
+ const RAIL_THUMB_YIELD = 80;
85
+
86
+ const RAIL_PRIORITY = {
87
+ error: 90, approval: 80, checkpoint: 70, turn: 60, user: 50,
88
+ assistant: 40, reasoning: 30, skill: 25, plan: 20,
89
+ session: 15, step: 10, result: 5, notice: 1
90
+ };
91
+
92
+ /**
93
+ * One row of the position indicator: a continuous rule with ONE dot at its
94
+ * middle. Line, dot, line — the same motif the input box carries.
95
+ *
96
+ * Alternating every other row was the first attempt and it was wrong: over a
97
+ * thumb of any length it reads as a long dashed line, which is the thing it was
98
+ * supposed to stop looking like. One dot at the centre makes even a full-track
99
+ * thumb read as a single object rather than as a run of broken marks.
100
+ *
101
+ * `│` is used by no marker, which is what lets a test find the thumb at all.
102
+ */
103
+ function thumbGlyph(offsetInThumb, thumbLength) {
104
+ const middle = thumbLength >= 3 ? Math.floor(thumbLength / 2) : -1;
105
+ return offsetInThumb === middle ? GLYPHS.dot : GLYPHS.vLine;
106
+ }
107
+
108
+ /**
109
+ * One rail cell. Every glyph is an existing GLYPH — the ambiguous-width set is
110
+ * already accounted for by visibleWidth(), and a new character here would be a
111
+ * new way for a row to measure one column and render two.
112
+ */
113
+ function railGlyph(kind) {
114
+ switch (kind) {
115
+ case 'error': return paint('danger', GLYPHS.cross);
116
+ case 'approval': return paint('warn', GLYPHS.step);
117
+ case 'checkpoint': return paint('success', GLYPHS.step);
118
+ case 'turn': return paint('rule', GLYPHS.hLine);
119
+ case 'user': return paint('user', GLYPHS.arrow);
120
+ case 'assistant': return paint('agent', GLYPHS.arrow);
121
+ case 'reasoning': return paint('muted', GLYPHS.dot);
122
+ default: return paint('rule', GLYPHS.dot);
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Wheel tuning, from the environment first and the saved config second.
128
+ *
129
+ * Env wins because every other Stage switch is an env var (CAUSYNE_STAGE,
130
+ * CAUSYNE_MOUSE, CAUSYNE_RAIL) and a user debugging a terminal wants one place to
131
+ * turn things off. The config file is where a preference lives once it is
132
+ * settled.
133
+ */
134
+ export function scrollSettings(env = process.env, config = null) {
135
+ const tui = (config && typeof config === 'object' && config.tui) || {};
136
+
137
+ let speed = Number(env.CAUSYNE_SCROLL_SPEED);
138
+ if (!Number.isFinite(speed) || speed <= 0) speed = Number(tui.scrollSpeed);
139
+ if (!Number.isFinite(speed) || speed <= 0) speed = DEFAULT_SCROLL_SPEED;
140
+ // A wheel that moves half the screen per notch is not a preference, it is a
141
+ // misconfiguration; clamp rather than obey it.
142
+ speed = Math.min(speed, 20);
143
+
144
+ const raw = env.CAUSYNE_SCROLL_ACCEL;
145
+ const accelerate = raw !== undefined && raw !== ''
146
+ ? raw === '1' || raw === 'true'
147
+ : Boolean(tui.scrollAcceleration);
148
+
149
+ return { speed, accelerate };
150
+ }
151
+
152
+ export class StageSurface {
153
+ constructor({ out = process.stdout, env = process.env, config = null, speed, accelerate, openingWidth = 0 } = {}) {
154
+ this.out = out;
155
+ this.env = env;
156
+
157
+ this.stage = new Stage({ out, env });
158
+ this.transcript = new Transcript({
159
+ onEvict: (rows) => this.absorbEvicted(rows),
160
+ onChange: () => this.handleChange()
161
+ });
162
+ const tuning = scrollSettings(env, config);
163
+ this.viewport = new Viewport({
164
+ transcript: this.transcript,
165
+ height: Math.max(MIN_VIEWPORT_ROWS, this.stage.height - 2),
166
+ width: this.stage.width(),
167
+ speed: speed !== undefined ? speed : tuning.speed,
168
+ acceleration: (accelerate !== undefined ? accelerate : tuning.accelerate)
169
+ ? new ScrollAcceleration()
170
+ : null
171
+ });
172
+
173
+ // Rows that fell out of the model. Not shown, not lost: they are printed
174
+ // ahead of the transcript on the way out.
175
+ this.evicted = [];
176
+ this.lastHeight = 0;
177
+ // Set by ChatPrompt.attachSurface.
178
+ this.onDirty = null;
179
+ this.exitHook = null;
180
+ this.signalHooks = [];
181
+ // UX-02: opt out entirely, for anyone whose terminal or workflow this gets
182
+ // in the way of.
183
+ this.mouseWanted = String(env.CAUSYNE_MOUSE ?? '1') !== '0';
184
+ this.mouseOn = false;
185
+
186
+ // The width the banner above the band actually used, handed over by the
187
+ // caller that printed it. Zero means "nobody measured", and the fallback
188
+ // applies.
189
+ this.openingWidth = Number.isFinite(openingWidth) && openingWidth > 0
190
+ ? Math.floor(openingWidth)
191
+ : 0;
192
+
193
+ // Has anything ever landed in this session?
194
+ //
195
+ // NOT the same question as "is the transcript empty", and the difference is
196
+ // a real defect: /print and ctrl+L empty the transcript, so keying on
197
+ // emptiness narrowed the input box back to the banner's measure long after
198
+ // the banner had scrolled out of view — the box visibly shrinking for a
199
+ // reason the user cannot see. The opening measure belongs to THE opening,
200
+ // once, not to every empty moment after it.
201
+ this.opened = false;
202
+ }
203
+
204
+ get width() {
205
+ return this.stage.width();
206
+ }
207
+
208
+ /**
209
+ * The width the prompt block should be composed to.
210
+ *
211
+ * Deliberately NOT centred, which is where Causyne parts company with the
212
+ * reference on purpose. opencode centres its logo and prompt because its logo
213
+ * is centred in a screen it owns (routes/home.tsx:72-90). Causyne's banner is
214
+ * left-aligned in the user's real scrollback, so a centred input box below it
215
+ * would be misaligned with the very thing it is supposed to belong to. Same
216
+ * origin, same measure: the opening reads as a document block, not a splash.
217
+ */
218
+ composeWidth() {
219
+ const width = this.width;
220
+ if (this.opened || !this.transcript.isEmpty()) return width;
221
+ const measure = this.openingWidth > 0 ? this.openingWidth : OPENING_FALLBACK_WIDTH;
222
+ return Math.min(width, measure);
223
+ }
224
+
225
+ /**
226
+ * Claim the band and arm the exit safety net.
227
+ *
228
+ * The hook matters: if the process dies without going through stop(), the
229
+ * band's rows are ordinary terminal text and nothing is corrupted, but the
230
+ * session would not reach scrollback. A synchronous exit handler is enough
231
+ * because collapse and print-through are plain writes.
232
+ */
233
+ open() {
234
+ // ONE row, not the whole screen.
235
+ //
236
+ // The band used to be born at `rows - 1` regardless of what it had to show.
237
+ // Measured at 156x78: the banner's 12 rows plus a 77-row band came to 89 on
238
+ // a 78-row screen, so the wordmark was scrolled 11 rows out of sight before
239
+ // the user had typed anything, and 73 of the 77 claimed rows were empty.
240
+ // present() grows it to fit what there actually is.
241
+ if (!this.stage.reserve(1)) return false;
242
+ this.enableMouse();
243
+ this.armTeardown();
244
+ return true;
245
+ }
246
+
247
+ enableMouse() {
248
+ if (!this.mouseWanted || this.mouseOn) return false;
249
+ this.out.write(mouseTracking(true));
250
+ this.mouseCaptured = true;
251
+ this.mouseOn = true;
252
+ return true;
253
+ }
254
+
255
+ disableMouse() {
256
+ if (!this.mouseOn) return false;
257
+ this.out.write(mouseTracking(false));
258
+ this.mouseCaptured = false;
259
+ this.mouseOn = false;
260
+ return true;
261
+ }
262
+
263
+
264
+ armTeardown() {
265
+ this.exitHook = () => { try { this.close(); } catch { /* nothing left to do */ } };
266
+ process.once('exit', this.exitHook);
267
+
268
+ for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
269
+ const handler = () => {
270
+ try { this.close(); } catch { /* keep going: the exit matters more */ }
271
+ // Re-raise the conventional status so a caller waiting on us still sees
272
+ // that we were signalled rather than that we exited cleanly.
273
+ process.exit(128 + { SIGINT: 2, SIGTERM: 15, SIGHUP: 1 }[signal]);
274
+ };
275
+ process.on(signal, handler);
276
+ this.signalHooks.push([signal, handler]);
277
+ }
278
+ }
279
+
280
+ disarmTeardown() {
281
+ if (this.exitHook) {
282
+ process.removeListener('exit', this.exitHook);
283
+ this.exitHook = null;
284
+ }
285
+ for (const [signal, handler] of this.signalHooks) process.removeListener(signal, handler);
286
+ this.signalHooks = [];
287
+ }
288
+
289
+ close() {
290
+ // Mouse first, unconditionally: it is the one piece of state that outlives
291
+ // the process and damages the terminal the user goes back to.
292
+ this.disableMouse();
293
+ if (!this.stage.reserved) { this.disarmTeardown(); return false; }
294
+ const rows = this.snapshot();
295
+ this.stage.collapse();
296
+ this.stage.printThrough(rows);
297
+ this.disarmTeardown();
298
+ return true;
299
+ }
300
+
301
+ /**
302
+ * Hand the terminal back to something that is about to write to it directly,
303
+ * then take it again.
304
+ *
305
+ * Anything spawned with stdio: 'inherit' writes straight into the band and
306
+ * the diff has no idea it happened. Causyne has four such call sites today
307
+ * (autoUpdate's two npm installs, the cortex provisioner, the capability
308
+ * provisioner); none of them is wired to this yet, and that is recorded as an
309
+ * open item rather than left to be discovered.
310
+ */
311
+ suspend() {
312
+ if (!this.stage.reserved) return false;
313
+ this.disableMouse();
314
+ this.stage.collapse();
315
+ return true;
316
+ }
317
+
318
+ resume() {
319
+ if (this.stage.reserved) return false;
320
+ if (!this.stage.reserve()) return false;
321
+ this.stage.invalidate();
322
+ this.enableMouse();
323
+ return true;
324
+ }
325
+
326
+ /** Everything the session produced, oldest first. */
327
+ snapshot() {
328
+ return this.evicted.concat(this.transcript.snapshot(this.width));
329
+ }
330
+
331
+ absorbEvicted(rows) {
332
+ for (const row of rows) this.evicted.push(row);
333
+ if (this.evicted.length > MAX_EVICTED_ROWS) {
334
+ this.evicted.splice(0, this.evicted.length - MAX_EVICTED_ROWS);
335
+ }
336
+ }
337
+
338
+ handleChange() {
339
+ if (!this.transcript.isEmpty()) this.opened = true;
340
+ const before = this.lastHeight;
341
+ const after = this.transcript.height(this.width);
342
+ this.lastHeight = after;
343
+ // Growth is what drives auto-scroll. Handing the delta over is also what
344
+ // lets the "N new below" affordance count only what arrived while the user
345
+ // was reading somewhere else.
346
+ this.viewport.refresh(Math.max(0, after - before));
347
+ if (this.onDirty) this.onDirty();
348
+ }
349
+
350
+ // -------------------------------------------------------------------------
351
+ // The interface ChatPrompt calls
352
+ // -------------------------------------------------------------------------
353
+
354
+ /**
355
+ * Append a permanent frame. `produce` must be a pure function of width — see
356
+ * the contract in transcript.js.
357
+ */
358
+ commit(kind, produce, meta = null) {
359
+ return this.transcript.append({ kind, produce, meta });
360
+ }
361
+
362
+ /**
363
+ * Paint the band: viewport on top, the prompt's own rows underneath.
364
+ */
365
+ present(promptRows, caret = null) {
366
+ if (!this.stage.reserved) return 0;
367
+
368
+ const width = this.width;
369
+ const rail = this.railEnabled(width);
370
+ const contentWidth = rail ? Math.max(20, width - RAIL_BUDGET) : width;
371
+
372
+ let block = promptRows;
373
+ let caretRow = caret && Number.isFinite(caret.row) ? caret.row : Math.max(0, block.length - 1);
374
+
375
+ // Grow the band to what there is to show, before deciding anything else.
376
+ //
377
+ // An empty session wants exactly the prompt and no more — no viewport, no
378
+ // void, and no rows taken from the user's terminal that nothing is going to
379
+ // fill. As the transcript grows the band follows it, up to the ceiling; it
380
+ // never shrinks, because shrinking would mean erasing rows the diff believes
381
+ // it owns and fighting the resize path for them.
382
+ const contentRows = this.transcript.height(contentWidth);
383
+ const desired = Math.min(this.stage.maxHeight, block.length + contentRows);
384
+ if (desired > this.stage.height) this.stage.grow(desired - this.stage.height);
385
+
386
+ const height = this.stage.height;
387
+
388
+ // The prompt may not eat the whole band — but only once there is a
389
+ // transcript for it to eat. Trim from the TOP when it would: the status
390
+ // footer and the caret row are the last rows and the ones worth keeping,
391
+ // and an input box that has scrolled its own oldest row off is a smaller lie
392
+ // than a transcript that has vanished.
393
+ const floor = contentRows > 0 ? MIN_VIEWPORT_ROWS : 0;
394
+ const maxBlock = Math.max(1, height - floor);
395
+ if (block.length > maxBlock) {
396
+ const cut = block.length - maxBlock;
397
+ block = block.slice(cut);
398
+ caretRow = Math.max(0, caretRow - cut);
399
+ }
400
+
401
+ const budget = Math.max(0, height - block.length);
402
+ let rows = [];
403
+ if (budget > 0) {
404
+ this.viewport.resize({ height: budget, width: contentWidth });
405
+ rows = this.viewport.rows();
406
+ this.decorate(rows, contentWidth);
407
+ if (rail) this.attachRail(rows, contentWidth);
408
+ }
409
+
410
+ // DO NOT PAINT WHILE THE TERMINAL OWNS THE SCROLL.
411
+ //
412
+ // The Stage repaints by DIFF against the rows it believes are on screen. It
413
+ // has no way to know the user scrolled the terminal's own scrollback, and
414
+ // when they do, the band's absolute rows move while the diff keeps writing
415
+ // to the old coordinates — which is how a session ends up with a stray rail
416
+ // column down the middle and fragments of old frames at the edges.
417
+ //
418
+ // We cannot detect a terminal scroll directly. What we CAN detect is the
419
+ // moment we handed the wheel over (viewport at its top, mouse reporting
420
+ // released), and from then until the user types again the terminal is the
421
+ // thing scrolling. Painting nothing is correct: the band is not where we
422
+ // left it, so every write would land somewhere wrong.
423
+ if (this.detached) return false;
424
+
425
+ return this.stage.paint(rows.concat(block), {
426
+ row: rows.length + caretRow,
427
+ col: caret ? caret.col : 0
428
+ });
429
+ }
430
+
431
+ /**
432
+ * The one thing on screen that opencode does not have: while the view is held
433
+ * away from the bottom, say so and say how much arrived.
434
+ *
435
+ * Without it, un-following is invisible — the transcript simply stops moving
436
+ * and the user cannot tell whether the agent stalled or they scrolled.
437
+ */
438
+ decorate(rows, width) {
439
+ const state = this.viewport.state();
440
+ if (state.following) return rows;
441
+
442
+ const count = state.unseen;
443
+ const label = count > 0
444
+ ? `${GLYPHS.arrow} ${count} new below`
445
+ : `${GLYPHS.arrow} scrolled up`;
446
+ const hint = `${label} ${GLYPHS.dot} shift+pgdn to follow`;
447
+ rows[rows.length - 1] = paint('accent', truncate(hint, Math.max(8, width - 1), GLYPHS.ellipsis));
448
+ return rows;
449
+ }
450
+
451
+ /**
452
+ * Ctrl+L: flush what is on the stage into real scrollback, then start clean.
453
+ *
454
+ * In a terminal, ctrl+L clears the screen and your history is still in
455
+ * scrollback. Clearing the transcript alone would destroy the session record,
456
+ * which is not what the key means — so print it through first. This is also
457
+ * the escape hatch for getting a transcript into the terminal without exiting.
458
+ */
459
+ flushAndClear() {
460
+ const rows = this.snapshot();
461
+ this.stage.collapse();
462
+ this.stage.printThrough(rows);
463
+ this.evicted = [];
464
+ this.transcript.clear();
465
+ this.lastHeight = 0;
466
+ this.stage.reserve();
467
+ this.stage.invalidate();
468
+ this.viewport.toBottom();
469
+ return rows.length;
470
+ }
471
+
472
+ /**
473
+ * The terminal changed size. Callers must debounce — ChatPrompt already
474
+ * settles resize at RESIZE_SETTLE_MS.
475
+ */
476
+ resize() {
477
+ const next = stageHeight(this.out, this.env);
478
+ if (next < 1) {
479
+ // The terminal became too short to hold a stage. Give the band back and
480
+ // let the caller fall through to the scrollback renderer rather than
481
+ // painting into a region that cannot hold a viewport.
482
+ this.close();
483
+ return false;
484
+ }
485
+ // Re-claim only what the content justifies; present() grows it from there.
486
+ this.stage.resize(next, 1);
487
+ this.stage.invalidate();
488
+ // Re-derive from the new geometry: growing the viewport lowers maxScrollTop,
489
+ // and leaving scrollTop above it would read as "the user scrolled away".
490
+ this.viewport.resize({ height: Math.max(MIN_VIEWPORT_ROWS, this.stage.height - 2), width: this.width });
491
+ this.lastHeight = this.transcript.height(this.width);
492
+ return this.stage.reserved;
493
+ }
494
+
495
+ /**
496
+ * Does this terminal get a rail?
497
+ */
498
+ railEnabled(width) {
499
+ if (String(this.env.CAUSYNE_RAIL ?? '1') === '0') return false;
500
+ return width >= RAIL_MIN_WIDTH;
501
+ }
502
+
503
+ /**
504
+ * The rail: the WHOLE session compressed into one column, plus where you are
505
+ * in it.
506
+ *
507
+ * Proportional, not per-row. A per-row gutter would only mark what is already
508
+ * on screen, which tells you nothing you cannot see; mapping the entire
509
+ * transcript onto the column means a glance answers "how far in am I, and is
510
+ * there anything back there worth going to". opencode has no equivalent —
511
+ * its scrollbar reports position and nothing about content.
512
+ *
513
+ * Returns `height` single-column strings.
514
+ */
515
+ buildRail(height, contentWidth) {
516
+ const total = Math.max(1, this.transcript.height(contentWidth));
517
+ const slotOf = (row) => Math.max(0, Math.min(height - 1, Math.floor((row / total) * height)));
518
+
519
+ const kinds = new Array(height).fill(null);
520
+ for (const marker of this.transcript.markers(contentWidth)) {
521
+ const slot = slotOf(marker.row);
522
+ const held = kinds[slot];
523
+ if (!held || (RAIL_PRIORITY[marker.kind] || 0) > (RAIL_PRIORITY[held] || 0)) {
524
+ kinds[slot] = marker.kind;
525
+ }
526
+ }
527
+
528
+ const state = this.viewport.state();
529
+ // Nothing to scroll, no thumb. A scrollbar whose track is full says the same
530
+ // thing as no scrollbar at all, and on an empty session it filled the whole
531
+ // column with position indicator for a position that could not change —
532
+ // noise where the rail is supposed to be information.
533
+ const scrollable = state.max > 0;
534
+ const from = scrollable ? slotOf(state.scrollTop) : -1;
535
+ const to = scrollable ? slotOf(Math.max(state.scrollTop, state.scrollTop + state.height - 1)) : -2;
536
+
537
+ const cells = new Array(height);
538
+ for (let i = 0; i < height; i++) {
539
+ const inThumb = i >= from && i <= to;
540
+ const kind = kinds[i];
541
+ const loud = kind && (RAIL_PRIORITY[kind] || 0) >= RAIL_THUMB_YIELD;
542
+ if (kind && (!inThumb || loud)) cells[i] = railGlyph(kind);
543
+ else if (inThumb) cells[i] = paint('scroll', thumbGlyph(i - from, to - from + 1));
544
+ else cells[i] = ' ';
545
+ }
546
+ return cells;
547
+ }
548
+
549
+ /**
550
+ * Lay the rail beside the viewport rows.
551
+ *
552
+ * The rows were rendered at contentWidth, so padding them out to it is what
553
+ * keeps the rail in one column instead of ragged against the text.
554
+ */
555
+ attachRail(rows, contentWidth) {
556
+ const rail = this.buildRail(rows.length, contentWidth);
557
+ for (let i = 0; i < rows.length; i++) {
558
+ rows[i] = `${padEnd(rows[i], contentWidth)} ${rail[i]}`;
559
+ }
560
+ return rows;
561
+ }
562
+
563
+ /**
564
+ * Hand the wheel back to the terminal.
565
+ *
566
+ * THE SEAM THIS EXISTS FOR. Two things scroll: this band's viewport, and the
567
+ * terminal's own scrollback above it, which holds everything already flushed.
568
+ * With ?1000 on, the app receives every wheel report and the terminal receives
569
+ * none — so once the viewport reached its top, scrolling further did nothing
570
+ * at all and the earlier conversation was unreachable by wheel. Releasing at
571
+ * the boundary is what makes the two halves feel like one scroll.
572
+ */
573
+ releaseMouse() {
574
+ if (!this.mouseCaptured) return false;
575
+ this.out.write(mouseTracking(false));
576
+ this.mouseCaptured = false;
577
+ this.detached = true;
578
+ return true;
579
+ }
580
+
581
+ /** Take it back, once the user is driving the band again. */
582
+ captureMouse() {
583
+ if (this.mouseCaptured) return false;
584
+ this.out.write(mouseTracking(true));
585
+ this.mouseCaptured = true;
586
+ this.detached = false;
587
+ // Every row the diff remembers is stale — the terminal has scrolled under
588
+ // us. Redraw the whole band rather than trusting a single cached row.
589
+ this.stage.invalidate();
590
+ return true;
591
+ }
592
+ }
593
+
594
+ /**
595
+ * Build a surface, or null when this terminal should keep the scrollback
596
+ * renderer. Every null here is a reason the region could not be repainted
597
+ * safely, and the fallback is the behaviour Causyne has always had.
598
+ */
599
+ export function createStageSurface({ out = process.stdout, env = process.env, config = null, speed, openingWidth = 0 } = {}) {
600
+ if (!out || !out.isTTY) return null;
601
+ if (String(env.TERM || '') === 'dumb') return null;
602
+ if (env.CAUSYNE_SIMPLE_INPUT === '1') return null;
603
+ if (stageHeight(out, env) < 1) return null;
604
+
605
+ const surface = new StageSurface({ out, env, config, speed, openingWidth });
606
+ if (!surface.open()) return null;
607
+ return surface;
608
+ }
609
+