@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,606 @@
1
+ // The concrete dialogs.
2
+ //
3
+ // Every one of these is the SelectList or a scrolling text pane inside a Layer.
4
+ // That is the payoff of building the primitives properly: opencode needs twenty
5
+ // `dialog-*.tsx` files because each one re-declares its own layout; here a
6
+ // picker is a title, a list of items and a callback.
7
+ //
8
+ // The one that is not a picker is ApprovalLayer, and it is the reason this file
9
+ // exists at all. Causyne's approval used inquirer, which means the question is
10
+ // asked in the scrollback, below whatever the agent last printed, with the diff
11
+ // already scrolled away above it. As a modal the risk badge, the tool, the
12
+ // arguments and the full scrollable diff sit in one frame that the user cannot
13
+ // lose — and neither reference does this: openclaw approves in a plain prompt,
14
+ // opencode's permission dialog does not scroll its diff by keyboard.
15
+ //
16
+ // Pure Node over ./selectList.js, ./overlay.js, ./theme.js, ./text.js.
17
+ // No dependency, no shell.
18
+
19
+ import { Layer, dialogHint } from './overlay.js';
20
+ import { SelectList } from './selectList.js';
21
+ import { LineEditor } from './lineEditor.js';
22
+ import { paint, bold, dim, badge, GLYPHS } from './theme.js';
23
+ import { truncate, wrap, visibleWidth, safeDisplay, shortenHome } from './text.js';
24
+ import { approvalDiff } from './diff.js';
25
+ import { isChar } from './keys.js';
26
+
27
+ /**
28
+ * A picker. `items` are SelectList items; `onPick(item)` fires on enter.
29
+ *
30
+ * onPick may return 'keep' to leave the dialog open — which is what a theme
31
+ * picker wants, so the user can see each theme applied before committing.
32
+ */
33
+ export class SelectLayer extends Layer {
34
+ constructor(options = {}) {
35
+ super({ width: options.width || 68, height: options.height || 18, ...options });
36
+ this.list = new SelectList(options.items || [], {
37
+ placeholder: options.placeholder,
38
+ emptyText: options.emptyText
39
+ });
40
+ this.onPick = options.onPick || null;
41
+ this.footer = options.footer || dialogHint();
42
+ }
43
+
44
+ preferredHeight() {
45
+ // filter row + rule + one row per item (+ its category header) + frame.
46
+ const categories = new Set(this.list.filtered.map((entry) => entry.item.category).filter(Boolean));
47
+ return 2 + this.list.filtered.length + categories.size + 2;
48
+ }
49
+
50
+ body(width, height) {
51
+ const { rows } = this.list.render(width, height);
52
+ this.cursor = { x: this.list.cursorColumn(), y: 0 };
53
+ return rows;
54
+ }
55
+
56
+ key(event) {
57
+ // Scroll by the budget the RENDERER used, not by one derived from the
58
+ // layer's configured height. The panel gives the body
59
+ // `min(preferredHeight, maxHeight) - 2` rows and the list then spends two
60
+ // more on the filter and its rule and one on the position counter — so this
61
+ // used to move the selection by 17 rows through a list that was drawing 15.
62
+ // The list publishes what it actually budgeted on its last render; before
63
+ // the first render there is nothing to disagree with, so the estimate stands.
64
+ const visibleRows = this.list.lastItemRows || Math.max(1, this.height - 5);
65
+ const action = this.list.handleKey(event, visibleRows);
66
+
67
+ if (action === 'select') {
68
+ const item = this.list.current;
69
+ const result = this.onPick ? this.onPick(item) : null;
70
+ return result === 'keep' ? 'handled' : 'close';
71
+ }
72
+ if (action === 'cancel') return 'close';
73
+ return action ? 'handled' : null;
74
+ }
75
+ }
76
+
77
+
78
+ export class InputLayer extends Layer {
79
+ constructor(options = {}) {
80
+ super({ width: options.width || 68, height: options.height || 10, ...options });
81
+ this.editor = new LineEditor(String(options.value == null ? '' : options.value));
82
+ this.lines = Array.isArray(options.lines) ? options.lines : [];
83
+ this.mask = options.mask === true;
84
+ this.placeholder = options.placeholder || '';
85
+ this.onSubmit = options.onSubmit || null;
86
+ this.onCancel = options.onCancel || null;
87
+ this.footer = options.footer || `enter accept ${GLYPHS.dot} esc cancel`;
88
+ this.settled = false;
89
+ }
90
+
91
+ // Blurb rows + a blank + the input row, inside the frame.
92
+ preferredHeight() { return this.lines.length + (this.lines.length ? 1 : 0) + 1 + 2; }
93
+
94
+ /** Dismissal is a cancel, so the caller's promise can never hang. */
95
+ answer() {
96
+ if (this.settled) return;
97
+ this.settled = true;
98
+ if (this.onCancel) this.onCancel();
99
+ }
100
+
101
+ body(width) {
102
+ const rows = this.lines.map((line) => truncate(line, width, GLYPHS.ellipsis));
103
+ if (rows.length) rows.push('');
104
+
105
+ const raw = this.editor.getText();
106
+ const shown = this.mask ? GLYPHS.bullet.repeat(raw.length) : raw;
107
+ const text = shown.length === 0 && this.placeholder ? dim(this.placeholder) : shown;
108
+
109
+ const prefix = paint('accent', `${GLYPHS.arrow} `);
110
+ rows.push(truncate(prefix + text, width, GLYPHS.ellipsis));
111
+
112
+ // Park the caret after what is displayed — which for a masked field is the
113
+ // dot count, not the character count, or the caret drifts off the value.
114
+ this.cursor = { x: 2 + (this.mask ? raw.length : visibleWidth(raw.slice(0, this.editor.col))), y: rows.length - 1 };
115
+ return rows;
116
+ }
117
+
118
+ key(event) {
119
+ if (!event) return null;
120
+
121
+ switch (event.name) {
122
+ case 'escape':
123
+ return 'close';
124
+ case 'enter':
125
+ case 'return': {
126
+ if (this.settled) return 'close';
127
+ this.settled = true;
128
+ const value = this.editor.getText();
129
+ if (this.onSubmit) this.onSubmit(value);
130
+ return 'close';
131
+ }
132
+ case 'backspace':
133
+ if (event.alt) this.editor.killWordLeft(); else this.editor.backspace();
134
+ return 'handled';
135
+ case 'delete': this.editor.deleteForward(); return 'handled';
136
+ case 'left': this.editor.moveLeft(); return 'handled';
137
+ case 'right': this.editor.moveRight(); return 'handled';
138
+ case 'home': this.editor.moveHome(); return 'handled';
139
+ case 'end': this.editor.moveEnd(); return 'handled';
140
+ case 'char': this.editor.insert(event.text); return 'handled';
141
+ case 'paste': this.editor.insert(event.text); return 'handled';
142
+ default: break;
143
+ }
144
+
145
+ if (event.ctrl) {
146
+ switch (event.name) {
147
+ case 'a': this.editor.moveHome(); return 'handled';
148
+ case 'e': this.editor.moveEnd(); return 'handled';
149
+ case 'k': this.editor.killToEnd(); return 'handled';
150
+ case 'u': this.editor.killToStart(); return 'handled';
151
+ case 'w': this.editor.killWordLeft(); return 'handled';
152
+ default: break;
153
+ }
154
+ }
155
+
156
+ // Stay modal for anything unrecognised: a stray key must not reach the
157
+ // input line behind a question that is still open.
158
+ return 'handled';
159
+ }
160
+ }
161
+
162
+ /**
163
+ * The home menu: the action list that sits above the input line.
164
+ *
165
+ * This is deliberately NOT a SelectLayer. A SelectLayer owns the keyboard and
166
+ * filters on every keystroke, which is exactly wrong here — home shows the menu
167
+ * and the chat input in ONE frame, so `↑↓` must pick an action while typing must
168
+ * reach the input line underneath. A filter row would also be a lie: there is
169
+ * nothing to type into it.
170
+ *
171
+ * So this layer claims four keys and hands everything else back:
172
+ *
173
+ * ↑ ↓ move the selection → 'handled'
174
+ * enter run the selected action → 'close' (onPick fires first)
175
+ * esc put the menu away → 'close'
176
+ * else not mine → 'dismiss' for a printable character,
177
+ * 'passthrough' for anything else
178
+ *
179
+ * 'dismiss' tears the menu down AND lets the key through, so the first character
180
+ * you type both closes the menu and appears in the input — one keystroke, not
181
+ * two. 'passthrough' leaves the menu up, so ctrl+c, ctrl+p and friends keep
182
+ * working while it is open. Every other Layer returns null and stays modal; the
183
+ * invariant is unchanged for them.
184
+ */
185
+ export class HomeLayer extends Layer {
186
+ constructor(options = {}) {
187
+ super({ width: options.width || 66, height: options.height || 14, ...options });
188
+ this.items = Array.isArray(options.items) ? options.items : [];
189
+ this.index = 0;
190
+ this.onPick = options.onPick || null;
191
+ this.title = options.title || 'what would you like to do?';
192
+ this.footer = options.footer || `↑↓ pick ${GLYPHS.dot} enter run ${GLYPHS.dot} esc menu off ${GLYPHS.dot} or just start typing`;
193
+ }
194
+
195
+ // No filter row and no rule — one row per item, plus the frame.
196
+ preferredHeight() { return this.items.length + 2; }
197
+
198
+ get current() { return this.items[this.index] || null; }
199
+
200
+ body(width) {
201
+ // The hint column is right-padded from the widest label so the two columns
202
+ // line up, but it is computed from the ACTUAL labels rather than a constant:
203
+ // a hardcoded inset is what makes a menu look off by one at some widths.
204
+ const labelWidth = this.items.reduce((max, item) => Math.max(max, visibleWidth(item.label)), 0);
205
+ return this.items.map((item, i) => {
206
+ const selected = i === this.index;
207
+ const marker = selected ? paint('accent', GLYPHS.arrow) : ' ';
208
+ const label = selected ? bold(paint('accent', item.label)) : item.label;
209
+ const pad = ' '.repeat(Math.max(0, labelWidth - visibleWidth(item.label)));
210
+ const hint = item.hint ? dim(item.hint) : '';
211
+ // truncate() is ANSI-aware, so styling the label first is safe. The row is
212
+ // clamped to the panel, never to the terminal — the panel is narrower.
213
+ return truncate(` ${marker} ${label}${pad} ${hint}`, width);
214
+ });
215
+ }
216
+
217
+ key(event) {
218
+ if (!event) return null;
219
+
220
+ switch (event.name) {
221
+ case 'up': this.index = (this.index - 1 + this.items.length) % this.items.length; return 'handled';
222
+ case 'down': this.index = (this.index + 1) % this.items.length; return 'handled';
223
+ case 'enter':
224
+ case 'return': {
225
+ const item = this.current;
226
+ if (!item) return 'close';
227
+ const result = this.onPick ? this.onPick(item) : null;
228
+ return result === 'keep' ? 'handled' : 'close';
229
+ }
230
+ case 'escape': return 'close';
231
+ default: break;
232
+ }
233
+
234
+ // A printable character is the user starting a sentence. Put the menu away
235
+ // and let the character land in the input line in the same keystroke.
236
+ if (event.name === 'char' || event.name === 'paste') return 'dismiss';
237
+
238
+ // Anything else — ctrl chords, tab, backspace on an empty line — belongs to
239
+ // the prompt, and the menu has no opinion about it.
240
+ return 'passthrough';
241
+ }
242
+ }
243
+
244
+ /**
245
+ * A scrolling text pane — help, usage, an expanded tool output, an error.
246
+ *
247
+ * Takes already-styled rows and scrolls them. It exists so that "show me the
248
+ * whole thing" never again means dumping 900 lines into the transcript.
249
+ */
250
+ export class TextLayer extends Layer {
251
+ constructor(options = {}) {
252
+ super({ width: options.width || 76, height: options.height || 20, ...options });
253
+ this.lines = Array.isArray(options.lines) ? options.lines : String(options.text || '').split('\n');
254
+ this.scroll = 0;
255
+ this.footer = options.footer || `↑↓ scroll ${GLYPHS.dot} esc close`;
256
+ }
257
+
258
+ preferredHeight() { return this.lines.length + 2; }
259
+
260
+ body(width, height) {
261
+ this.viewHeight = height;
262
+ const maximum = Math.max(0, this.lines.length - height);
263
+ this.scroll = Math.max(0, Math.min(this.scroll, maximum));
264
+
265
+ const window = this.lines.slice(this.scroll, this.scroll + height)
266
+ .map((line) => truncate(line, width, GLYPHS.ellipsis));
267
+
268
+ if (this.lines.length > height) {
269
+ const position = `${this.scroll + 1}-${Math.min(this.lines.length, this.scroll + height)}/${this.lines.length}`;
270
+ while (window.length < height) window.push('');
271
+ window[height - 1] = dim(position.padStart(width));
272
+ }
273
+ this.cursor = null;
274
+ return window;
275
+ }
276
+
277
+ key(event) {
278
+ if (!event) return null;
279
+ const page = Math.max(1, (this.viewHeight || this.height) - 1);
280
+ switch (event.name) {
281
+ case 'escape':
282
+ case 'q': return 'close';
283
+ case 'up': this.scroll--; return 'handled';
284
+ case 'down': this.scroll++; return 'handled';
285
+ case 'pageup': this.scroll -= page; return 'handled';
286
+ case 'pagedown': this.scroll += page; return 'handled';
287
+ case 'home': this.scroll = 0; return 'handled';
288
+ case 'end': this.scroll = this.lines.length; return 'handled';
289
+ case 'return': return 'close';
290
+ default: return 'handled';
291
+ }
292
+ }
293
+ }
294
+
295
+ /**
296
+ * The approval modal.
297
+ *
298
+ * Answers three questions in one frame, none of which the old prompt answered:
299
+ * how dangerous is this, what exactly will it do, and what will change on disk.
300
+ * The diff scrolls, so a 400-line rewrite is inspectable rather than truncated.
301
+ *
302
+ * Deliberately has NO default. `y` and `n` are explicit, enter is not bound, and
303
+ * escape denies. A dialog where the safe answer is one keypress away from the
304
+ * dangerous one is how a person approves something they meant to read.
305
+ */
306
+ export class ApprovalLayer extends Layer {
307
+ constructor(options = {}) {
308
+ super({ width: options.width || 84, height: options.height || 22, ...options });
309
+ this.request = options.request || {};
310
+ this.detail = options.detail || '';
311
+ this.verb = options.verb || '';
312
+ this.onAnswer = options.onAnswer || null;
313
+ this.scroll = 0;
314
+
315
+ // The risk TIER is kept for colour and for the audit trail; it is never
316
+ // shown as a word. Printing 'DANGEROUS' at someone who asked to install a
317
+ // package does not make them careful, it makes them numb — and a gate people
318
+ // are numb to is not a gate. opencode shows a warning triangle and the tool,
319
+ // and that is the whole signal: being asked at all IS the warning.
320
+ const risk = String(this.request.risk || 'unknown').toUpperCase();
321
+ this.risk = risk;
322
+ this.role = risk === 'EXTREME' ? 'danger' : risk === 'DANGEROUS' ? 'warn' : 'accent';
323
+ this.title = 'permission required';
324
+
325
+ // Selectable options, modelled on opencode's permission prompt: a row of
326
+ // chips moved with ←/→ and committed with enter. Typing y/n still answers
327
+ // directly, so the fast path stays one keystroke rather than two.
328
+ this.options = [
329
+ { id: 'once', label: 'Allow once', answer: true },
330
+ // Standing approval for the rest of the PROCESS — never written to disk.
331
+ // Seven developers in ten would rather grant this than be asked again,
332
+ // and a gate people hammer through is worse than one they chose to lift.
333
+ // It is not the default and it is confirmed a second time before it takes
334
+ // effect; see makeApprovalHandler in cliMode.js.
335
+ { id: 'always', label: 'Allow always', answer: true },
336
+ { id: 'reject', label: 'Reject', answer: false }
337
+ ];
338
+
339
+ // THE DEFAULT IS DENY, AND THAT IS DELIBERATE.
340
+ //
341
+ // opencode starts on its approving option, which suits a coding session
342
+ // where the worst case is an unwanted file edit. Causyne's approval gate
343
+ // also stands in front of `sysRun` and `desktop_act` — arbitrary shell and
344
+ // full control of the user's mouse and keyboard. Enter is the key people
345
+ // press without reading, so it must not be the one that says yes.
346
+ //
347
+ // Approving is still a single keystroke (`y`); it is just not the REFLEX
348
+ // keystroke. Set the highlight to Approve here and one careless enter
349
+ // authorises anything the agent asks for.
350
+ this.selected = this.options.findIndex(option => option.id === 'reject');
351
+
352
+ this.footer = `←→ choose ${GLYPHS.dot} enter confirm ${GLYPHS.dot} y/a/n direct ${GLYPHS.dot} ↑↓ scroll ${GLYPHS.dot} esc reject`;
353
+
354
+ this.diff = approvalDiff(this.request.tool, this.request.args, { width: this.width - 6, maxLines: 400 });
355
+ }
356
+
357
+ preferredHeight() {
358
+ const detailRows = this.detail ? 1 : 0;
359
+ // A diff costs a blank, a stat row and a rule on top of its own rows.
360
+ const diffRows = this.diff ? this.diff.rows.length + 3 : Object.keys(this.request.args || {}).length + 1;
361
+ // +2 for the blank line and the option row that always sit at the bottom.
362
+ return 1 + detailRows + diffRows + 2 + 2;
363
+ }
364
+
365
+ /** The chip row: the selected option is filled, the rest are dimmed. */
366
+ optionRow() {
367
+ return this.options
368
+ .map((option, index) => index === this.selected
369
+ ? badge(option.id === 'reject' ? 'danger' : 'success', option.label)
370
+ : dim(` ${option.label} `))
371
+ .join(' ');
372
+ }
373
+
374
+ body(width, fullHeight) {
375
+ // Two rows are reserved at the bottom for the blank line and the option
376
+ // chips, so the diff never grows into the controls and push them off-panel.
377
+ const height = Math.max(1, fullHeight - 2);
378
+ const rows = [];
379
+ rows.push(`${paint(this.role, GLYPHS.warnMark)} ${bold(safeDisplay(this.request.tool || ''))}${this.verb ? dim(` ${this.verb}`) : ''}`);
380
+
381
+ if (this.detail) {
382
+ for (const line of wrap(paint('ink', this.detail), width).slice(0, 2)) rows.push(line);
383
+ }
384
+
385
+ if (this.diff) {
386
+ const stat = [
387
+ this.diff.added ? paint('success', `+${this.diff.added}`) : '',
388
+ this.diff.removed ? paint('danger', `-${this.diff.removed}`) : ''
389
+ ].filter(Boolean).join(' ');
390
+ const note = this.diff.note ? dim(` ${GLYPHS.dot} ${this.diff.note}`) : '';
391
+ // The detail row above already names the file; printing the path again
392
+ // here just spends a row saying the same thing twice.
393
+ const where = this.detail ? '' : `${dim(this.diff.path)} `;
394
+ rows.push('');
395
+ rows.push(`${where}${stat}${note}`);
396
+ rows.push(paint('rule', GLYPHS.hLine.repeat(width)));
397
+
398
+ // Only spend a row on the scroll indicator when there is actually
399
+ // something out of view. Reserving it unconditionally cost one diff row
400
+ // on every dialog that fit perfectly.
401
+ const available = Math.max(1, height - rows.length);
402
+ const needsIndicator = this.diff.rows.length > available;
403
+ const room = needsIndicator ? Math.max(1, available - 1) : available;
404
+ this.viewHeight = room;
405
+ const maximum = Math.max(0, this.diff.rows.length - room);
406
+ this.scroll = Math.max(0, Math.min(this.scroll, maximum));
407
+
408
+ for (const line of this.diff.rows.slice(this.scroll, this.scroll + room)) {
409
+ rows.push(truncate(line, width, GLYPHS.ellipsis));
410
+ }
411
+ if (this.diff.rows.length > room) {
412
+ rows.push(dim(`${this.scroll + 1}-${Math.min(this.diff.rows.length, this.scroll + room)}/${this.diff.rows.length}`.padStart(width)));
413
+ }
414
+ } else if (this.request.args) {
415
+ rows.push('');
416
+ // No diff means this is not a file mutation; show the arguments instead,
417
+ // because "approve sysRun" with the command hidden is not consent either.
418
+ for (const [key, value] of Object.entries(this.request.args).slice(0, 8)) {
419
+ const shown = typeof value === 'string' ? shortenHome(value) : JSON.stringify(value);
420
+ for (const line of wrap(`${paint('muted', key)} ${paint('ink', safeDisplay(String(shown)))}`, width).slice(0, 4)) {
421
+ rows.push(line);
422
+ if (rows.length >= height) break;
423
+ }
424
+ if (rows.length >= height) break;
425
+ }
426
+ }
427
+
428
+ while (rows.length < height) rows.push('');
429
+ rows.push('');
430
+ rows.push(this.optionRow());
431
+
432
+ this.cursor = null;
433
+ return rows.slice(0, fullHeight);
434
+ }
435
+
436
+ key(event) {
437
+ if (!event) return null;
438
+ const page = Math.max(1, (this.viewHeight || 8) - 1);
439
+
440
+ // isChar(), NOT event.name. A printable key decodes to {name:'char',
441
+ // text:'y'} — only MODIFIED letters carry the letter as a name. Testing
442
+ // event.name === 'y' here matched ctrl+y and never matched y, so this
443
+ // dialog could not be approved at all; escape worked, which made it look
444
+ // responsive. See src/tui/keys.js isChar().
445
+ if (isChar(event, 'y')) { this.answer(true, 'once'); return 'close'; }
446
+ if (isChar(event, 'a')) { this.answer(true, 'always'); return 'close'; }
447
+ if (isChar(event, 'n')) { this.answer(false, 'reject'); return 'close'; }
448
+ if (event.name === 'escape') { this.answer(false, 'reject'); return 'close'; }
449
+
450
+ // ←/→ move the highlight, enter commits it — opencode's permission model,
451
+ // which is the one people already have muscle memory for. `h`/`l` come
452
+ // along for the same reason opencode binds them: a vim user reaches for
453
+ // them without thinking, and they cost nothing here.
454
+ const move = (delta) => {
455
+ this.selected = (this.selected + delta + this.options.length) % this.options.length;
456
+ return 'handled';
457
+ };
458
+ if (event.name === 'left' || isChar(event, 'h')) return move(-1);
459
+ if (event.name === 'right' || isChar(event, 'l')) return move(1);
460
+ if (event.name === 'enter' || event.name === 'return') {
461
+ const choice = this.options[this.selected];
462
+ this.answer(Boolean(choice && choice.answer), choice ? choice.id : 'reject');
463
+ return 'close';
464
+ }
465
+
466
+ switch (event.name) {
467
+ case 'up': this.scroll--; return 'handled';
468
+ case 'down': this.scroll++; return 'handled';
469
+ case 'pageup': this.scroll -= page; return 'handled';
470
+ case 'pagedown': this.scroll += page; return 'handled';
471
+ case 'home': this.scroll = 0; return 'handled';
472
+ case 'end': this.scroll = this.diff ? this.diff.rows.length : 0; return 'handled';
473
+ default: return 'handled';
474
+ }
475
+ }
476
+
477
+ /**
478
+ * Deliver the decision exactly once.
479
+ *
480
+ * `scope` says WHICH option produced it — 'once', 'always' or 'reject'.
481
+ * closeLayer() calls this with no arguments when a dialog is torn down from
482
+ * outside, and the defaults make that a rejection, which is the safe reading
483
+ * of a question nobody answered.
484
+ */
485
+ answer(approved = false, scope = 'reject') {
486
+ if (this.answered) return;
487
+ this.answered = true;
488
+ if (this.onAnswer) this.onAnswer(approved, scope);
489
+ }
490
+ }
491
+
492
+ /** A yes/no confirm, for destructive commands that are not tool approvals. */
493
+ export class ConfirmLayer extends Layer {
494
+ constructor(options = {}) {
495
+ super({ width: options.width || 60, height: options.height || 8, ...options });
496
+ this.message = options.message || 'Are you sure?';
497
+ this.onAnswer = options.onAnswer || null;
498
+ this.footer = `y yes ${GLYPHS.dot} n no ${GLYPHS.dot} esc cancel`;
499
+ this.settled = false;
500
+ }
501
+
502
+ /**
503
+ * Dismissal is an ANSWER, and the safe answer is no.
504
+ *
505
+ * ChatPrompt.closeLayer() calls this on any layer that defines it. Without it
506
+ * a confirm torn down from outside — an interrupt, a resize teardown, the
507
+ * host stopping — left its promise pending forever and hung the caller
508
+ * mid-approval. The `settled` guard is what makes the y/n path and this path
509
+ * safe to both fire.
510
+ */
511
+ answer(value = false) {
512
+ if (this.settled) return;
513
+ this.settled = true;
514
+ if (this.onAnswer) this.onAnswer(Boolean(value));
515
+ }
516
+
517
+ preferredHeight() { return 6; }
518
+
519
+ body(width) {
520
+ this.cursor = null;
521
+ return ['', ...wrap(paint('ink', safeDisplay(this.message)), width)];
522
+ }
523
+
524
+ key(event) {
525
+ if (!event) return null;
526
+ // Only an explicit `y` approves. Every other key either says no or is
527
+ // ignored — a mistyped keypress must never approve.
528
+ if (isChar(event, 'y')) { this.answer(true); return 'close'; }
529
+ if (isChar(event, 'n') || event.name === 'escape') { this.answer(false); return 'close'; }
530
+ return 'handled';
531
+ }
532
+ }
533
+
534
+ /** The command palette — every command in the app, fuzzy-searchable. */
535
+ export function commandPalette(commands, onRun) {
536
+ return new SelectLayer({
537
+ title: 'commands',
538
+ placeholder: 'search commands',
539
+ emptyText: 'no command matches',
540
+ width: 72,
541
+ height: 20,
542
+ items: commands.map((command) => ({
543
+ label: command.name,
544
+ description: command.description || '',
545
+ category: command.category || '',
546
+ hint: command.hint || '',
547
+ searchText: command.description || '',
548
+ value: command
549
+ })),
550
+ onPick: (item) => { if (item && onRun) onRun(item.value); }
551
+ });
552
+ }
553
+
554
+ /**
555
+ * The skill browser: 100 skills, fuzzy-searchable, grouped by domain.
556
+ *
557
+ * `/skills` used to print every skill as one unwrapped line. At five skills that
558
+ * was merely ugly — descriptions reached 195 columns on a 102-column terminal
559
+ * and the terminal broke them mid-word at column 0. At 100 it would be a wall of
560
+ * text nobody reads. Both references browse skills in a picker instead
561
+ * (opencode's dialog-skill.tsx), and the picker truncates each description to
562
+ * the pane rather than to the terminal, so overflow is structurally impossible.
563
+ */
564
+ export function skillPicker(skills, onOpen) {
565
+ return new SelectLayer({
566
+ title: `skills (${skills.length})`,
567
+ placeholder: 'search skills',
568
+ emptyText: 'no skill matches',
569
+ width: 76,
570
+ height: 22,
571
+ items: skills.map((skill) => ({
572
+ label: `${skill.domain}/${skill.id}`,
573
+ description: skill.description || '',
574
+ category: skill.domain,
575
+ searchText: `${skill.name} ${skill.description || ''}`,
576
+ value: skill
577
+ })),
578
+ onPick: (item) => { if (item && onOpen) onOpen(item.value); }
579
+ });
580
+ }
581
+
582
+ /** Help: the keys, grouped, in a scrolling pane. */
583
+ export function helpLayer(sections) {
584
+ const lines = [];
585
+ for (const section of sections) {
586
+ lines.push(bold(paint('ink', section.title)));
587
+ for (const [keys, what] of section.rows) {
588
+ lines.push(` ${paint('accent', keys.padEnd(18))}${dim(what)}`);
589
+ }
590
+ lines.push('');
591
+ }
592
+ return new TextLayer({ title: 'keys', lines, width: 62, height: Math.min(24, lines.length + 3) });
593
+ }
594
+
595
+ /** A status pane: session facts that would otherwise need a scrollback hunt. */
596
+ export function statusLayer(rows) {
597
+ const width = rows.reduce((max, row) => Math.max(max, row[0].length), 0);
598
+ const lines = rows.map(([label, value]) => (
599
+ label === ''
600
+ ? ''
601
+ : `${paint('muted', String(label).padEnd(width + 2))}${paint('ink', safeDisplay(String(value)))}`
602
+ ));
603
+ return new TextLayer({ title: 'session', lines, width: 74, height: Math.min(24, lines.length + 3) });
604
+ }
605
+
606
+ export { visibleWidth };