@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.
- 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,1258 @@
|
|
|
1
|
+
// The chat prompt: raw keyboard, live redraw, completion, history, queueing.
|
|
2
|
+
//
|
|
3
|
+
// This is what replaces `inquirer.prompt({ type: 'input' })`. inquirer answered
|
|
4
|
+
// one question at a time and owned the terminal while it did, which is why the
|
|
5
|
+
// chat could not offer history, could not complete a command, could not accept
|
|
6
|
+
// a paragraph, and went deaf the moment the agent started working.
|
|
7
|
+
//
|
|
8
|
+
// Three behaviours here are the ones a user actually feels:
|
|
9
|
+
//
|
|
10
|
+
// * TYPE WHILE BUSY — keystrokes during a turn are kept and sent when it ends,
|
|
11
|
+
// instead of being swallowed by a terminal nobody is reading.
|
|
12
|
+
// * ESC INTERRUPTS — wired to the AgentLoop.abort() that already existed and
|
|
13
|
+
// was only reachable by typing /stop after the turn had finished.
|
|
14
|
+
// * FAIL OPEN — if raw mode is unavailable (piped stdin, a CI runner, an
|
|
15
|
+
// unusual terminal) the caller falls back to inquirer. A fancy editor must
|
|
16
|
+
// never be the reason chat does not start.
|
|
17
|
+
//
|
|
18
|
+
// Pure Node. No dependency, no shell.
|
|
19
|
+
|
|
20
|
+
import { KeyDecoder, bracketedPaste } from './keys.js';
|
|
21
|
+
import { LineEditor } from './lineEditor.js';
|
|
22
|
+
import { PromptHistory } from './history.js';
|
|
23
|
+
import { FileIndex, suggest, applyCompletion, renderPicker, MAX_VISIBLE } from './completion.js';
|
|
24
|
+
import { paint, dim, bold, GLYPHS, terminalWidth, HAS_COLOR } from './theme.js';
|
|
25
|
+
import { barBlock, GUTTER } from './block.js';
|
|
26
|
+
import { visibleWidth, truncate, wrapBlock, shortenHome } from './text.js';
|
|
27
|
+
import { pickExample, fitHints } from './banner.js';
|
|
28
|
+
import { panelRows } from './overlay.js';
|
|
29
|
+
import { commandPalette, skillPicker, ApprovalLayer, ConfirmLayer, HomeLayer } from './dialogs.js';
|
|
30
|
+
import { describeTool } from './toolDisplay.js';
|
|
31
|
+
|
|
32
|
+
const CTRL_C_WINDOW_MS = 2000;
|
|
33
|
+
|
|
34
|
+
// Rows the editor may occupy before it starts scrolling internally.
|
|
35
|
+
//
|
|
36
|
+
// Without a cap, pasting a 200-line file draws 200 rows and erase() then walks
|
|
37
|
+
// 200 rows back up every keystroke. The live region has to stay small enough
|
|
38
|
+
// that the conversation above it is never pushed off the screen.
|
|
39
|
+
const MAX_PROMPT_ROWS = 8;
|
|
40
|
+
|
|
41
|
+
// How long to wait for a resize gesture to settle before repainting.
|
|
42
|
+
//
|
|
43
|
+
// A window drag delivers a resize event per step — dozens per second. Painting
|
|
44
|
+
// the live region on each one is what filled the screen with overlapping copies
|
|
45
|
+
// of the input row. One repaint per gesture is both correct and invisible to the
|
|
46
|
+
// user at this delay.
|
|
47
|
+
const RESIZE_SETTLE_MS = 60;
|
|
48
|
+
|
|
49
|
+
// Air. The two rows that were missing.
|
|
50
|
+
//
|
|
51
|
+
// opencode's prompt box carries paddingTop={1} INSIDE its filled surface and a
|
|
52
|
+
// one-row bordered spacer between the box and the footer
|
|
53
|
+
// (component/prompt/index.tsx:1361-1363, :1487-1497). Causyne had neither: the
|
|
54
|
+
// caret sat on the very first row of the fill and the footer was welded to the
|
|
55
|
+
// bottom of the box, which is what made the whole thing read as jammed into the
|
|
56
|
+
// last four rows of the terminal.
|
|
57
|
+
//
|
|
58
|
+
// They are constants rather than magic numbers in the layout because the caret
|
|
59
|
+
// row is computed from them — get that wrong and the cursor parks on the wrong
|
|
60
|
+
// line, which is the class of bug erase() exists to prevent.
|
|
61
|
+
const PROMPT_PAD_TOP = 1;
|
|
62
|
+
const PROMPT_PAD_BOTTOM = 1;
|
|
63
|
+
const FOOTER_GAP = 1;
|
|
64
|
+
|
|
65
|
+
// Air is what a roomy terminal can afford, and the first thing a cramped one
|
|
66
|
+
// gives up.
|
|
67
|
+
//
|
|
68
|
+
// opencode's spacers carry `minHeight={0} flexShrink={1}` (routes/home.tsx:73-80)
|
|
69
|
+
// precisely so they collapse before anything with content does. Causyne has no
|
|
70
|
+
// flex engine, so the same idea is arithmetic — and it is not decoration: on a
|
|
71
|
+
// 17-row terminal the extra row made the banner plus the band come to exactly 17,
|
|
72
|
+
// which costs the top of the wordmark. UX-03's sweep is what caught it.
|
|
73
|
+
const ROOMY_TERMINAL_ROWS = 20;
|
|
74
|
+
const CRAMPED_TERMINAL_ROWS = 18;
|
|
75
|
+
|
|
76
|
+
/** Can this process own the keyboard? */
|
|
77
|
+
export function isInteractive() {
|
|
78
|
+
if (process.env.CAUSYNE_SIMPLE_INPUT === '1') return false;
|
|
79
|
+
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export class ChatPrompt {
|
|
83
|
+
constructor(options = {}) {
|
|
84
|
+
this.out = options.out || process.stdout;
|
|
85
|
+
this.input = options.input || process.stdin;
|
|
86
|
+
this.root = options.root || process.cwd();
|
|
87
|
+
this.onInterrupt = options.onInterrupt || (() => {});
|
|
88
|
+
|
|
89
|
+
// The Stage, when one is running. Null means the scrollback renderer, which
|
|
90
|
+
// is what every non-TTY, dumb terminal and CAUSYNE_STAGE=off session gets.
|
|
91
|
+
this.surface = options.surface || null;
|
|
92
|
+
this.onExit = options.onExit || (() => process.exit(0));
|
|
93
|
+
this.onExpand = options.onExpand || (() => {});
|
|
94
|
+
|
|
95
|
+
this.editor = new LineEditor();
|
|
96
|
+
this.history = options.history || new PromptHistory();
|
|
97
|
+
this.index = new FileIndex(this.root);
|
|
98
|
+
this.decoder = new KeyDecoder();
|
|
99
|
+
|
|
100
|
+
this.busy = false;
|
|
101
|
+
this.active = false;
|
|
102
|
+
this.drawnRows = 0;
|
|
103
|
+
|
|
104
|
+
// Which row of the drawn block the caret is parked on.
|
|
105
|
+
//
|
|
106
|
+
// erase() has to walk back up to the FIRST row of the block. It used to
|
|
107
|
+
// assume the caret was on the last one and move up drawnRows - 1 — but
|
|
108
|
+
// draw() deliberately parks it on the editor's cursor row, which is higher
|
|
109
|
+
// whenever a completion picker or a notice is showing, or the caret sits on
|
|
110
|
+
// an earlier line of a multiline message. The difference was walked up past
|
|
111
|
+
// the top of the block and then erased with \x1b[J, destroying that many
|
|
112
|
+
// rows of real conversation above the prompt.
|
|
113
|
+
this.cursorRow = 0;
|
|
114
|
+
|
|
115
|
+
// The status row of the live region, and the clock that animates it.
|
|
116
|
+
this.renderer = null;
|
|
117
|
+
this.statusMessage = '';
|
|
118
|
+
this.tick = 0;
|
|
119
|
+
this.tickTimer = null;
|
|
120
|
+
|
|
121
|
+
// One concrete example, chosen per session — `ask anything` teaches a new
|
|
122
|
+
// user nothing about what this agent can actually be asked to do.
|
|
123
|
+
this.example = pickExample();
|
|
124
|
+
|
|
125
|
+
// Commands for the palette, and the dialog that currently owns the keyboard.
|
|
126
|
+
//
|
|
127
|
+
// A dialog is not a floating window here — it is rows of the live region, the
|
|
128
|
+
// way openclaw draws its pickers. There is no cell grid to overlay onto and
|
|
129
|
+
// no way to dim what is already in the user's scrollback.
|
|
130
|
+
this.commands = options.commands || [];
|
|
131
|
+
|
|
132
|
+
// Is this line a COMMAND rather than a message?
|
|
133
|
+
//
|
|
134
|
+
// Injected rather than imported: commands.js pulls in the compiled skill
|
|
135
|
+
// payload, the MCP registry and the slop detector, and this file's whole
|
|
136
|
+
// premise is that it depends on nothing. The rule still lives in exactly
|
|
137
|
+
// one place (commands.js isCommandInput) — cliMode hands it down.
|
|
138
|
+
this.isCommand = typeof options.isCommand === 'function' ? options.isCommand : () => false;
|
|
139
|
+
this.layer = null;
|
|
140
|
+
this.queue = [];
|
|
141
|
+
this.waiter = null;
|
|
142
|
+
this.completion = null;
|
|
143
|
+
this.selected = 0;
|
|
144
|
+
this.lastCtrlC = 0;
|
|
145
|
+
this.notice = '';
|
|
146
|
+
this.redrawScheduled = false;
|
|
147
|
+
this.resizeTimer = null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// -------------------------------------------------------------------------
|
|
151
|
+
// Lifecycle
|
|
152
|
+
// -------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
start() {
|
|
155
|
+
if (this.active) return;
|
|
156
|
+
this.active = true;
|
|
157
|
+
this.input.setRawMode(true);
|
|
158
|
+
this.input.resume();
|
|
159
|
+
this.input.on('data', this.handleData);
|
|
160
|
+
this.out.write(bracketedPaste(true));
|
|
161
|
+
// Rows are laid out against the terminal width; without this they stay stale
|
|
162
|
+
// until the next keystroke, and a narrowed terminal wraps rows that erase()
|
|
163
|
+
// is still counting as one.
|
|
164
|
+
if (typeof this.out.on === 'function') this.out.on('resize', this.handleResize);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
stop() {
|
|
168
|
+
if (!this.active) return;
|
|
169
|
+
this.active = false;
|
|
170
|
+
this.stopTick();
|
|
171
|
+
if (this.resizeTimer) { clearTimeout(this.resizeTimer); this.resizeTimer = null; }
|
|
172
|
+
this.erase();
|
|
173
|
+
// The exit contract (UX-01 rev 2 clause d): the band collapses and the whole
|
|
174
|
+
// session is flushed into real terminal scrollback, so leaving Causyne leaves
|
|
175
|
+
// a transcript behind exactly as it always has.
|
|
176
|
+
if (this.surface) this.surface.close();
|
|
177
|
+
this.out.write(bracketedPaste(false));
|
|
178
|
+
if (typeof this.out.removeListener === 'function') this.out.removeListener('resize', this.handleResize);
|
|
179
|
+
this.input.off('data', this.handleData);
|
|
180
|
+
try { this.input.setRawMode(false); } catch (e) { /* already closed */ }
|
|
181
|
+
this.input.pause();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The terminal changed size. Take the live region down before redrawing it.
|
|
186
|
+
*
|
|
187
|
+
* This used to zero drawnRows and cursorRow and then schedule a draw. Zeroing
|
|
188
|
+
* the counters is what erase() reads to decide how far to walk back up — so
|
|
189
|
+
* with them at zero it returned IMMEDIATELY and removed nothing, while the
|
|
190
|
+
* caret was still parked mid-block at column 2 from the previous draw. The
|
|
191
|
+
* redraw then wrote a fresh copy starting at that column, on top of a row that
|
|
192
|
+
* already had content.
|
|
193
|
+
*
|
|
194
|
+
* One stale copy per event. Dragging a window emits hundreds, and the result
|
|
195
|
+
* was a screen filled edge to edge with overlapping copies of the input row.
|
|
196
|
+
*
|
|
197
|
+
* Erasing with the pre-resize geometry is approximate — the terminal has
|
|
198
|
+
* already reflowed what we drew — but approximate removal is categorically
|
|
199
|
+
* better than none, and the debounce below means it happens once per gesture
|
|
200
|
+
* instead of once per pixel.
|
|
201
|
+
*/
|
|
202
|
+
handleResize = () => {
|
|
203
|
+
this.erase();
|
|
204
|
+
// The Stage owns its own geometry and cannot repaint a band whose height it
|
|
205
|
+
// no longer knows. Resizing it here, before the settle timer, would do it
|
|
206
|
+
// once per pixel of a drag; it is done in the settled callback below.
|
|
207
|
+
// A drag delivers a continuous stream of these. Redrawing on every one is
|
|
208
|
+
// both the corruption above and a huge amount of pointless output; settle
|
|
209
|
+
// first, then draw once.
|
|
210
|
+
if (this.resizeTimer) clearTimeout(this.resizeTimer);
|
|
211
|
+
this.resizeTimer = setTimeout(() => {
|
|
212
|
+
this.resizeTimer = null;
|
|
213
|
+
// Geometry from before the reflow is meaningless now. Drop it AFTER the
|
|
214
|
+
// erase above has used it, not before.
|
|
215
|
+
this.drawnRows = 0;
|
|
216
|
+
this.cursorRow = 0;
|
|
217
|
+
// A stage that can no longer fit gives the band back and hands the
|
|
218
|
+
// session to the scrollback renderer rather than painting into a region
|
|
219
|
+
// too short to hold a viewport.
|
|
220
|
+
if (this.surface && !this.surface.resize()) this.surface = null;
|
|
221
|
+
this.draw();
|
|
222
|
+
}, RESIZE_SETTLE_MS);
|
|
223
|
+
if (this.resizeTimer.unref) this.resizeTimer.unref();
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
handleData = (chunk) => {
|
|
227
|
+
for (const key of this.decoder.decode(chunk)) {
|
|
228
|
+
try {
|
|
229
|
+
this.handleKey(key);
|
|
230
|
+
} catch (e) {
|
|
231
|
+
// A key handler must never take the session down. Say so once and
|
|
232
|
+
// carry on, rather than dying silently the way the old catch(e){} did.
|
|
233
|
+
process.stderr.write(`\n[tui] key "${key.name}" failed: ${e.message}\n`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
this.draw();
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Let the transcript print without fighting the prompt for the same rows.
|
|
241
|
+
*
|
|
242
|
+
* The renderer writes upward from where the cursor is; the prompt lives at the
|
|
243
|
+
* bottom. Without this the two interleave and leave debris on screen.
|
|
244
|
+
*/
|
|
245
|
+
attach(renderer) {
|
|
246
|
+
renderer.onBeforeWrite = () => this.erase();
|
|
247
|
+
renderer.onAfterWrite = () => this.scheduleDraw();
|
|
248
|
+
|
|
249
|
+
// Take the status line over from the renderer's spinner.
|
|
250
|
+
//
|
|
251
|
+
// That spinner writes straight to stdout with readline.cursorTo(out, 0),
|
|
252
|
+
// which lands on whatever row the caret is on — and after draw() that is a
|
|
253
|
+
// row INSIDE the prompt block. Every 90 ms frame overwrote the prompt's first
|
|
254
|
+
// row and clearLine ate the rest, leaving drawnRows describing a block that
|
|
255
|
+
// was no longer on screen. It also bypassed onBeforeWrite/onAfterWrite
|
|
256
|
+
// entirely, so the prompt never got a chance to lift itself out of the way.
|
|
257
|
+
//
|
|
258
|
+
// The renderer already has the right behaviour for this: the quiet path it
|
|
259
|
+
// uses in full-screen mode, which reports status instead of animating it.
|
|
260
|
+
// Reuse that and make the status a row of the live region.
|
|
261
|
+
this.renderer = renderer;
|
|
262
|
+
renderer.quietStatus = true;
|
|
263
|
+
renderer.onStatusChange = (message) => {
|
|
264
|
+
this.statusMessage = message;
|
|
265
|
+
this.scheduleDraw();
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* The footer row: spinner, elapsed, steps, tokens, cost, model.
|
|
271
|
+
*
|
|
272
|
+
* One clock drives it, and only while a turn is running — an idle prompt costs
|
|
273
|
+
* no timer at all. Rendered through the renderer's own StatusLine so the
|
|
274
|
+
* usage_update counters it accumulates are the ones displayed.
|
|
275
|
+
*/
|
|
276
|
+
statusRow(width) {
|
|
277
|
+
if (!this.renderer || !this.renderer.status) return [];
|
|
278
|
+
const status = this.renderer.status;
|
|
279
|
+
|
|
280
|
+
// Between turns the working numbers are all zero and the line said "ready"
|
|
281
|
+
// beside a model name — two facts nobody can act on. opencode's permanent
|
|
282
|
+
// footer shows the working directory and what the context window has left,
|
|
283
|
+
// which is what the idle form carries instead.
|
|
284
|
+
if (!this.busy) {
|
|
285
|
+
return [status.renderIdle({
|
|
286
|
+
width: Math.max(10, width),
|
|
287
|
+
hint: this.commands.length > 0 ? 'ctrl+p commands' : ''
|
|
288
|
+
})];
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const frame = GLYPHS.spinner[this.tick % GLYPHS.spinner.length];
|
|
292
|
+
const since = this.renderer.turnStartedAt;
|
|
293
|
+
return [status.render({
|
|
294
|
+
message: this.statusMessage || 'working',
|
|
295
|
+
elapsedMs: since ? Date.now() - since : 0,
|
|
296
|
+
steps: this.renderer.stepCount || 0,
|
|
297
|
+
width: Math.max(10, width - 1),
|
|
298
|
+
spinnerFrame: frame
|
|
299
|
+
})];
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* The line inside the input box: mode, model, provider.
|
|
304
|
+
*
|
|
305
|
+
* opencode puts exactly this row at the foot of its prompt box
|
|
306
|
+
* (`Build · MiMo V2.5 Free OpenCode Zen`), and it is the one place the fact
|
|
307
|
+
* that matters most — which model is about to spend the user's money — is
|
|
308
|
+
* visible at the moment they press enter. Causyne showed it once in the session
|
|
309
|
+
* header and then never again.
|
|
310
|
+
*/
|
|
311
|
+
agentRow(width) {
|
|
312
|
+
const status = this.renderer && this.renderer.status;
|
|
313
|
+
if (!status) return null;
|
|
314
|
+
|
|
315
|
+
const mode = this.busy ? 'Working' : 'Build';
|
|
316
|
+
const parts = [bold(paint('accent', mode))];
|
|
317
|
+
if (status.model) parts.push(bold(paint('ink', status.model)));
|
|
318
|
+
const line = parts.join(dim(` ${GLYPHS.dot} `));
|
|
319
|
+
const trailing = [status.provider, status.tier].filter(Boolean).join(' ');
|
|
320
|
+
const full = trailing ? `${line} ${dim(trailing)}` : line;
|
|
321
|
+
return visibleWidth(full) > width ? truncate(full, width, GLYPHS.ellipsis) : full;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
startTick() {
|
|
325
|
+
if (this.tickTimer) return;
|
|
326
|
+
this.tickTimer = setInterval(() => {
|
|
327
|
+
this.tick++;
|
|
328
|
+
if (this.busy) this.scheduleDraw();
|
|
329
|
+
}, 90);
|
|
330
|
+
if (this.tickTimer.unref) this.tickTimer.unref();
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
stopTick() {
|
|
334
|
+
if (!this.tickTimer) return;
|
|
335
|
+
clearInterval(this.tickTimer);
|
|
336
|
+
this.tickTimer = null;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
openLayer(layer) {
|
|
340
|
+
this.closeLayer();
|
|
341
|
+
this.layer = layer;
|
|
342
|
+
this.draw();
|
|
343
|
+
return layer;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
closeLayer() {
|
|
347
|
+
const layer = this.layer;
|
|
348
|
+
if (!layer) return;
|
|
349
|
+
this.layer = null;
|
|
350
|
+
// Dismissing a question is an ANSWER, and the safe answer is no. A layer that
|
|
351
|
+
// is torn down without one would otherwise leave its promise pending forever
|
|
352
|
+
// and hang the turn.
|
|
353
|
+
if (typeof layer.answer === 'function') layer.answer(false);
|
|
354
|
+
this.draw();
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Every command in the app, fuzzy-searchable. */
|
|
358
|
+
openPalette() {
|
|
359
|
+
if (this.commands.length === 0) {
|
|
360
|
+
this.notice = 'no commands available';
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
this.openLayer(commandPalette(this.commands, (command) => {
|
|
364
|
+
// Run it the way the user would have typed it, so there is one execution
|
|
365
|
+
// path for a command however it was reached.
|
|
366
|
+
this.editor.setText(`/${command.name}`);
|
|
367
|
+
this.layer = null;
|
|
368
|
+
this.submit();
|
|
369
|
+
}));
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
openHome(items, onPick) {
|
|
374
|
+
if (!Array.isArray(items) || items.length === 0) {
|
|
375
|
+
this.notice = 'no actions available';
|
|
376
|
+
this.scheduleDraw();
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
return this.openLayer(new HomeLayer({
|
|
380
|
+
items,
|
|
381
|
+
onPick: (item) => {
|
|
382
|
+
this.layer = null;
|
|
383
|
+
return onPick ? onPick(item) : null;
|
|
384
|
+
}
|
|
385
|
+
}));
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Put text on the input line, ready to be added to.
|
|
390
|
+
*
|
|
391
|
+
* Picking from the skill browser lands here: the command is written for you
|
|
392
|
+
* and the caret sits after it, so the next thing you type is what you want
|
|
393
|
+
* done. A picker that ran the skill on selection would give you no way to say
|
|
394
|
+
* that.
|
|
395
|
+
*/
|
|
396
|
+
setText(text) {
|
|
397
|
+
this.editor.setText(String(text == null ? '' : text));
|
|
398
|
+
this.completion = null;
|
|
399
|
+
this.scheduleDraw();
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Browse the compiled skill library.
|
|
404
|
+
*
|
|
405
|
+
* 100 skills cannot be a flat listing — that is what overflowed the terminal
|
|
406
|
+
* at five. The picker is the same SelectLayer that already backs ctrl+p, so
|
|
407
|
+
* fuzzy search, grouping and scrolling come for free, and every row is
|
|
408
|
+
* truncated to the panel rather than to the terminal.
|
|
409
|
+
*/
|
|
410
|
+
browseSkills(skills, onOpen) {
|
|
411
|
+
if (!Array.isArray(skills) || skills.length === 0) {
|
|
412
|
+
this.notice = 'no skills are compiled into this build';
|
|
413
|
+
this.scheduleDraw();
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
this.openLayer(skillPicker(skills, (skill) => {
|
|
417
|
+
this.layer = null;
|
|
418
|
+
if (onOpen) onOpen(skill);
|
|
419
|
+
}));
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Ask for approval of a tool call: risk, arguments and a scrollable diff.
|
|
424
|
+
*
|
|
425
|
+
* Replaces an inquirer.prompt() that took stdin while this prompt was already
|
|
426
|
+
* holding it in raw mode — the two contended, and the confirm could arrive on
|
|
427
|
+
* top of a half-drawn prompt.
|
|
428
|
+
*/
|
|
429
|
+
approve(request = {}) {
|
|
430
|
+
return new Promise((resolve) => {
|
|
431
|
+
// Only the curated verb — "run", "overwrite", "delete". The layer already
|
|
432
|
+
// renders the arguments (or a diff) underneath, so passing a rendered
|
|
433
|
+
// argument summary as `detail` printed the same line twice.
|
|
434
|
+
const { verb } = describeTool(request.tool, request.args || {});
|
|
435
|
+
// Resolve with WHICH option was chosen, not just yes/no: 'always' has to
|
|
436
|
+
// reach the caller so it can record a standing approval. A bare boolean
|
|
437
|
+
// would have silently downgraded it to 'once'.
|
|
438
|
+
this.openLayer(new ApprovalLayer({
|
|
439
|
+
request,
|
|
440
|
+
verb,
|
|
441
|
+
onAnswer: (approved, scope) => resolve({ approved, scope: scope || (approved ? 'once' : 'reject') })
|
|
442
|
+
}));
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** A plain yes/no, for the questions that are not tool approvals. */
|
|
447
|
+
confirm(message) {
|
|
448
|
+
return new Promise((resolve) => {
|
|
449
|
+
let settled = false;
|
|
450
|
+
const settle = (answer) => { if (!settled) { settled = true; resolve(Boolean(answer)); } };
|
|
451
|
+
const layer = new ConfirmLayer({ message, onAnswer: settle });
|
|
452
|
+
layer.answer = () => settle(false);
|
|
453
|
+
this.openLayer(layer);
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Columns available on the stream this prompt actually writes to.
|
|
459
|
+
*
|
|
460
|
+
* draw() used to call the global terminalWidth(), which reads
|
|
461
|
+
* process.stdout.columns. That is right in production — `out` IS stdout — but
|
|
462
|
+
* it means the widget cannot be measured against anything else, and a panel
|
|
463
|
+
* sized from the wrong width overflows and wraps, which corrupts erase()'s row
|
|
464
|
+
* count and starts eating the conversation above.
|
|
465
|
+
*/
|
|
466
|
+
width() {
|
|
467
|
+
// The Stage may ask for a narrower measure — it does so only for the opening
|
|
468
|
+
// composition, while there is no transcript yet, so the input box lines up
|
|
469
|
+
// with the banner above it instead of stretching past it. Once the session
|
|
470
|
+
// starts it hands back the full width and nothing below here changes.
|
|
471
|
+
if (this.surface && typeof this.surface.composeWidth === 'function') {
|
|
472
|
+
const asked = this.surface.composeWidth();
|
|
473
|
+
if (Number.isFinite(asked) && asked > 0) return Math.max(20, asked);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const columns = this.out && this.out.columns;
|
|
477
|
+
// No ceiling. The prompt is chrome: the box, its fill and the footer are the
|
|
478
|
+
// surface the user is typing into, and they follow the window. A clamp at
|
|
479
|
+
// 120 left a stretched terminal two-thirds covered and one-third empty.
|
|
480
|
+
return Number.isFinite(columns) && columns > 0
|
|
481
|
+
? Math.max(20, columns)
|
|
482
|
+
: terminalWidth();
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
scheduleDraw() {
|
|
486
|
+
if (this.redrawScheduled || !this.active) return;
|
|
487
|
+
this.redrawScheduled = true;
|
|
488
|
+
setImmediate(() => {
|
|
489
|
+
this.redrawScheduled = false;
|
|
490
|
+
this.draw();
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// -------------------------------------------------------------------------
|
|
495
|
+
// Reading
|
|
496
|
+
// -------------------------------------------------------------------------
|
|
497
|
+
|
|
498
|
+
/** Next submitted line. Resolves immediately if one was queued while busy. */
|
|
499
|
+
read() {
|
|
500
|
+
if (this.queue.length > 0) return Promise.resolve(this.queue.shift());
|
|
501
|
+
return new Promise((resolve) => { this.waiter = resolve; });
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Resolve a pending read() with a value nobody typed.
|
|
506
|
+
*
|
|
507
|
+
* The home menu needs this: picking an action must satisfy the same read()
|
|
508
|
+
* the keyboard would have satisfied, or the caller sits waiting for a line
|
|
509
|
+
* that is never coming. It deliberately does NOT echo, add to history or
|
|
510
|
+
* touch the editor — an action is not a message, and showing `start` in the
|
|
511
|
+
* transcript as though the user had said it would be a lie about what
|
|
512
|
+
* happened. If no read is outstanding the value queues, exactly as a typed
|
|
513
|
+
* line would while a turn is running.
|
|
514
|
+
*/
|
|
515
|
+
deliver(value) {
|
|
516
|
+
if (this.waiter) {
|
|
517
|
+
const resolve = this.waiter;
|
|
518
|
+
this.waiter = null;
|
|
519
|
+
resolve(value);
|
|
520
|
+
return true;
|
|
521
|
+
}
|
|
522
|
+
this.queue.push(value);
|
|
523
|
+
return false;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
setBusy(busy) {
|
|
527
|
+
this.busy = busy;
|
|
528
|
+
if (!busy) this.notice = '';
|
|
529
|
+
if (busy) this.startTick(); else this.stopTick();
|
|
530
|
+
this.draw();
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
submit() {
|
|
534
|
+
const text = this.editor.getText();
|
|
535
|
+
if (!text.trim()) return;
|
|
536
|
+
|
|
537
|
+
this.erase();
|
|
538
|
+
this.history.add(text);
|
|
539
|
+
this.editor.clear();
|
|
540
|
+
this.completion = null;
|
|
541
|
+
|
|
542
|
+
if (this.busy) {
|
|
543
|
+
// The turn is still running: hold the line rather than dropping it.
|
|
544
|
+
this.queue.push(text);
|
|
545
|
+
this.notice = `queued (${this.queue.length}) — sends when this turn ends`;
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// A COMMAND IS NOT A MESSAGE, so it must not be drawn as one.
|
|
550
|
+
//
|
|
551
|
+
// This echoed every submitted line as a filled user block BEFORE the caller
|
|
552
|
+
// had any chance to decide what the line was. Typing `/usage` therefore
|
|
553
|
+
// rendered exactly like a sentence you had sent to the model — the command
|
|
554
|
+
// did run, and its output appeared underneath, but the transcript showed a
|
|
555
|
+
// conversation turn that never happened. opencode draws an invocation here,
|
|
556
|
+
// not a message, and the difference is the whole reason a command line
|
|
557
|
+
// exists.
|
|
558
|
+
if (this.isCommand(text)) {
|
|
559
|
+
this.commit('command', (width) => [
|
|
560
|
+
`${paint('accent', GLYPHS.arrow)} ${bold(truncate(text, Math.max(8, width - 3), GLYPHS.ellipsis))}`
|
|
561
|
+
]);
|
|
562
|
+
const settle = this.waiter;
|
|
563
|
+
this.waiter = null;
|
|
564
|
+
if (settle) settle(text);
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// Echo what was sent as a BLOCK, so the transcript reads as a conversation.
|
|
569
|
+
//
|
|
570
|
+
// A `▸ ` prefix made the user's own words indistinguishable from a tool row
|
|
571
|
+
// at a glance. The reference shows the user's message as its own left-barred,
|
|
572
|
+
// filled block (screenshot 15.02.21), which is the seam that lets the eye
|
|
573
|
+
// find where each exchange begins when scrolling back through a long turn.
|
|
574
|
+
// width - 1, not width. A block built to the FULL terminal width fills the
|
|
575
|
+
// final column, the terminal wraps it, and one drawn row becomes two real
|
|
576
|
+
// ones — so erase() walks back half as far as it should and leaves the
|
|
577
|
+
// remainder on screen. That is why a six-line message came back as six
|
|
578
|
+
// stray fragments of its own tail. barBlock honours whatever width it is
|
|
579
|
+
// given; reserving the column is the caller's job.
|
|
580
|
+
this.commit('user', (width) => {
|
|
581
|
+
const blockWidth = Math.max(8, width - 1);
|
|
582
|
+
return barBlock(
|
|
583
|
+
wrapBlock(text, Math.max(8, blockWidth - visibleWidth(GLYPHS.bar) - GUTTER)),
|
|
584
|
+
{ width: blockWidth, role: 'user', filled: true, surface: 'surfaceHi' }
|
|
585
|
+
);
|
|
586
|
+
});
|
|
587
|
+
const resolve = this.waiter;
|
|
588
|
+
this.waiter = null;
|
|
589
|
+
if (resolve) resolve(text);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// -------------------------------------------------------------------------
|
|
593
|
+
// Keys
|
|
594
|
+
// -------------------------------------------------------------------------
|
|
595
|
+
|
|
596
|
+
handleKey(key) {
|
|
597
|
+
// The wheel was handed to the terminal at the top of the band; the moment
|
|
598
|
+
// the user types again they are driving this prompt, so take it back.
|
|
599
|
+
if (key.name !== 'wheel' && key.name !== 'mouse'
|
|
600
|
+
&& this.surface && typeof this.surface.captureMouse === 'function') {
|
|
601
|
+
if (this.surface.captureMouse() && this.notice.startsWith('top of this view')) this.notice = '';
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
if (key.name === 'paste') {
|
|
605
|
+
this.editor.insert(key.text);
|
|
606
|
+
this.refreshCompletion();
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
if (key.ctrl && key.name === 'c') {
|
|
611
|
+
// A modal owns the screen, so ctrl+c must dismiss THAT before it touches
|
|
612
|
+
// the turn behind it. closeLayer() answers false, which is what makes
|
|
613
|
+
// this safe: the pending approval resolves as a denial.
|
|
614
|
+
//
|
|
615
|
+
// It shipped the other way round. With an approval up, ctrl+c fell
|
|
616
|
+
// straight through to handleCtrlC(), saw `busy`, and interrupted the
|
|
617
|
+
// turn — while the modal stayed on screen and its promise was never
|
|
618
|
+
// resolved. The loop then waited forever for an answer that could no
|
|
619
|
+
// longer be given, which is why ctrl+c "did nothing" and only escape
|
|
620
|
+
// ever got the user out.
|
|
621
|
+
if (this.layer) { this.closeLayer(); return; }
|
|
622
|
+
return this.handleCtrlC();
|
|
623
|
+
}
|
|
624
|
+
if (key.ctrl && key.name === 'd' && this.editor.isEmpty) return this.onExit();
|
|
625
|
+
|
|
626
|
+
// A dialog owns the keyboard while it is up — the editor must not also see
|
|
627
|
+
// the keystroke, or typing a filter would edit the message behind it.
|
|
628
|
+
if (this.layer) {
|
|
629
|
+
const action = this.layer.key(key);
|
|
630
|
+
if (action === 'close') { this.closeLayer(); return; }
|
|
631
|
+
// A layer may hand a key BACK rather than swallow it. The home menu does
|
|
632
|
+
// this for every printable character, so `↑↓` picks an action while typing
|
|
633
|
+
// goes to the input line in the same frame: 'dismiss' closes the menu and
|
|
634
|
+
// lets the key through, 'passthrough' leaves the menu up and lets it
|
|
635
|
+
// through. Silence still means "mine, ignored", so every other layer stays
|
|
636
|
+
// strictly modal — a dialog that leaks keys is the bug Layer exists to
|
|
637
|
+
// prevent, and only a layer that opts in by name can do it.
|
|
638
|
+
if (action === 'dismiss') this.closeLayer();
|
|
639
|
+
else if (action !== 'passthrough') return;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
if (this.completion && this.handleCompletionKey(key)) return;
|
|
643
|
+
if (this.handleScrollKey(key)) return;
|
|
644
|
+
|
|
645
|
+
switch (key.name) {
|
|
646
|
+
case 'escape': return this.handleEscape();
|
|
647
|
+
case 'enter':
|
|
648
|
+
if (key.alt) { this.editor.newline(); return; }
|
|
649
|
+
return this.submit();
|
|
650
|
+
case 'tab':
|
|
651
|
+
if (key.shift) return;
|
|
652
|
+
return this.openCompletion(true);
|
|
653
|
+
case 'backspace':
|
|
654
|
+
if (key.alt) this.editor.killWordLeft(); else this.editor.backspace();
|
|
655
|
+
return this.refreshCompletion();
|
|
656
|
+
case 'delete': this.editor.deleteForward(); return this.refreshCompletion();
|
|
657
|
+
case 'left': return key.ctrl || key.alt ? this.editor.moveWordLeft() : this.editor.moveLeft();
|
|
658
|
+
case 'right': return key.ctrl || key.alt ? this.editor.moveWordRight() : this.editor.moveRight();
|
|
659
|
+
case 'home': return this.editor.moveHome();
|
|
660
|
+
case 'end': return this.editor.moveEnd();
|
|
661
|
+
case 'up': return this.historyOrMove(-1);
|
|
662
|
+
case 'down': return this.historyOrMove(1);
|
|
663
|
+
case 'char': this.editor.insert(key.text); return this.refreshCompletion();
|
|
664
|
+
default: break;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
if (key.ctrl) {
|
|
668
|
+
switch (key.name) {
|
|
669
|
+
case 'a': return this.editor.moveHome();
|
|
670
|
+
case 'e': return this.editor.moveEnd();
|
|
671
|
+
case 'k': return this.editor.killToEnd();
|
|
672
|
+
case 'u': return this.editor.killToStart();
|
|
673
|
+
case 'w': this.editor.killWordLeft(); return this.refreshCompletion();
|
|
674
|
+
case 'y': this.editor.yank(); return this.refreshCompletion();
|
|
675
|
+
case 'p': return this.openPalette();
|
|
676
|
+
case 'l': return this.clearScreen();
|
|
677
|
+
case 'o': return this.expand();
|
|
678
|
+
default: break;
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
if (key.alt && key.name === 'b') return this.editor.moveWordLeft();
|
|
682
|
+
if (key.alt && key.name === 'f') return this.editor.moveWordRight();
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* Scroll the transcript viewport. Returns true when the key was ours.
|
|
687
|
+
*
|
|
688
|
+
* Only bound when a Stage is running: without one there is no viewport, the
|
|
689
|
+
* terminal's own scrollback is the viewport, and stealing pageup from it
|
|
690
|
+
* would be worse than useless.
|
|
691
|
+
*
|
|
692
|
+
* The choices, and why they are not opencode's:
|
|
693
|
+
*
|
|
694
|
+
* * pageup/pagedown move a full viewport MINUS ONE ROW of overlap, which is
|
|
695
|
+
* what every pager does and what lets the eye carry across the jump.
|
|
696
|
+
* opencode calls height/2 a "page" and height/4 a "half page" — both are
|
|
697
|
+
* half of what their names claim, and there is no reason to inherit that.
|
|
698
|
+
* * ctrl+u / ctrl+d are NOT scroll keys. They are kill-to-start and
|
|
699
|
+
* delete/exit, tests/chatSurface.test.js asserts it, and a chat where the
|
|
700
|
+
* key that deletes your line also scrolls is a trap. Half-page is on the
|
|
701
|
+
* alt-modified pair instead.
|
|
702
|
+
* * home/end stay with the editor, so top/bottom take the shifted pageup and
|
|
703
|
+
* pagedown rather than fighting for them.
|
|
704
|
+
*/
|
|
705
|
+
handleScrollKey(key) {
|
|
706
|
+
const viewport = this.surface && this.surface.viewport;
|
|
707
|
+
|
|
708
|
+
// A mouse report must be swallowed whether or not there is a viewport to
|
|
709
|
+
// act on. The bytes are already consumed by the decoder; letting the event
|
|
710
|
+
// fall through would put `mouse` or `wheel` into the switch below, where a
|
|
711
|
+
// future case could act on it by accident — and, worse, a terminal that
|
|
712
|
+
// kept reporting after a failed teardown would type into the user's line.
|
|
713
|
+
if (key.name === 'mouse') return true;
|
|
714
|
+
if (key.name === 'wheel') {
|
|
715
|
+
if (!viewport || !key.wheel) return true;
|
|
716
|
+
|
|
717
|
+
viewport.wheel(key.wheel.direction);
|
|
718
|
+
|
|
719
|
+
// At the top of the band, an upward wheel belongs to the TERMINAL — the
|
|
720
|
+
// conversation continues above us in its own scrollback. Releasing mouse
|
|
721
|
+
// reporting is the only way to give it back; the bytes for this event are
|
|
722
|
+
// already consumed, so the release takes effect from the next notch.
|
|
723
|
+
if (!viewport.lastWheelMoved && key.wheel.direction === 'up'
|
|
724
|
+
&& this.surface && typeof this.surface.releaseMouse === 'function') {
|
|
725
|
+
if (this.surface.releaseMouse()) {
|
|
726
|
+
this.notice = 'top of this view — scrolling with the terminal now; press any key to come back';
|
|
727
|
+
this.draw();
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
return true;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
if (!viewport) return false;
|
|
734
|
+
|
|
735
|
+
if (key.name === 'pageup') {
|
|
736
|
+
if (key.shift) viewport.toTop(); else viewport.pageUp();
|
|
737
|
+
return true;
|
|
738
|
+
}
|
|
739
|
+
if (key.name === 'pagedown') {
|
|
740
|
+
// Shift+PgDn is the way back to following, and it is spelled out in the
|
|
741
|
+
// affordance the surface draws while the view is held away.
|
|
742
|
+
if (key.shift) viewport.toBottom(); else viewport.pageDown();
|
|
743
|
+
return true;
|
|
744
|
+
}
|
|
745
|
+
if (key.shift && key.name === 'up') { viewport.lineUp(); return true; }
|
|
746
|
+
if (key.shift && key.name === 'down') { viewport.lineDown(); return true; }
|
|
747
|
+
if (key.alt && key.ctrl && key.name === 'u') { viewport.halfPageUp(); return true; }
|
|
748
|
+
if (key.alt && key.ctrl && key.name === 'd') { viewport.halfPageDown(); return true; }
|
|
749
|
+
|
|
750
|
+
// Jump by structure, not by rows. The transcript knows where each frame
|
|
751
|
+
// starts, so `,`/`.` step through tool calls and the shifted `<`/`>` on the
|
|
752
|
+
// same two physical keys step through exchanges — a bigger jump on the
|
|
753
|
+
// bigger key press.
|
|
754
|
+
//
|
|
755
|
+
// These are alt-modified because a bare bracket is a character the user is
|
|
756
|
+
// entitled to type; the plan's `[`/`]` could not be bound at all, and
|
|
757
|
+
// alt+[ is worse than unbound — ESC [ IS the CSI introducer, so it is
|
|
758
|
+
// indistinguishable from the start of an escape sequence.
|
|
759
|
+
if (key.alt && key.name === ',') { viewport.jump(-1, ['step']); return true; }
|
|
760
|
+
if (key.alt && key.name === '.') { viewport.jump(1, ['step']); return true; }
|
|
761
|
+
// Exchanges are marked by the USER's message, not by the turn footer. Both
|
|
762
|
+
// sit at almost the same seam — a footer is immediately followed by the next
|
|
763
|
+
// question — so accepting either made the jump land twice two rows apart
|
|
764
|
+
// instead of once per exchange.
|
|
765
|
+
if (key.alt && key.name === '<') { viewport.jump(-1, ['user']); return true; }
|
|
766
|
+
if (key.alt && key.name === '>') { viewport.jump(1, ['user']); return true; }
|
|
767
|
+
|
|
768
|
+
if (key.alt && key.name === 'u') { this.offerUndo(); return true; }
|
|
769
|
+
|
|
770
|
+
return false;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* OFFER the undo for the restore point you are looking at. Never perform it.
|
|
775
|
+
*
|
|
776
|
+
* The rail marks every restore point, which makes scrolling back a way of
|
|
777
|
+
* finding somewhere to roll back TO — and that is worth nothing if reaching it
|
|
778
|
+
* means remembering a command. This puts the command in the input line for
|
|
779
|
+
* review; it does not run it, and it does not overwrite a message you were
|
|
780
|
+
* part-way through typing.
|
|
781
|
+
*
|
|
782
|
+
* `/undo` is preview-by-default and needs `--apply` to write, so even the
|
|
783
|
+
* accidental path here is a read. That is deliberate: a single keystroke may
|
|
784
|
+
* not be able to overwrite a day's work.
|
|
785
|
+
*
|
|
786
|
+
* WHAT THIS IS NOT: undo for *exactly* that point. `/undo` takes a task, not a
|
|
787
|
+
* checkpoint id, so there is no way to ask for "the state at this marker"
|
|
788
|
+
* without changing restore semantics — which is not a terminal-UI change and
|
|
789
|
+
* is not being smuggled into one. The notice says which step the marker is, so
|
|
790
|
+
* the choice is at least informed.
|
|
791
|
+
*/
|
|
792
|
+
offerUndo() {
|
|
793
|
+
const surface = this.surface;
|
|
794
|
+
if (!surface) return;
|
|
795
|
+
const width = surface.viewport.width;
|
|
796
|
+
const marks = surface.transcript.markers(width).filter((m) => m.kind === 'checkpoint');
|
|
797
|
+
if (marks.length === 0) {
|
|
798
|
+
this.notice = 'no restore points in this session yet';
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
const top = surface.viewport.scrollTop;
|
|
803
|
+
const bottom = top + surface.viewport.height;
|
|
804
|
+
const onScreen = marks.filter((m) => m.row >= top && m.row < bottom);
|
|
805
|
+
const above = marks.filter((m) => m.row < top);
|
|
806
|
+
const mark = onScreen.length ? onScreen[onScreen.length - 1] : (above.pop() || marks[0]);
|
|
807
|
+
const meta = mark.meta || {};
|
|
808
|
+
|
|
809
|
+
const where = meta.path ? shortenHome(meta.path) : 'files changed this task';
|
|
810
|
+
const step = meta.step == null ? '' : ` at step ${meta.step}`;
|
|
811
|
+
|
|
812
|
+
if (this.editor.isEmpty) {
|
|
813
|
+
this.editor.setText('/undo');
|
|
814
|
+
this.notice = `restore point${step} ${GLYPHS.dot} ${where} ${GLYPHS.dot} enter previews, --apply writes`;
|
|
815
|
+
} else {
|
|
816
|
+
// Their draft is theirs. Say what is there and leave the line alone.
|
|
817
|
+
this.notice = `restore point${step} ${GLYPHS.dot} ${where} ${GLYPHS.dot} run /undo to preview`;
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
handleCtrlC() {
|
|
822
|
+
const now = Date.now();
|
|
823
|
+
if (!this.editor.isEmpty) {
|
|
824
|
+
this.editor.clear();
|
|
825
|
+
this.completion = null;
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
if (this.busy) { this.interrupt(); return; }
|
|
829
|
+
if (now - this.lastCtrlC < CTRL_C_WINDOW_MS) return this.onExit();
|
|
830
|
+
this.lastCtrlC = now;
|
|
831
|
+
this.notice = 'press Ctrl+C again to exit';
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
handleEscape() {
|
|
835
|
+
if (this.completion) { this.completion = null; return; }
|
|
836
|
+
if (this.busy) return this.interrupt();
|
|
837
|
+
this.editor.clear();
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
interrupt() {
|
|
841
|
+
this.erase();
|
|
842
|
+
this.commit('notice', () => [`${paint('warn', GLYPHS.cross)} ${dim('interrupted')}`]);
|
|
843
|
+
this.notice = '';
|
|
844
|
+
this.onInterrupt();
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/** Show the last tool output in full, above the prompt. */
|
|
848
|
+
expand() {
|
|
849
|
+
this.erase();
|
|
850
|
+
this.onExpand();
|
|
851
|
+
this.notice = '';
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
clearScreen() {
|
|
855
|
+
// On the Stage, ctrl+L means what it means in a terminal: the screen goes
|
|
856
|
+
// blank and your history is still behind you. Clearing the transcript alone
|
|
857
|
+
// would destroy the session record, so the surface prints it through into
|
|
858
|
+
// real scrollback first and then starts the band clean. It is also the
|
|
859
|
+
// escape hatch for getting a transcript out of the band without exiting.
|
|
860
|
+
if (this.surface) { this.surface.flushAndClear(); return; }
|
|
861
|
+
|
|
862
|
+
this.erase();
|
|
863
|
+
// The one clear Causyne may perform: the user pressed ctrl+L and asked for it.
|
|
864
|
+
// UX-01 forbids clearing the terminal on the app's own initiative.
|
|
865
|
+
this.out.write('\x1b[2J\x1b[H');
|
|
866
|
+
this.drawnRows = 0;
|
|
867
|
+
this.cursorRow = 0;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
historyOrMove(direction) {
|
|
871
|
+
// Inside a multi-line buffer the arrows move the cursor; history is only
|
|
872
|
+
// reachable from the edge, which is what every shell does.
|
|
873
|
+
if (direction === -1 && this.editor.line > 0) return this.editor.moveUp();
|
|
874
|
+
if (direction === 1 && this.editor.line < this.editor.lines.length - 1) return this.editor.moveDown();
|
|
875
|
+
|
|
876
|
+
const text = direction === -1
|
|
877
|
+
? this.history.previous(this.editor.getText())
|
|
878
|
+
: this.history.next();
|
|
879
|
+
if (text === null || text === undefined) return;
|
|
880
|
+
this.editor.setText(text);
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// -------------------------------------------------------------------------
|
|
884
|
+
// Completion
|
|
885
|
+
// -------------------------------------------------------------------------
|
|
886
|
+
|
|
887
|
+
cursorOffset() {
|
|
888
|
+
const before = this.editor.lines
|
|
889
|
+
.slice(0, this.editor.line)
|
|
890
|
+
.reduce((total, line) => total + line.length + 1, 0);
|
|
891
|
+
return before + this.editor.col;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
openCompletion(force = false) {
|
|
895
|
+
const text = this.editor.getText();
|
|
896
|
+
const result = suggest(text, this.cursorOffset(), { root: this.root, index: this.index });
|
|
897
|
+
if (!result) {
|
|
898
|
+
if (force) this.notice = 'nothing to complete here';
|
|
899
|
+
// Close a picker whose trigger has gone. refreshCompletion() documents
|
|
900
|
+
// this ("close it when the trigger is gone") but this early return never
|
|
901
|
+
// did it — so typing past the end of an argument left the suggestions on
|
|
902
|
+
// screen, still offering matches for text that was no longer there.
|
|
903
|
+
this.completion = null;
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
this.completion = result;
|
|
907
|
+
this.selected = 0;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/** Re-run completion after an edit; close it when the trigger is gone. */
|
|
911
|
+
refreshCompletion() {
|
|
912
|
+
if (!this.completion) {
|
|
913
|
+
// `/` at the start and `@` anywhere open the picker without a keystroke,
|
|
914
|
+
// which is the behaviour both openclaw and opencode settled on.
|
|
915
|
+
const text = this.editor.getText();
|
|
916
|
+
const offset = this.cursorOffset();
|
|
917
|
+
const tail = text.slice(0, offset);
|
|
918
|
+
// `/` at the start, `@` anywhere, and the argument of a slash command all
|
|
919
|
+
// open the picker without a keystroke. The third is what makes `/skills d`
|
|
920
|
+
// suggest as you type instead of only after Tab — the behaviour both
|
|
921
|
+
// references settled on, and the reason a hundred-skill library is usable
|
|
922
|
+
// by someone who already knows the name they want.
|
|
923
|
+
const opens = /^\/[a-z0-9_?-]*$/i.test(tail)
|
|
924
|
+
|| /(?:^|\s)@[^\s@]*$/.test(tail)
|
|
925
|
+
|| /^\/[a-z0-9_?-]+[ \t]+\S*$/i.test(tail);
|
|
926
|
+
if (opens) this.openCompletion(false);
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
this.openCompletion(false);
|
|
930
|
+
if (this.completion) this.selected = Math.min(this.selected, this.completion.items.length - 1);
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/**
|
|
934
|
+
* Is the only suggestion the thing that is already typed?
|
|
935
|
+
*
|
|
936
|
+
* `/skill mine` offers exactly one completion — `mine` — which is what the
|
|
937
|
+
* user has already written. Accepting it changes nothing, refreshCompletion()
|
|
938
|
+
* immediately reopens the picker on the same text, and Enter becomes a key
|
|
939
|
+
* that can never submit. The command was unreachable.
|
|
940
|
+
*
|
|
941
|
+
* openclaw solves this with shouldSubmitExactArgumentCompletion
|
|
942
|
+
* (src/tui/commands.ts): when the sole completion equals the argument text,
|
|
943
|
+
* submit instead of completing. Same rule here, extended to a whole command
|
|
944
|
+
* so `/undo` submits on the first Enter rather than the second.
|
|
945
|
+
*
|
|
946
|
+
* Deliberately NOT applied to file completion: there, accepting a directory
|
|
947
|
+
* that matches exactly is how you drill into it.
|
|
948
|
+
*/
|
|
949
|
+
completionIsExact() {
|
|
950
|
+
if (!this.completion || this.completion.items.length !== 1) return false;
|
|
951
|
+
const [item] = this.completion.items;
|
|
952
|
+
if (this.completion.kind === 'command-argument') return item.value === this.completion.query;
|
|
953
|
+
if (this.completion.kind === 'command') return item.value === this.editor.getText();
|
|
954
|
+
return false;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
handleCompletionKey(key) {
|
|
958
|
+
if (key.name === 'up') { this.moveSelection(-1); return true; }
|
|
959
|
+
if (key.name === 'down') { this.moveSelection(1); return true; }
|
|
960
|
+
if (key.name === 'enter' && !key.alt) {
|
|
961
|
+
if (this.completionIsExact()) {
|
|
962
|
+
// Nothing left to complete. Close the picker and let the key through to
|
|
963
|
+
// submit, rather than re-offering what is already on the line.
|
|
964
|
+
this.completion = null;
|
|
965
|
+
return false;
|
|
966
|
+
}
|
|
967
|
+
this.acceptCompletion();
|
|
968
|
+
return true;
|
|
969
|
+
}
|
|
970
|
+
if (key.name === 'tab') { this.acceptCompletion(); return true; }
|
|
971
|
+
if (key.name === 'escape') { this.completion = null; return true; }
|
|
972
|
+
return false;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
moveSelection(direction) {
|
|
976
|
+
const count = this.completion.items.length;
|
|
977
|
+
this.selected = (this.selected + direction + count) % count;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
acceptCompletion() {
|
|
981
|
+
const item = this.completion.items[this.selected];
|
|
982
|
+
if (!item) { this.completion = null; return; }
|
|
983
|
+
const applied = applyCompletion(
|
|
984
|
+
this.editor.getText(), this.cursorOffset(), this.completion, item
|
|
985
|
+
);
|
|
986
|
+
this.editor.setText(applied.text);
|
|
987
|
+
// setText parks the cursor at the end; put it back where the insert ended.
|
|
988
|
+
this.setCursorOffset(applied.cursor);
|
|
989
|
+
this.completion = null;
|
|
990
|
+
this.refreshCompletion();
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
setCursorOffset(offset) {
|
|
994
|
+
let remaining = offset;
|
|
995
|
+
for (let index = 0; index < this.editor.lines.length; index++) {
|
|
996
|
+
const length = this.editor.lines[index].length;
|
|
997
|
+
if (remaining <= length) {
|
|
998
|
+
this.editor.line = index;
|
|
999
|
+
this.editor.col = remaining;
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
remaining -= length + 1;
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
// -------------------------------------------------------------------------
|
|
1007
|
+
// Drawing
|
|
1008
|
+
// -------------------------------------------------------------------------
|
|
1009
|
+
|
|
1010
|
+
/**
|
|
1011
|
+
* Clamp every row so the terminal can never wrap one of them.
|
|
1012
|
+
*
|
|
1013
|
+
* THE invariant of the live region. erase() walks back up `cursorRow` rows and
|
|
1014
|
+
* clears with \x1b[J, and both numbers are LOGICAL row counts. A row wider
|
|
1015
|
+
* than the terminal occupies TWO physical rows, so the walk lands one row too
|
|
1016
|
+
* low and \x1b[J then clears from the middle of the block — leaving the rows
|
|
1017
|
+
* above it on screen as debris. That is the doubled hint row and the trail of
|
|
1018
|
+
* half-drawn glyphs in the reported screenshot.
|
|
1019
|
+
*
|
|
1020
|
+
* Measuring correctly is necessary but not sufficient: every glyph in GLYPHS is
|
|
1021
|
+
* East Asian Ambiguous (see text.js), so the measurement can legitimately
|
|
1022
|
+
* disagree with the terminal by a column. Cutting to width - 1 makes a wrap
|
|
1023
|
+
* impossible whatever the measurement says, and leaves the last column free so
|
|
1024
|
+
* a row that lands exactly on the edge cannot trip the terminal's deferred-wrap
|
|
1025
|
+
* state either.
|
|
1026
|
+
*
|
|
1027
|
+
* truncate() is escape-aware and closes an open SGR at the cut, so a clamped
|
|
1028
|
+
* row cannot leak colour into the row below.
|
|
1029
|
+
*/
|
|
1030
|
+
clampRows(rows, width) {
|
|
1031
|
+
const limit = Math.max(1, width - 1);
|
|
1032
|
+
return rows.map((row) => (visibleWidth(row) <= limit ? row : truncate(row, limit, '')));
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
/** Remove everything this prompt drew, leaving the cursor where it started. */
|
|
1036
|
+
erase() {
|
|
1037
|
+
// Nothing of ours is on the terminal in stage mode — the band is painted by
|
|
1038
|
+
// a diff and the previous frame is a row array, not screen state. Walking
|
|
1039
|
+
// up and clearing here would erase rows the Stage owns and then paint over
|
|
1040
|
+
// them, which is the collision this method exists to prevent.
|
|
1041
|
+
if (this.surface) return;
|
|
1042
|
+
if (this.drawnRows === 0) {
|
|
1043
|
+
// Nothing of OURS is on screen — but the caret may still be sitting at a
|
|
1044
|
+
// non-zero column, because whoever wrote last left it there. Writing the
|
|
1045
|
+
// block from there puts its first row at that offset, which is how a
|
|
1046
|
+
// second `▏` appeared beside the first on the same row. Returning to
|
|
1047
|
+
// column 0 costs one byte and removes the whole class.
|
|
1048
|
+
this.out.write('\r');
|
|
1049
|
+
return;
|
|
1050
|
+
}
|
|
1051
|
+
if (this.cursorRow > 0) this.out.write(`\x1b[${this.cursorRow}A`);
|
|
1052
|
+
this.out.write('\r\x1b[J');
|
|
1053
|
+
this.drawnRows = 0;
|
|
1054
|
+
this.cursorRow = 0;
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
draw() {
|
|
1058
|
+
if (!this.active) return;
|
|
1059
|
+
this.erase();
|
|
1060
|
+
|
|
1061
|
+
const width = this.width();
|
|
1062
|
+
if (this.layer) return this.drawLayer(width);
|
|
1063
|
+
|
|
1064
|
+
// The input is a BLOCK, not a line.
|
|
1065
|
+
//
|
|
1066
|
+
// It used to be a bare `▸ ` prefix, so the place the user types looked like
|
|
1067
|
+
// one more row of shell output among all the others. opencode gives its
|
|
1068
|
+
// prompt a left border in the agent's colour and a filled surface
|
|
1069
|
+
// (component/prompt/index.tsx:1353), which is what makes the input read as a
|
|
1070
|
+
// surface you are writing INTO. Every row of the block is inset by the same
|
|
1071
|
+
// gutter, so the caret column is a constant rather than a special case for
|
|
1072
|
+
// row 0 — which is also one fewer thing for erase() to get wrong.
|
|
1073
|
+
const inset = visibleWidth(GLYPHS.bar) + GUTTER;
|
|
1074
|
+
const inner = Math.max(8, width - inset);
|
|
1075
|
+
const layout = this.editor.render(inner, '');
|
|
1076
|
+
|
|
1077
|
+
// Scroll the editor internally rather than growing without bound: keep a
|
|
1078
|
+
// window of MAX_PROMPT_ROWS that always contains the caret.
|
|
1079
|
+
const start = Math.max(0, Math.min(layout.cursorRow - MAX_PROMPT_ROWS + 1,
|
|
1080
|
+
layout.rows.length - MAX_PROMPT_ROWS));
|
|
1081
|
+
const top = Math.max(0, start);
|
|
1082
|
+
const window = layout.rows.slice(top, top + MAX_PROMPT_ROWS);
|
|
1083
|
+
const caretRow = layout.cursorRow - top;
|
|
1084
|
+
let body = window.slice();
|
|
1085
|
+
|
|
1086
|
+
if (this.editor.isEmpty && !this.completion) {
|
|
1087
|
+
const idle = fitHints([`try "${this.example}"`, '/ commands', '@ files'], inner, 'ask anything');
|
|
1088
|
+
body = [dim(this.busy ? 'type to queue a message…' : idle)];
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
// The model line lives INSIDE the box, as its last row — except while a
|
|
1092
|
+
// completion picker is open. The suggestions belong to the text being
|
|
1093
|
+
// typed, and an ambient status row wedged between the two breaks that
|
|
1094
|
+
// connection for no gain; opencode floats its autocomplete directly against
|
|
1095
|
+
// the input for the same reason.
|
|
1096
|
+
//
|
|
1097
|
+
// A blank row separates the two. Without it the box is two lines of text
|
|
1098
|
+
// touching, which reads as a list; the reference leaves a row of space and
|
|
1099
|
+
// that is most of what makes it read as a surface you are typing into.
|
|
1100
|
+
const agent = this.completion ? null : this.agentRow(inner);
|
|
1101
|
+
if (agent) body = body.concat(['', agent]);
|
|
1102
|
+
|
|
1103
|
+
// Air at the top of the surface and at the bottom, with the caret moved down
|
|
1104
|
+
// to match. The box is the thing the user is about to type into; it earns
|
|
1105
|
+
// the room.
|
|
1106
|
+
const rows = Number(this.out && this.out.rows);
|
|
1107
|
+
const roomy = !Number.isFinite(rows) || rows >= ROOMY_TERMINAL_ROWS;
|
|
1108
|
+
const padTop = (!Number.isFinite(rows) || rows >= CRAMPED_TERMINAL_ROWS) ? PROMPT_PAD_TOP : 0;
|
|
1109
|
+
const padBottom = roomy ? PROMPT_PAD_BOTTOM : 0;
|
|
1110
|
+
|
|
1111
|
+
const padded = new Array(padTop).fill('')
|
|
1112
|
+
.concat(body, new Array(padBottom).fill(''));
|
|
1113
|
+
const boxRows = barBlock(padded, {
|
|
1114
|
+
width,
|
|
1115
|
+
role: this.busy ? 'accent' : 'user',
|
|
1116
|
+
filled: true,
|
|
1117
|
+
surface: 'surface',
|
|
1118
|
+
punctuate: true
|
|
1119
|
+
});
|
|
1120
|
+
|
|
1121
|
+
const pickerRows = this.completion ? this.pickerRows(width) : [];
|
|
1122
|
+
const noticeRows = this.notice ? [dim(` ${this.notice}`)] : [];
|
|
1123
|
+
|
|
1124
|
+
// And a row of air between the box and the footer. The picker and a notice
|
|
1125
|
+
// belong to the input and stay welded to it — opencode floats its
|
|
1126
|
+
// autocomplete directly against the prompt for the same reason — so the gap
|
|
1127
|
+
// goes after them, immediately above the status row.
|
|
1128
|
+
const status = this.statusRow(width);
|
|
1129
|
+
const gap = status.length > 0 ? new Array(FOOTER_GAP).fill('') : [];
|
|
1130
|
+
|
|
1131
|
+
const all = this.clampRows(
|
|
1132
|
+
boxRows.concat(pickerRows, noticeRows, gap, status), width
|
|
1133
|
+
);
|
|
1134
|
+
|
|
1135
|
+
// Park the cursor where the user is typing, not at the end of the picker.
|
|
1136
|
+
this.present(all, caretRow + padTop, layout.cursorCol + inset);
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
/**
|
|
1140
|
+
* Put the composed block on screen.
|
|
1141
|
+
*
|
|
1142
|
+
* The two draw paths used to end with the same eleven lines of cursor
|
|
1143
|
+
* arithmetic; extracting it is what lets the Stage substitute itself for
|
|
1144
|
+
* stdout without either path knowing. In stage mode the rows are handed to
|
|
1145
|
+
* the surface, which stacks the transcript viewport above them and paints the
|
|
1146
|
+
* whole band in one diff — so drawnRows/cursorRow stay at zero, because
|
|
1147
|
+
* nothing of ours is on the terminal for erase() to walk back over.
|
|
1148
|
+
*/
|
|
1149
|
+
present(all, caretRow, caretCol) {
|
|
1150
|
+
if (this.surface) {
|
|
1151
|
+
this.drawnRows = 0;
|
|
1152
|
+
this.cursorRow = 0;
|
|
1153
|
+
this.surface.present(all, { row: caretRow, col: caretCol });
|
|
1154
|
+
return;
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
this.out.write(all.join('\n'));
|
|
1158
|
+
this.drawnRows = all.length;
|
|
1159
|
+
|
|
1160
|
+
const below = all.length - 1 - caretRow;
|
|
1161
|
+
if (below > 0) this.out.write(`\x1b[${below}A`);
|
|
1162
|
+
this.cursorRow = caretRow;
|
|
1163
|
+
this.out.write('\r');
|
|
1164
|
+
if (caretCol > 0) this.out.write(`\x1b[${caretCol}C`);
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
/**
|
|
1168
|
+
* Attach a Stage surface. From here on the prompt composes rows and this
|
|
1169
|
+
* decides where they land.
|
|
1170
|
+
*/
|
|
1171
|
+
attachSurface(surface) {
|
|
1172
|
+
this.surface = surface || null;
|
|
1173
|
+
if (!surface) return;
|
|
1174
|
+
surface.onDirty = () => this.scheduleDraw();
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
/**
|
|
1178
|
+
* Put a finished block into the transcript.
|
|
1179
|
+
*
|
|
1180
|
+
* `produce` must be a pure function of width — a stage frame is re-rendered
|
|
1181
|
+
* on every resize, so anything it closes over must already be resolved. In
|
|
1182
|
+
* scrollback mode it is called once, immediately, and printed.
|
|
1183
|
+
*/
|
|
1184
|
+
commit(kind, produce) {
|
|
1185
|
+
// clampRows() guarded the live region and nothing else, so a committed
|
|
1186
|
+
// block could still be exactly terminal-width, wrap, and desynchronise the
|
|
1187
|
+
// row count erase() walks back. The net belongs on both paths, not one.
|
|
1188
|
+
const clamped = (width) => this.clampRows(produce(width), width);
|
|
1189
|
+
if (this.surface) { this.surface.commit(kind, clamped); return; }
|
|
1190
|
+
this.erase();
|
|
1191
|
+
this.out.write(clamped(this.width()).join('\n') + '\n');
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
/** The live region while a dialog is up: the panel, then the status row. */
|
|
1195
|
+
drawLayer(width) {
|
|
1196
|
+
const panelWidth = Math.max(24, Math.min(width - 1, this.layer.width || 64));
|
|
1197
|
+
const maxHeight = Math.max(6, Math.min(this.layer.preferredHeight(), 20));
|
|
1198
|
+
const panel = panelRows(this.layer, panelWidth, maxHeight);
|
|
1199
|
+
const all = this.clampRows(panel.concat(this.statusRow(width)), width);
|
|
1200
|
+
|
|
1201
|
+
// panelRows() called body(), which is where a layer publishes its cursor.
|
|
1202
|
+
const cursor = this.layer.cursor;
|
|
1203
|
+
this.present(all, cursor ? 1 + cursor.y : 0, cursor ? 2 + cursor.x : 0);
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
pickerRows(width) {
|
|
1207
|
+
const rows = renderPicker(this.completion.items, this.selected, width);
|
|
1208
|
+
const labelWidth = Math.min(
|
|
1209
|
+
36,
|
|
1210
|
+
rows.reduce((max, row) => Math.max(max, visibleWidth(row.label)), 0)
|
|
1211
|
+
);
|
|
1212
|
+
|
|
1213
|
+
const drawn = rows.map((row) => {
|
|
1214
|
+
const marker = row.selected ? paint('accent', GLYPHS.arrow) : ' ';
|
|
1215
|
+
const label = truncate(row.label, labelWidth, GLYPHS.ellipsis);
|
|
1216
|
+
const padded = label + ' '.repeat(Math.max(0, labelWidth - visibleWidth(label)));
|
|
1217
|
+
const name = row.selected ? bold(paint('ink', padded)) : paint('ink', padded);
|
|
1218
|
+
const description = row.description
|
|
1219
|
+
? dim(' ' + truncate(row.description, Math.max(0, width - labelWidth - 8), GLYPHS.ellipsis))
|
|
1220
|
+
: '';
|
|
1221
|
+
return ` ${marker} ${name}${description}`;
|
|
1222
|
+
});
|
|
1223
|
+
|
|
1224
|
+
// Count against everything that MATCHED, not against the capped array the
|
|
1225
|
+
// picker happens to hold. Reporting the capped figure told the user there
|
|
1226
|
+
// were 42 more entries when 107 were reachable by scrolling.
|
|
1227
|
+
const matched = this.completion.total || this.completion.items.length;
|
|
1228
|
+
const hidden = matched - drawn.length;
|
|
1229
|
+
if (hidden > 0) drawn.push(dim(` ${GLYPHS.ellipsis} ${hidden} more`));
|
|
1230
|
+
return drawn;
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
/**
|
|
1235
|
+
* Hint line for the session header — the keys that are not discoverable.
|
|
1236
|
+
*
|
|
1237
|
+
* FITTED to the terminal. The full list is 140 columns; printed unconditionally
|
|
1238
|
+
* it wrapped on any terminal narrower than that, which is how the very first
|
|
1239
|
+
* thing a user sees ended up broken across two rows mid-word. Fragments are
|
|
1240
|
+
* dropped from the right — least important first — rather than the line being
|
|
1241
|
+
* truncated, because a hint clipped mid-key is worse than a missing one.
|
|
1242
|
+
*/
|
|
1243
|
+
export function keyHints(width = terminalWidth()) {
|
|
1244
|
+
const parts = [
|
|
1245
|
+
'enter send', 'alt+enter newline', 'ctrl+p palette', '/ commands', '@ files',
|
|
1246
|
+
'↑ history', 'tab complete', 'esc interrupt', 'ctrl+o expand'
|
|
1247
|
+
];
|
|
1248
|
+
const separator = ` ${GLYPHS.dot} `;
|
|
1249
|
+
const kept = parts.slice();
|
|
1250
|
+
// Reserve a column, for the same reason every drawn row does: a line landing
|
|
1251
|
+
// exactly on the margin trips the terminal's deferred wrap.
|
|
1252
|
+
while (kept.length > 1 && visibleWidth(kept.join(separator)) > width - 1) kept.pop();
|
|
1253
|
+
return HAS_COLOR
|
|
1254
|
+
? kept.map((part) => dim(part)).join(dim(separator))
|
|
1255
|
+
: kept.join(separator);
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
export { MAX_VISIBLE };
|