@causyne/agent 0.0.0-stage → 1.10.1

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,335 @@
1
+ // The filterable list every dialog is built out of.
2
+ //
3
+ // openclaw has components/filterable-select-list.ts and searchable-select-list.ts;
4
+ // opencode has ui/dialog-select.tsx, and its command palette, model picker,
5
+ // session list, theme list, agent list and MCP list are all that one component
6
+ // with different options. The lesson is the component, not the dialogs: build
7
+ // this once and well, and every dialog after it costs twenty lines.
8
+ //
9
+ // The fuzzy matcher is the part that decides whether a palette feels sharp or
10
+ // useless. A plain `includes()` cannot find "session list" from "sesl", and an
11
+ // unscored subsequence match ranks garbage first. This scores the way people
12
+ // expect: consecutive letters and word starts win, and a match at the beginning
13
+ // of the label beats the same letters buried in the middle.
14
+ //
15
+ // Pure Node over ./theme.js and ./text.js. No dependency, no shell.
16
+
17
+ import { paint, bold, dim, GLYPHS } from './theme.js';
18
+ import { truncate, visibleWidth, safeDisplay } from './text.js';
19
+
20
+ /**
21
+ * Score `query` against `text` as a fuzzy subsequence.
22
+ *
23
+ * Returns { score, positions } or null when the query is not a subsequence.
24
+ * Higher is better. The weights are the conventional ones and they matter:
25
+ *
26
+ * +16 the match continues the previous one (consecutive letters)
27
+ * +12 the match is at a word start (after a space, -, _, /, or a case bump)
28
+ * +8 the match is the very first character
29
+ * -1 per character skipped, so a tight match beats a scattered one
30
+ */
31
+ export function fuzzyScore(query, text) {
32
+ const needle = String(query || '').toLowerCase();
33
+ const haystack = String(text || '');
34
+ if (needle === '') return { score: 0, positions: [] };
35
+
36
+ const lower = haystack.toLowerCase();
37
+ const positions = [];
38
+ let score = 0;
39
+ let cursor = 0;
40
+ let previous = -2;
41
+
42
+ for (const character of needle) {
43
+ if (character === ' ') continue; // spaces separate words, they do not match
44
+ const found = lower.indexOf(character, cursor);
45
+ if (found === -1) return null;
46
+
47
+ if (found === previous + 1) score += 16;
48
+ if (found === 0) score += 8;
49
+
50
+ const before = haystack[found - 1];
51
+ if (before !== undefined) {
52
+ const isBoundary = /[\s\-_/.:]/.test(before)
53
+ || (before === before.toLowerCase() && haystack[found] === haystack[found].toUpperCase()
54
+ && haystack[found] !== haystack[found].toLowerCase());
55
+ if (isBoundary) score += 12;
56
+ }
57
+
58
+ score -= Math.min(found - cursor, 8);
59
+ positions.push(found);
60
+ previous = found;
61
+ cursor = found + 1;
62
+ }
63
+
64
+ // A shorter label containing the same letters is the more likely target.
65
+ score -= Math.floor(haystack.length / 12);
66
+ return { score, positions };
67
+ }
68
+
69
+ /** Filter and rank items. Items keep their original order when the query is empty. */
70
+ export function fuzzyFilter(items, query) {
71
+ if (!query) return items.map((item, index) => ({ item, score: 0, positions: [], index }));
72
+
73
+ const scored = [];
74
+ for (let index = 0; index < items.length; index++) {
75
+ const item = items[index];
76
+ const subject = `${item.label || ''} ${item.searchText || ''} ${item.category || ''}`.trim();
77
+ const result = fuzzyScore(query, subject);
78
+ if (result) scored.push({ item, score: result.score, positions: result.positions, index });
79
+ }
80
+ // Stable: equal scores keep the order they were declared in.
81
+ scored.sort((a, b) => (b.score - a.score) || (a.index - b.index));
82
+ return scored;
83
+ }
84
+
85
+ export class SelectList {
86
+ /**
87
+ * items: { label, description, category, hint, value, searchText, disabled }
88
+ */
89
+ constructor(items = [], options = {}) {
90
+ this.setItems(items);
91
+ this.query = '';
92
+ this.selected = 0;
93
+ this.offset = 0;
94
+ this.placeholder = options.placeholder || 'type to filter';
95
+ this.emptyText = options.emptyText || 'no matches';
96
+ this.showFilter = options.showFilter !== false;
97
+ }
98
+
99
+ setItems(items) {
100
+ this.all = Array.isArray(items) ? items.filter(Boolean) : [];
101
+ this.refilter();
102
+ return this;
103
+ }
104
+
105
+ refilter() {
106
+ this.filtered = fuzzyFilter(this.all, this.query);
107
+ this.selected = Math.min(this.selected || 0, Math.max(0, this.filtered.length - 1));
108
+ this.offset = 0;
109
+ return this;
110
+ }
111
+
112
+ get current() {
113
+ const entry = this.filtered[this.selected];
114
+ return entry ? entry.item : null;
115
+ }
116
+
117
+ move(delta, visibleRows) {
118
+ if (this.filtered.length === 0) return this;
119
+ // Wrapping is what makes a short list feel responsive: pressing up on the
120
+ // first row lands on the last, instead of doing nothing.
121
+ this.selected = (this.selected + delta + this.filtered.length) % this.filtered.length;
122
+ this.ensureVisible(visibleRows);
123
+ return this;
124
+ }
125
+
126
+ moveTo(index, visibleRows) {
127
+ if (this.filtered.length === 0) return this;
128
+ this.selected = Math.max(0, Math.min(index, this.filtered.length - 1));
129
+ this.ensureVisible(visibleRows);
130
+ return this;
131
+ }
132
+
133
+ /**
134
+ * How many items starting at `from` actually fit in `rows` rendered rows.
135
+ *
136
+ * render() emits a category header whenever the group changes, so a window of
137
+ * N items does NOT occupy N rows. Counting items and rendering rows are two
138
+ * different quantities, and treating them as one is what made the selection
139
+ * marker disappear: ensureVisible() put the selected item at the end of an
140
+ * item-counted window, the render loop ran out of ROWS on the headers before
141
+ * it reached that item, and the marker was simply never drawn. Pressing up
142
+ * moved the selection back into the part that had been drawn, which is why it
143
+ * came back.
144
+ */
145
+ renderableCount(from, rows) {
146
+ const budget = Math.max(1, rows);
147
+ let used = 0;
148
+ let count = 0;
149
+ let lastCategory = null;
150
+ for (let index = from; index < this.filtered.length; index++) {
151
+ const item = this.filtered[index].item;
152
+ const needed = (item.category && item.category !== lastCategory) ? 2 : 1;
153
+ if (used + needed > budget) break;
154
+ if (item.category) lastCategory = item.category;
155
+ used += needed;
156
+ count++;
157
+ }
158
+ return count;
159
+ }
160
+
161
+ ensureVisible(visibleRows) {
162
+ const rows = Math.max(1, visibleRows || 10);
163
+ if (this.filtered.length === 0) { this.offset = 0; return this; }
164
+
165
+ if (this.selected < this.offset) this.offset = this.selected;
166
+
167
+ // Scroll down until the selected item is genuinely among the ones that will
168
+ // be drawn — measured in rows, headers included, not in items.
169
+ let guard = this.filtered.length;
170
+ while (
171
+ guard-- > 0 &&
172
+ this.offset < this.selected &&
173
+ this.selected >= this.offset + this.renderableCount(this.offset, rows)
174
+ ) {
175
+ this.offset++;
176
+ }
177
+
178
+ // Never scroll past the selection: the old clamp to
179
+ // `filtered.length - rows` was item-counted too, and could pull the offset
180
+ // back to a position where the selection was off-window again.
181
+ this.offset = Math.max(0, Math.min(this.offset, this.selected));
182
+ return this;
183
+ }
184
+
185
+ /**
186
+ * Feed a decoded key. Returns 'select' | 'cancel' | 'handled' | null.
187
+ *
188
+ * null means the key was not ours, which is how the caller knows it may pass
189
+ * the key on. A dialog that swallows every key is a dialog you cannot escape.
190
+ */
191
+ handleKey(key, visibleRows) {
192
+ if (!key) return null;
193
+ const rows = Math.max(1, visibleRows || 10);
194
+
195
+ switch (key.name) {
196
+ case 'up': this.move(-1, rows); return 'handled';
197
+ case 'down': this.move(1, rows); return 'handled';
198
+ // A page is the number of ITEMS on screen, not the number of rows.
199
+ // Category headers spend rows without being selectable, so paging by the
200
+ // row budget jumped further than a screenful and left a band of items that
201
+ // could not be reached by paging at all.
202
+ case 'pageup':
203
+ case 'pagedown': {
204
+ const page = Math.max(1, this.renderableCount(this.offset, rows));
205
+ this.move(key.name === 'pageup' ? -page : page, rows);
206
+ return 'handled';
207
+ }
208
+ case 'home': this.moveTo(0, rows); return 'handled';
209
+ case 'end': this.moveTo(this.filtered.length - 1, rows); return 'handled';
210
+ // keys.js emits 'enter' (see makeKey('enter', …) for both CR and LF).
211
+ // This listened only for 'return', a name the decoder never produces, so
212
+ // Enter fell through to the printable-character branch, was rejected there
213
+ // for being a control byte, and returned null — a completely dead key.
214
+ // Nothing in the picker or the command palette could ever be selected.
215
+ // 'return' is kept so a decoder that emits it still works.
216
+ case 'enter':
217
+ case 'return': return this.current ? 'select' : 'handled';
218
+ case 'escape': return 'cancel';
219
+ case 'backspace':
220
+ if (this.query.length > 0) {
221
+ this.query = this.query.slice(0, -1);
222
+ this.refilter();
223
+ return 'handled';
224
+ }
225
+ return 'cancel';
226
+ default: break;
227
+ }
228
+
229
+ // Emacs navigation, which both references bind and terminal users expect.
230
+ if (key.ctrl) {
231
+ if (key.name === 'p') { this.move(-1, rows); return 'handled'; }
232
+ if (key.name === 'n') { this.move(1, rows); return 'handled'; }
233
+ if (key.name === 'u') { this.query = ''; this.refilter(); return 'handled'; }
234
+ return null;
235
+ }
236
+
237
+ if (key.sequence && key.sequence.length === 1 && key.sequence >= ' ' && !key.meta) {
238
+ this.query += key.sequence;
239
+ this.refilter();
240
+ return 'handled';
241
+ }
242
+ return null;
243
+ }
244
+
245
+ /**
246
+ * Render into an array of styled rows for a panel `width` x `height`.
247
+ *
248
+ * Returns { rows, cursorColumn } — the caret sits in the filter field, which
249
+ * is the only place a caret belongs in a picker.
250
+ */
251
+ render(width, height) {
252
+ const rows = [];
253
+ const inner = Math.max(4, width);
254
+
255
+ if (this.showFilter) {
256
+ const shown = this.query || dim(this.placeholder);
257
+ rows.push(`${paint('accent', GLYPHS.arrow)} ${truncate(shown, inner - 2, GLYPHS.ellipsis)}`);
258
+ rows.push(paint('rule', GLYPHS.hLine.repeat(inner)));
259
+ }
260
+
261
+ // Rows the ITEM loop may use.
262
+ //
263
+ // Two rows are already spent on the filter and its rule, and one more is
264
+ // reserved at the bottom for the position counter — which is written with
265
+ // `rows[height - 1] = line`, i.e. it OVERWRITES whatever is on the last row.
266
+ // Letting the loop fill that row meant the counter could clobber the very
267
+ // item the marker was on, which is a second, independent way for the
268
+ // selection to vanish.
269
+ const itemRows = Math.max(1, height - rows.length - 1);
270
+
271
+ // Remember what the renderer actually budgeted, so key handling scrolls by
272
+ // the same number of rows it draws. SelectLayer used to derive its own
273
+ // figure from the layer's configured height, which is not the height the
274
+ // panel ends up giving the body — 17 against the renderer's 15 here.
275
+ this.lastItemRows = itemRows;
276
+ this.ensureVisible(itemRows);
277
+
278
+ if (this.filtered.length === 0) {
279
+ rows.push('');
280
+ rows.push(` ${dim(this.emptyText)}`);
281
+ return { rows, listRows: itemRows };
282
+ }
283
+
284
+ const window = this.filtered.slice(this.offset, this.offset + itemRows);
285
+ // The last row belongs to the position counter whenever one is shown.
286
+ const ceiling = this.filtered.length > itemRows ? height - 1 : height;
287
+ let lastCategory = null;
288
+
289
+ for (let index = 0; index < window.length; index++) {
290
+ const entry = window[index];
291
+ const item = entry.item;
292
+ const isSelected = this.offset + index === this.selected;
293
+
294
+ if (item.category && item.category !== lastCategory) {
295
+ lastCategory = item.category;
296
+ rows.push(dim(` ${safeDisplay(item.category)}`));
297
+ if (rows.length >= ceiling) break;
298
+ }
299
+
300
+ const marker = isSelected ? paint('accent', GLYPHS.arrow) : ' ';
301
+ const label = safeDisplay(item.label || '');
302
+ const hint = item.hint ? dim(` ${safeDisplay(item.hint)}`) : '';
303
+ const description = item.description ? safeDisplay(item.description) : '';
304
+
305
+ // The hint (a keybinding) is right-aligned, which is what makes a palette
306
+ // scannable: the names line up on the left, the keys on the right.
307
+ const left = `${marker} ${isSelected ? bold(paint('ink', label)) : paint('ink', label)}`;
308
+ const hintWidth = visibleWidth(hint);
309
+ const room = inner - visibleWidth(left) - hintWidth - 2;
310
+
311
+ const middle = description && room > 6
312
+ ? dim(' ' + truncate(description, room, GLYPHS.ellipsis))
313
+ : '';
314
+ const padding = Math.max(0, inner - visibleWidth(left) - visibleWidth(middle) - hintWidth);
315
+
316
+ rows.push(left + middle + ' '.repeat(padding) + hint);
317
+ if (rows.length >= ceiling) break;
318
+ }
319
+
320
+ // A count is the cheapest possible scroll indicator and it never lies.
321
+ if (this.filtered.length > itemRows) {
322
+ const position = `${this.selected + 1}/${this.filtered.length}`;
323
+ const line = dim(position.padStart(inner));
324
+ if (rows.length >= height) rows[height - 1] = line;
325
+ else rows.push(line);
326
+ }
327
+
328
+ return { rows, listRows: itemRows };
329
+ }
330
+
331
+ /** Column the caret sits at within the filter row. */
332
+ cursorColumn() {
333
+ return 2 + visibleWidth(this.query);
334
+ }
335
+ }
@@ -0,0 +1,330 @@
1
+ // The Stage — a bounded, bottom-anchored region Causyne owns and repaints.
2
+ //
3
+ // WHY THIS IS NOT AN ALTERNATE SCREEN.
4
+ //
5
+ // opencode's full TUI gets its "the chat scrolls inside a box, the terminal
6
+ // does not scroll" behaviour the blunt way: it writes \x1b[?1049h and owns the
7
+ // whole screen, so there is no terminal scrollback left to scroll
8
+ // (packages/tui/src/app.tsx:190-205, screenMode defaults to "alternate-screen").
9
+ // UX-01 forbids Causyne doing that, and the reason is specific and correct: the
10
+ // old src/tui/screen.js wrote ALT_ENTER + HOME + CLEAR_ALL unconditionally,
11
+ // there is no capability detection for the alternate buffer anywhere, and a
12
+ // terminal that silently IGNORES ?1049h does not throw — so the \x1b[2J then
13
+ // wiped the user's real screen.
14
+ //
15
+ // The Stage owns a REGION instead of a screen. Every escape it emits is
16
+ // relative, so it is structurally incapable of touching a row it did not write:
17
+ //
18
+ // * the band is reserved by emitting newlines, never by absolute addressing;
19
+ // * movement is CUU/CUD only — a newline inside paint() would scroll the
20
+ // terminal out from under the block, so paint() emits none;
21
+ // * one seam row is left above the band, which is the visible proof that this
22
+ // is not an alternate screen;
23
+ // * on exit the band is collapsed and the transcript is flushed to real
24
+ // scrollback (printThrough), so the session stays permanent terminal
25
+ // history — the property UX-01 was actually protecting.
26
+ //
27
+ // Rendering is a ROW diff, not a cell diff. opencode can afford per-cell
28
+ // diffing because @opentui/core ships per-platform Zig binaries over FFI;
29
+ // ZM-06 bars that here, so the region is bounded and the granularity is coarser.
30
+ // A spinner tick touches one row; a scroll touches all of them, at a
31
+ // user-driven rate. Both are cheap at 40x200 in pure JS.
32
+ //
33
+ // Pure Node: no dependency, no shell.
34
+
35
+ import { visibleWidth, truncate } from './text.js';
36
+
37
+ // Below this the band cannot hold a viewport plus an input box plus a footer,
38
+ // and a cramped stage is worse than honest scrollback. The fallback is not a
39
+ // degraded stage; it is the scrollback renderer, unchanged.
40
+ export const MIN_STAGE_ROWS = 16;
41
+
42
+ // Synchronized output. A terminal that does not implement it ignores both
43
+ // halves, so this is free; one that does composites the whole repaint at once,
44
+ // which is what stops a scroll from tearing.
45
+ const SYNC_ON = '\x1b[?2026h';
46
+ const SYNC_OFF = '\x1b[?2026l';
47
+ const HIDE_CURSOR = '\x1b[?25l';
48
+ const SHOW_CURSOR = '\x1b[?25h';
49
+ const ERASE_LINE = '\x1b[K';
50
+ const ERASE_BELOW = '\x1b[J';
51
+
52
+ /**
53
+ * How tall the band should be, or 0 for "do not use a stage".
54
+ *
55
+ * CAUSYNE_STAGE: off | auto | <rows> | <pct>%
56
+ */
57
+ export function stageHeight(out = process.stdout, env = process.env) {
58
+ const setting = String(env.CAUSYNE_STAGE ?? 'auto').trim().toLowerCase();
59
+ if (setting === 'off' || setting === '0' || setting === 'false') return 0;
60
+
61
+ const rows = Number(out && out.rows);
62
+ if (!Number.isFinite(rows) || rows <= 0) return 0;
63
+
64
+ // The seam: never take the last row of the user's screen. It costs one row
65
+ // and it is the difference between "a region" and "the screen".
66
+ const ceiling = rows - 1;
67
+
68
+ let target = ceiling;
69
+ const percent = /^(\d{1,3})%$/.exec(setting);
70
+ if (percent) target = Math.round((rows * Number(percent[1])) / 100);
71
+ else if (/^\d+$/.test(setting)) target = Number(setting);
72
+
73
+ target = Math.min(target, ceiling);
74
+ if (!Number.isFinite(target) || target < MIN_STAGE_ROWS) return 0;
75
+ return target;
76
+ }
77
+
78
+ /**
79
+ * May we run a stage on this stream at all?
80
+ *
81
+ * Everything here is a reason the region could not be repainted safely, so the
82
+ * answer is the scrollback renderer rather than a stage that misbehaves.
83
+ */
84
+ export function stageEnabled(out = process.stdout, env = process.env) {
85
+ if (!out || !out.isTTY) return false;
86
+ if (String(env.TERM || '') === 'dumb') return false;
87
+ if (env.CAUSYNE_SIMPLE_INPUT === '1') return false;
88
+ return stageHeight(out, env) > 0;
89
+ }
90
+
91
+ export class Stage {
92
+ constructor({ out = process.stdout, env = process.env, height = 0 } = {}) {
93
+ this.out = out;
94
+ this.env = env;
95
+
96
+ // The CEILING, not the size. A band claimed by printing newlines costs the
97
+ // user a real row of terminal for every row it takes, so it takes only what
98
+ // it has something to put in and grows from there. Sizing it to the screen
99
+ // is free on an alternate screen — which is what opencode is — and on a
100
+ // reserved band it is 73 rows of void and a banner scrolled out of sight.
101
+ this.maxHeight = height > 0 ? Math.floor(height) : stageHeight(out, env);
102
+ // Rows actually claimed. Zero until reserve().
103
+ this.height = 0;
104
+
105
+ this.reserved = false;
106
+ // Where the caret is, as a row index inside the band. reserve() leaves it
107
+ // on the band's last row, because that is where the newlines end.
108
+ this.cursor = 0;
109
+ // What is currently on screen, row for row. The diff is against this.
110
+ this.prev = [];
111
+ this.caret = { row: 0, col: 0 };
112
+
113
+ // Measured, so the tests can assert the diff actually diffs.
114
+ // `claimed` is what makes the newline invariant checkable: every newline the
115
+ // Stage ever emits is a row it claimed, and paint() emits none. The old
116
+ // phrasing — "nothing after reserve emits a newline" — could not survive a
117
+ // band that grows, and conflated the claim path with the paint path.
118
+ this.stats = { paints: 0, rowsWritten: 0, bytes: 0, claimed: 0 };
119
+ }
120
+
121
+ width() {
122
+ const columns = Number(this.out && this.out.columns);
123
+ return Number.isFinite(columns) && columns > 0 ? Math.max(20, columns) : 80;
124
+ }
125
+
126
+ /** Rows available to the caller for content, i.e. the whole band. */
127
+ get rows() {
128
+ return this.height;
129
+ }
130
+
131
+ write(text) {
132
+ if (!text) return;
133
+ this.stats.bytes += text.length;
134
+ this.out.write(text);
135
+ }
136
+
137
+ /**
138
+ * Claim the band.
139
+ *
140
+ * Emitting `height` newlines is the whole mechanism. It scrolls whatever was
141
+ * on screen up into the terminal's own scrollback — the same thing any
142
+ * program that prints does — and leaves the caret on the band's last row.
143
+ * With height = rows - 1 this lands on the bottom row from any starting
144
+ * position, which is what anchors the band without absolute addressing.
145
+ */
146
+ reserve(rows = this.maxHeight) {
147
+ if (this.reserved || this.maxHeight < 1) return false;
148
+ const claim = Math.max(1, Math.min(Math.floor(rows), this.maxHeight));
149
+ this.write('\n'.repeat(claim));
150
+ this.stats.claimed += claim;
151
+ this.reserved = true;
152
+ this.height = claim;
153
+ this.cursor = claim - 1;
154
+ this.prev = new Array(claim).fill(null);
155
+ this.caret = { row: claim - 1, col: 0 };
156
+ return true;
157
+ }
158
+
159
+ /**
160
+ * Claim more rows, up to the ceiling.
161
+ *
162
+ * Newlines emitted from the band's last row scroll the screen and leave the
163
+ * new rows at the BOTTOM, so in band coordinates growth appends: rows 0..h-1
164
+ * keep what they had and the new ones arrive blank. That is why `prev` is
165
+ * extended with nulls rather than rebuilt — the diff keeps everything it
166
+ * already knows and only pays for what actually changed.
167
+ *
168
+ * Growth scrolls whatever is above the band up by the same amount, which is
169
+ * what any program that prints does, and is the only thing UX-01 rev 2 clause
170
+ * (b) permits to move it.
171
+ */
172
+ grow(delta) {
173
+ if (!this.reserved || delta <= 0) return 0;
174
+ const room = this.maxHeight - this.height;
175
+ const claim = Math.min(Math.floor(delta), room);
176
+ if (claim <= 0) return 0;
177
+
178
+ this.moveTo(this.height - 1);
179
+ this.write('\n'.repeat(claim));
180
+ this.stats.claimed += claim;
181
+ this.height += claim;
182
+ this.cursor = this.height - 1;
183
+ for (let i = 0; i < claim; i++) this.prev.push(null);
184
+ return claim;
185
+ }
186
+
187
+ /**
188
+ * Normalise a caller's rows to exactly `height` rows, each safe to write.
189
+ *
190
+ * The width clamp is the invariant everything rests on and it is the same one
191
+ * ChatPrompt.clampRows() enforces: a row wider than the terminal occupies TWO
192
+ * physical rows, and one wrap desynchronises every relative move that follows
193
+ * it. The spare column also avoids the terminal's deferred-wrap state at the
194
+ * exact margin.
195
+ */
196
+ normalize(rows) {
197
+ const limit = Math.max(1, this.width() - 1);
198
+ const out = new Array(this.height);
199
+ for (let i = 0; i < this.height; i++) {
200
+ let row = rows && rows[i] != null ? String(rows[i]) : '';
201
+ if (row.includes('\n')) row = row.split('\n')[0];
202
+ out[i] = visibleWidth(row) <= limit ? row : truncate(row, limit, '');
203
+ }
204
+ return out;
205
+ }
206
+
207
+ moveTo(row) {
208
+ const target = Math.max(0, Math.min(row, this.height - 1));
209
+ const delta = target - this.cursor;
210
+ // CUU/CUD only. Never a newline: on the band's last row a newline scrolls
211
+ // the terminal, which would move the whole block out from under the diff.
212
+ if (delta < 0) this.write(`\x1b[${-delta}A`);
213
+ else if (delta > 0) this.write(`\x1b[${delta}B`);
214
+ this.cursor = target;
215
+ }
216
+
217
+ /**
218
+ * Repaint the band. Rows that did not change cost nothing.
219
+ *
220
+ * Returns the number of rows actually written, so a test can assert that an
221
+ * idle frame is free.
222
+ */
223
+ paint(rows, caret = null) {
224
+ if (!this.reserved) return 0;
225
+
226
+ const next = this.normalize(rows);
227
+ const target = caret && Number.isFinite(caret.row)
228
+ ? { row: Math.max(0, Math.min(Math.floor(caret.row), this.height - 1)), col: Math.max(0, Math.floor(caret.col) || 0) }
229
+ : { row: this.height - 1, col: 0 };
230
+
231
+ const changed = [];
232
+ for (let i = 0; i < this.height; i++) {
233
+ if (next[i] !== this.prev[i]) changed.push(i);
234
+ }
235
+
236
+ const caretMoved = target.row !== this.caret.row || target.col !== this.caret.col;
237
+ // A frame with nothing to say emits zero bytes. opencode's renderer makes
238
+ // the same bet and calls it out as its main anti-flicker mechanism.
239
+ if (changed.length === 0 && !caretMoved) return 0;
240
+
241
+ this.write(SYNC_ON + HIDE_CURSOR);
242
+ for (const i of changed) {
243
+ this.moveTo(i);
244
+ this.write('\r' + next[i] + ERASE_LINE);
245
+ }
246
+ this.moveTo(target.row);
247
+ this.write('\r');
248
+ if (target.col > 0) this.write(`\x1b[${target.col}C`);
249
+ this.write(SHOW_CURSOR + SYNC_OFF);
250
+
251
+ this.prev = next;
252
+ this.caret = target;
253
+ this.stats.paints++;
254
+ this.stats.rowsWritten += changed.length;
255
+ return changed.length;
256
+ }
257
+
258
+ /**
259
+ * Give the band back.
260
+ *
261
+ * Erases from the band's top row down. ED 0 clears from the caret to the end
262
+ * of the screen, which is exactly the band and nothing above it. It is not
263
+ * the whole-screen erase (ED 2) that UX-01 bans, and the source scan in
264
+ * tests/chatSurface.test.js is what keeps the two from being confused.
265
+ */
266
+ collapse() {
267
+ if (!this.reserved) return;
268
+ this.write(SYNC_ON);
269
+ this.moveTo(0);
270
+ this.write('\r' + ERASE_BELOW + SHOW_CURSOR + SYNC_OFF);
271
+ this.reserved = false;
272
+ this.cursor = 0;
273
+ this.prev = [];
274
+ }
275
+
276
+ /**
277
+ * The exit contract: the session becomes permanent terminal history.
278
+ *
279
+ * Call after collapse(). opencode's alternate-screen TUI leaves nothing
280
+ * behind and its split-footer mode leaves everything but cannot scroll;
281
+ * doing both is the point of the Stage.
282
+ */
283
+ printThrough(lines) {
284
+ if (!Array.isArray(lines) || lines.length === 0) return 0;
285
+ let written = 0;
286
+ let chunk = '';
287
+ for (const line of lines) {
288
+ chunk += (line == null ? '' : String(line)) + '\n';
289
+ written++;
290
+ if (chunk.length > 16384) { this.write(chunk); chunk = ''; }
291
+ }
292
+ if (chunk) this.write(chunk);
293
+ return written;
294
+ }
295
+
296
+ /**
297
+ * Terminal resized.
298
+ *
299
+ * Collapse and re-reserve rather than trying to grow or shrink in place. The
300
+ * band's geometry is the one thing the diff cannot recover from being wrong
301
+ * about, and a resize already costs a full reflow of the transcript; one
302
+ * extra scroll per settled gesture is the cheap half. Callers must debounce
303
+ * (ChatPrompt already settles resize at 60ms).
304
+ */
305
+ resize(height = 0, claim = 0) {
306
+ const next = height > 0 ? Math.floor(height) : stageHeight(this.out, this.env);
307
+ const wasReserved = this.reserved;
308
+ if (wasReserved) this.collapse();
309
+ this.maxHeight = next;
310
+ this.height = 0;
311
+ if (next < 1) return false;
312
+ if (wasReserved) return this.reserve(claim > 0 ? claim : next);
313
+ return false;
314
+ }
315
+
316
+ /**
317
+ * Force the next paint to redraw every row.
318
+ *
319
+ * Anything that writes to the terminal behind the Stage's back (an evicted
320
+ * frame flushed to scrollback, a print-through) invalidates the diff.
321
+ */
322
+ invalidate() {
323
+ this.prev = new Array(this.height).fill(null);
324
+ }
325
+
326
+ /** Rows still available before the band hits its ceiling. */
327
+ get room() {
328
+ return Math.max(0, this.maxHeight - this.height);
329
+ }
330
+ }