@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.
- package/LICENSE +102 -0
- package/README.md +31 -2
- package/SECURITY.md +24 -0
- package/bin/causyne.js +13 -0
- package/docs/ARCHITECTURE.md +63 -0
- package/docs/PROVENANCE.md +29 -0
- package/docs/SECURITY_REVIEW.md +89 -0
- package/docs/THIRD_PARTY_NOTICES.md +49 -0
- package/native-skills/ai-engineering/context-window-management/SKILL.md +43 -0
- package/native-skills/ai-engineering/llm-evaluation/SKILL.md +46 -0
- package/native-skills/ai-engineering/model-cost-and-latency/SKILL.md +46 -0
- package/native-skills/ai-engineering/prompt-construction/SKILL.md +42 -0
- package/native-skills/ai-engineering/retrieval-augmented-generation/SKILL.md +42 -0
- package/native-skills/ai-engineering/structured-output-and-schemas/SKILL.md +45 -0
- package/native-skills/ai-engineering/tool-calling-agents/SKILL.md +43 -0
- package/native-skills/android/app-performance-profiling/SKILL.md +42 -0
- package/native-skills/android/background-work-scheduling/SKILL.md +47 -0
- package/native-skills/android/compose-state-and-recomposition/SKILL.md +43 -0
- package/native-skills/android/gradle-build-configuration/SKILL.md +41 -0
- package/native-skills/android/lifecycle-and-leaks/SKILL.md +46 -0
- package/native-skills/android/play-store-release/SKILL.md +43 -0
- package/native-skills/android/room-and-persistence/SKILL.md +40 -0
- package/native-skills/backend/authentication-and-sessions/SKILL.md +45 -0
- package/native-skills/backend/background-jobs-and-queues/SKILL.md +42 -0
- package/native-skills/backend/caching-strategy/SKILL.md +44 -0
- package/native-skills/backend/graceful-shutdown/SKILL.md +42 -0
- package/native-skills/backend/rate-limiting-and-quotas/SKILL.md +40 -0
- package/native-skills/backend/rest-api-design/SKILL.md +40 -0
- package/native-skills/backend/webhook-delivery-and-receipt/SKILL.md +43 -0
- package/native-skills/cloud/cost-optimisation/SKILL.md +46 -0
- package/native-skills/cloud/iam-least-privilege/SKILL.md +43 -0
- package/native-skills/cloud/infrastructure-as-code/SKILL.md +43 -0
- package/native-skills/cloud/multi-region-and-failover/SKILL.md +43 -0
- package/native-skills/cloud/serverless-patterns/SKILL.md +47 -0
- package/native-skills/data/data-quality-monitoring/SKILL.md +51 -0
- package/native-skills/data/etl-pipeline-reliability/SKILL.md +43 -0
- package/native-skills/data/exploratory-analysis/SKILL.md +43 -0
- package/native-skills/data/metric-definition/SKILL.md +42 -0
- package/native-skills/data/notebook-to-production/SKILL.md +43 -0
- package/native-skills/database/backup-and-recovery/SKILL.md +43 -0
- package/native-skills/database/connection-pooling/SKILL.md +42 -0
- package/native-skills/database/data-integrity-and-constraints/SKILL.md +42 -0
- package/native-skills/database/query-optimisation/SKILL.md +38 -0
- package/native-skills/database/safe-migrations/SKILL.md +47 -0
- package/native-skills/database/schema-design/SKILL.md +38 -0
- package/native-skills/database/transactions-and-isolation/SKILL.md +41 -0
- package/native-skills/design/colour-system-construction/SKILL.md +38 -0
- package/native-skills/design/design-direction-brief/SKILL.md +41 -0
- package/native-skills/design/motion-and-transitions/SKILL.md +38 -0
- package/native-skills/design/responsive-strategy/SKILL.md +39 -0
- package/native-skills/design/spacing-and-rhythm/SKILL.md +38 -0
- package/native-skills/design/type-scale-and-hierarchy/SKILL.md +39 -0
- package/native-skills/design/visual-qa-loop/SKILL.md +42 -0
- package/native-skills/devops/docker-multi-stage-builds/SKILL.md +60 -0
- package/native-skills/devops/github-actions-ci/SKILL.md +60 -0
- package/native-skills/devops/incident-triage/SKILL.md +38 -0
- package/native-skills/devops/kubernetes-deployment-hardening/SKILL.md +64 -0
- package/native-skills/devops/observability-baseline/SKILL.md +38 -0
- package/native-skills/devops/secret-management/SKILL.md +40 -0
- package/native-skills/devops/terraform-state-and-modules/SKILL.md +36 -0
- package/native-skills/devops/zero-downtime-deploys/SKILL.md +40 -0
- package/native-skills/git/bisect-regression-hunt/SKILL.md +43 -0
- package/native-skills/git/branch-strategy/SKILL.md +39 -0
- package/native-skills/git/commit-hygiene/SKILL.md +41 -0
- package/native-skills/git/history-recovery/SKILL.md +44 -0
- package/native-skills/git/large-repository-surgery/SKILL.md +46 -0
- package/native-skills/git/merge-conflict-resolution/SKILL.md +42 -0
- package/native-skills/media/cinematic-3d-shot/SKILL.md +104 -0
- package/native-skills/mobile/app-startup-optimisation/SKILL.md +41 -0
- package/native-skills/mobile/cross-platform-tradeoffs/SKILL.md +42 -0
- package/native-skills/mobile/deep-linking/SKILL.md +39 -0
- package/native-skills/mobile/mobile-storage-security/SKILL.md +45 -0
- package/native-skills/mobile/offline-first-sync/SKILL.md +42 -0
- package/native-skills/mobile/push-notifications/SKILL.md +38 -0
- package/native-skills/performance/algorithmic-complexity/SKILL.md +43 -0
- package/native-skills/performance/concurrency-and-parallelism/SKILL.md +42 -0
- package/native-skills/performance/load-testing/SKILL.md +42 -0
- package/native-skills/performance/memory-and-leak-hunting/SKILL.md +41 -0
- package/native-skills/performance/network-and-payload-efficiency/SKILL.md +42 -0
- package/native-skills/performance/profiling-methodology/SKILL.md +46 -0
- package/native-skills/python/async-correctness/SKILL.md +40 -0
- package/native-skills/python/cli-tool-construction/SKILL.md +44 -0
- package/native-skills/python/data-processing-pipelines/SKILL.md +38 -0
- package/native-skills/python/environment-isolation/SKILL.md +41 -0
- package/native-skills/python/packaging-and-distribution/SKILL.md +56 -0
- package/native-skills/python/pytest-suite-design/SKILL.md +39 -0
- package/native-skills/python/type-hints-and-static-checking/SKILL.md +42 -0
- package/native-skills/security/authorization-model/SKILL.md +42 -0
- package/native-skills/security/crypto-usage/SKILL.md +48 -0
- package/native-skills/security/dependency-supply-chain/SKILL.md +42 -0
- package/native-skills/security/input-validation-and-injection/SKILL.md +44 -0
- package/native-skills/security/secure-file-uploads/SKILL.md +46 -0
- package/native-skills/security/security-headers-and-csp/SKILL.md +44 -0
- package/native-skills/security/threat-modelling/SKILL.md +46 -0
- package/native-skills/testing/end-to-end-test-stability/SKILL.md +42 -0
- package/native-skills/testing/property-based-testing/SKILL.md +46 -0
- package/native-skills/testing/regression-test-from-a-bug/SKILL.md +41 -0
- package/native-skills/testing/snapshot-and-visual-regression/SKILL.md +42 -0
- package/native-skills/testing/test-doubles-and-mocking/SKILL.md +43 -0
- package/native-skills/testing/test-strategy-and-pyramid/SKILL.md +39 -0
- package/native-skills/testing/writing-good-assertions/SKILL.md +42 -0
- package/native-skills/web/accessible-components/SKILL.md +45 -0
- package/native-skills/web/api-client-resilience/SKILL.md +39 -0
- package/native-skills/web/bundle-size-reduction/SKILL.md +38 -0
- package/native-skills/web/core-web-vitals/SKILL.md +40 -0
- package/native-skills/web/css-layout-systems/SKILL.md +41 -0
- package/native-skills/web/form-validation-ux/SKILL.md +39 -0
- package/native-skills/web/react-state-architecture/SKILL.md +41 -0
- package/native-skills/web/ssr-hydration-correctness/SKILL.md +45 -0
- package/package.json +68 -4
- package/src/agentLoop.js +2458 -0
- package/src/appChannel.js +109 -0
- package/src/approvalGrants.js +66 -0
- package/src/auditLog.js +52 -0
- package/src/autoUpdate.js +12 -0
- package/src/capabilities.js +36 -0
- package/src/checkpoints.js +1199 -0
- package/src/cliMode.js +638 -0
- package/src/commands.js +805 -0
- package/src/compiledSkills.js +540 -0
- package/src/config.js +130 -0
- package/src/contextManager.js +498 -0
- package/src/cortex/autoCondenser.js +192 -0
- package/src/cortex/provisioner.js +27 -0
- package/src/cortex/vectorDb.js +447 -0
- package/src/designPalettes.js +99 -0
- package/src/devMode.js +6 -0
- package/src/embodimentSession.js +180 -0
- package/src/entrypoint.js +194 -0
- package/src/execSafe.js +21 -0
- package/src/gitSnapshot.js +378 -0
- package/src/mcp/jsonRpc.js +165 -0
- package/src/mcp/mcpClient.js +136 -0
- package/src/mcp/mcpRegistry.js +207 -0
- package/src/memory.js +62 -0
- package/src/memoryManager.js +124 -0
- package/src/modelCatalogue.js +132 -0
- package/src/modelIds.js +42 -0
- package/src/modelRouter.js +145 -0
- package/src/outputStore.js +246 -0
- package/src/perception.js +124 -0
- package/src/planCache.js +437 -0
- package/src/procSignal.js +32 -0
- package/src/providers/anthropic.js +408 -0
- package/src/providers/base.js +328 -0
- package/src/providers/dashscope-byok.js +86 -0
- package/src/providers/deepseek.js +80 -0
- package/src/providers/factory.js +37 -0
- package/src/providers/gemini.js +346 -0
- package/src/providers/moonshot.js +73 -0
- package/src/providers/muse.js +169 -0
- package/src/providers/ollama.js +104 -0
- package/src/providers/openai-compatible.js +84 -0
- package/src/providers/openai.js +20 -0
- package/src/providers/qwen-byok.js +76 -0
- package/src/providers/xai.js +73 -0
- package/src/provisioning/capabilityConsent.js +24 -0
- package/src/provisioning/capabilityProvisioner.js +271 -0
- package/src/provisioning/capabilityTools.js +29 -0
- package/src/reporting.js +373 -0
- package/src/sandbox.js +529 -0
- package/src/sessionStore.js +279 -0
- package/src/skillsManager.js +288 -0
- package/src/slopDetector.js +307 -0
- package/src/taskBuffer.js +45 -0
- package/src/taskShape.js +241 -0
- package/src/textDiff.js +150 -0
- package/src/tokenTracker.js +560 -0
- package/src/tool-scripts/ax_snapshot.applescript +39 -0
- package/src/tool-scripts/ax_snapshot.ps1 +10 -0
- package/src/tool-scripts/desktop_act.applescript +58 -0
- package/src/tool-scripts/resolve_automator.py +119 -0
- package/src/toolPacks.js +524 -0
- package/src/tools/archive.js +63 -0
- package/src/tools/astryxOracle.js +78 -0
- package/src/tools/axSnapshot.js +165 -0
- package/src/tools/blenderAutomator.js +349 -0
- package/src/tools/browserAutomator.js +252 -0
- package/src/tools/checkSystemSpecs.js +64 -0
- package/src/tools/clipboard.js +36 -0
- package/src/tools/codeSearchAst.js +101 -0
- package/src/tools/codeSearcher.js +230 -0
- package/src/tools/codeTools.js +262 -0
- package/src/tools/codebaseSearch.js +75 -0
- package/src/tools/createSkill.js +129 -0
- package/src/tools/deferredMcpSearch.js +246 -0
- package/src/tools/desktopAct.js +208 -0
- package/src/tools/desktopOpen.js +34 -0
- package/src/tools/dynamicLoader.js +44 -0
- package/src/tools/executeProceduralMedia.js +93 -0
- package/src/tools/fsDelete.js +51 -0
- package/src/tools/fsEdit.js +105 -0
- package/src/tools/fsFind.js +128 -0
- package/src/tools/fsList.js +77 -0
- package/src/tools/fsMove.js +57 -0
- package/src/tools/fsRead.js +87 -0
- package/src/tools/fsSearch.js +198 -0
- package/src/tools/fsWrite.js +41 -0
- package/src/tools/gameAiBehaviorTree.js +208 -0
- package/src/tools/gameAnimationRig.js +194 -0
- package/src/tools/gameAssetFetcher.js +372 -0
- package/src/tools/gameAssetHarvester.js +210 -0
- package/src/tools/gameAudioSynth.js +267 -0
- package/src/tools/gameBuilder.js +886 -0
- package/src/tools/gameCanvasAudit.js +101 -0
- package/src/tools/gameCinematicSequencer.js +151 -0
- package/src/tools/gameDataForge.js +124 -0
- package/src/tools/gameDialogueTreeGen.js +166 -0
- package/src/tools/gameEconomyBalancer.js +161 -0
- package/src/tools/gameEntitySpawner.js +207 -0
- package/src/tools/gameLoreForge.js +153 -0
- package/src/tools/gameQuestArcBuilder.js +152 -0
- package/src/tools/gameSceneBuilder.js +158 -0
- package/src/tools/gameShaderForge.js +181 -0
- package/src/tools/gameTilemapGenerator.js +164 -0
- package/src/tools/gameUiHudBuilder.js +292 -0
- package/src/tools/gameVfxEmitterGen.js +108 -0
- package/src/tools/gameWebRuntimeInspector.js +91 -0
- package/src/tools/generateMediaViaApi.js +358 -0
- package/src/tools/gitExec.js +54 -0
- package/src/tools/godotAutomator.js +437 -0
- package/src/tools/manageLocalAiDependencies.js +220 -0
- package/src/tools/memoryWrite.js +99 -0
- package/src/tools/multiEdit.js +107 -0
- package/src/tools/palmierAutomator.js +104 -0
- package/src/tools/pdfReader.js +112 -0
- package/src/tools/persistentShell.js +148 -0
- package/src/tools/printer.js +316 -0
- package/src/tools/procTerm.js +91 -0
- package/src/tools/processList.js +52 -0
- package/src/tools/readNativeSkill.js +39 -0
- package/src/tools/registry.js +277 -0
- package/src/tools/resolveAutomator.js +102 -0
- package/src/tools/screenshot.js +75 -0
- package/src/tools/subagentOrchestrator.js +305 -0
- package/src/tools/sysRun.js +125 -0
- package/src/tools/todoWrite.js +75 -0
- package/src/tools/toolDispatch.js +127 -0
- package/src/tools/toolForge.js +124 -0
- package/src/tools/updatePlan.js +88 -0
- package/src/tools/viewImage.js +82 -0
- package/src/tools/visionInspector.js +154 -0
- package/src/tools/webFetch.js +74 -0
- package/src/tools/webSearch.js +82 -0
- package/src/tui/ask.js +164 -0
- package/src/tui/banner.js +158 -0
- package/src/tui/block.js +152 -0
- package/src/tui/chatRenderer.js +953 -0
- package/src/tui/completion.js +424 -0
- package/src/tui/dialogs.js +606 -0
- package/src/tui/diff.js +296 -0
- package/src/tui/history.js +135 -0
- package/src/tui/home.js +217 -0
- package/src/tui/invocation.js +105 -0
- package/src/tui/keys.js +443 -0
- package/src/tui/lineEditor.js +256 -0
- package/src/tui/overlay.js +256 -0
- package/src/tui/prompt.js +1258 -0
- package/src/tui/selectList.js +335 -0
- package/src/tui/stage.js +330 -0
- package/src/tui/stageSurface.js +609 -0
- package/src/tui/statusLine.js +254 -0
- package/src/tui/text.js +345 -0
- package/src/tui/theme.js +393 -0
- package/src/tui/toolDisplay.js +176 -0
- package/src/tui/transcript.js +235 -0
- package/src/tui/viewport.js +337 -0
- package/src/uncertainty.js +271 -0
- package/src/userSkills.js +265 -0
- package/src/utils/cli_ui.js +0 -0
- package/src/utils/inquirer.js +12 -0
- package/src/utils/paths.js +37 -0
- package/src/version.js +38 -0
- 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 };
|