@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,235 @@
|
|
|
1
|
+
// The retained transcript — the model the Stage scrolls over.
|
|
2
|
+
//
|
|
3
|
+
// ChatRenderer was a pure formatter into stdout: every transcript row was
|
|
4
|
+
// `stdout.write(line + '\n')` and nothing was kept. That is exactly right for a
|
|
5
|
+
// scrollback surface, where the terminal IS the model — and it makes a viewport
|
|
6
|
+
// impossible, because a viewport has to answer "what are rows 400..440 at 96
|
|
7
|
+
// columns" long after those rows were produced.
|
|
8
|
+
//
|
|
9
|
+
// FRAMES, NOT ROWS. A frame is (kind, produce(width) -> string[]). Storing the
|
|
10
|
+
// rendered rows instead would mean a resize could not reflow: that is the
|
|
11
|
+
// failure opencode's --mini mode actually has, where the only way to handle a
|
|
12
|
+
// resize is to clear the saved lines and re-emit the entire transcript
|
|
13
|
+
// (packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts:373-398). Storing the
|
|
14
|
+
// producer makes a resize a re-render, which is both cheaper and correct.
|
|
15
|
+
//
|
|
16
|
+
// The producer must be a pure function of width. That is a real constraint on
|
|
17
|
+
// the caller: every value that depends on wall-clock time or on renderer state
|
|
18
|
+
// (a step number, an elapsed duration, the model name at the time) has to be
|
|
19
|
+
// resolved BEFORE the closure is built, not read from inside it. ChatRenderer
|
|
20
|
+
// separates its state mutations from its rendering for exactly this reason.
|
|
21
|
+
//
|
|
22
|
+
// Pure JS over nothing at all. No dependency, no shell.
|
|
23
|
+
|
|
24
|
+
// Frames kept re-renderable. A frame is small — a closure plus the message it
|
|
25
|
+
// closed over — so this is a bound on pathological sessions, not a working
|
|
26
|
+
// limit. Overflow is not lost: it goes to onEvict, which the Stage wires to
|
|
27
|
+
// real terminal scrollback, so eviction degrades to today's behaviour instead
|
|
28
|
+
// of dropping history.
|
|
29
|
+
export const MAX_FRAMES = 4000;
|
|
30
|
+
|
|
31
|
+
// A frame kind is a semantic label, not a style. The rail (and, later, the
|
|
32
|
+
// jump bindings) index on it, so it must survive a change of theme.
|
|
33
|
+
export const KINDS = Object.freeze([
|
|
34
|
+
'session', 'user', 'assistant', 'reasoning', 'plan', 'step', 'result',
|
|
35
|
+
'approval', 'notice', 'error', 'skill', 'turn', 'checkpoint'
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
function normalizeRows(produced) {
|
|
39
|
+
if (!Array.isArray(produced)) return [];
|
|
40
|
+
const rows = [];
|
|
41
|
+
for (const row of produced) {
|
|
42
|
+
const text = row == null ? '' : String(row);
|
|
43
|
+
// A frame may never contain a newline: the Stage paints with relative
|
|
44
|
+
// cursor motion only, and one embedded '\n' would scroll the terminal out
|
|
45
|
+
// from under the whole block. Split rather than drop, so nothing is lost.
|
|
46
|
+
if (text.includes('\n')) rows.push(...text.split('\n'));
|
|
47
|
+
else rows.push(text);
|
|
48
|
+
}
|
|
49
|
+
return rows;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class Transcript {
|
|
53
|
+
constructor({ maxFrames = MAX_FRAMES, onEvict = null, onChange = null } = {}) {
|
|
54
|
+
this.frames = [];
|
|
55
|
+
this.maxFrames = Math.max(1, maxFrames);
|
|
56
|
+
this.onEvict = typeof onEvict === 'function' ? onEvict : null;
|
|
57
|
+
// Fired after any structural change. In scrollback mode the renderer's
|
|
58
|
+
// onAfterWrite hook is what tells the prompt to repaint; in stage mode no
|
|
59
|
+
// byte reaches the terminal, so the model has to say so itself.
|
|
60
|
+
this.onChange = typeof onChange === 'function' ? onChange : null;
|
|
61
|
+
|
|
62
|
+
this.nextId = 1;
|
|
63
|
+
// Bumped on every structural change. The Stage compares it to decide
|
|
64
|
+
// whether a repaint is needed at all; opencode's renderer makes the same
|
|
65
|
+
// bet (a frame with nothing to say emits zero bytes).
|
|
66
|
+
this.revision = 0;
|
|
67
|
+
this.evictedFrames = 0;
|
|
68
|
+
this.evictedRows = 0;
|
|
69
|
+
|
|
70
|
+
// Flattened view, memoised per width.
|
|
71
|
+
this.cache = null;
|
|
72
|
+
// The width the most recent flatten used, so eviction can render a frame
|
|
73
|
+
// one last time on its way out.
|
|
74
|
+
this.lastWidth = 80;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
get length() {
|
|
78
|
+
return this.frames.length;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
isEmpty() {
|
|
82
|
+
return this.frames.length === 0;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Append one frame.
|
|
87
|
+
*
|
|
88
|
+
* `produce` is called lazily and may be called again at a different width.
|
|
89
|
+
* It must not mutate anything the caller will read later.
|
|
90
|
+
*/
|
|
91
|
+
append({ kind = 'notice', produce, meta = null } = {}) {
|
|
92
|
+
if (typeof produce !== 'function') return null;
|
|
93
|
+
const frame = {
|
|
94
|
+
id: this.nextId++,
|
|
95
|
+
kind: KINDS.includes(kind) ? kind : 'notice',
|
|
96
|
+
ts: Date.now(),
|
|
97
|
+
meta,
|
|
98
|
+
produce,
|
|
99
|
+
rows: null,
|
|
100
|
+
renderedWidth: -1
|
|
101
|
+
};
|
|
102
|
+
this.frames.push(frame);
|
|
103
|
+
this.cache = null;
|
|
104
|
+
this.revision++;
|
|
105
|
+
this.evictOverflow();
|
|
106
|
+
this.changed();
|
|
107
|
+
return frame.id;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Replace the newest frame in place.
|
|
112
|
+
*
|
|
113
|
+
* Streaming needs this: the tail of an assistant reply is re-rendered as
|
|
114
|
+
* tokens arrive, and appending a frame per token would make the rail useless
|
|
115
|
+
* and the flatten quadratic.
|
|
116
|
+
*/
|
|
117
|
+
replaceTail({ kind, produce, meta } = {}) {
|
|
118
|
+
const frame = this.frames[this.frames.length - 1];
|
|
119
|
+
if (!frame || typeof produce !== 'function') return this.append({ kind, produce, meta });
|
|
120
|
+
if (kind && KINDS.includes(kind)) frame.kind = kind;
|
|
121
|
+
if (meta !== undefined) frame.meta = meta;
|
|
122
|
+
frame.produce = produce;
|
|
123
|
+
frame.rows = null;
|
|
124
|
+
frame.renderedWidth = -1;
|
|
125
|
+
this.cache = null;
|
|
126
|
+
this.revision++;
|
|
127
|
+
this.changed();
|
|
128
|
+
return frame.id;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
changed() {
|
|
132
|
+
if (!this.onChange) return;
|
|
133
|
+
// A listener that throws must not take the transcript down with it: the
|
|
134
|
+
// model is the session's memory and a repaint is only a picture of it.
|
|
135
|
+
try { this.onChange(this); } catch { /* the surface will catch up next frame */ }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
evictOverflow() {
|
|
139
|
+
while (this.frames.length > this.maxFrames) {
|
|
140
|
+
const frame = this.frames.shift();
|
|
141
|
+
const rows = this.renderFrame(frame, this.lastWidth);
|
|
142
|
+
this.evictedFrames++;
|
|
143
|
+
this.evictedRows += rows.length;
|
|
144
|
+
this.cache = null;
|
|
145
|
+
// Eviction is not deletion. The Stage hands these to the terminal, where
|
|
146
|
+
// they become permanent scrollback exactly as they would have in the
|
|
147
|
+
// scrollback renderer.
|
|
148
|
+
if (this.onEvict) {
|
|
149
|
+
try { this.onEvict(rows, frame); } catch { /* a sink that throws must not kill the session */ }
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
renderFrame(frame, width) {
|
|
155
|
+
if (frame.rows && frame.renderedWidth === width) return frame.rows;
|
|
156
|
+
let produced = [];
|
|
157
|
+
try {
|
|
158
|
+
produced = frame.produce(width);
|
|
159
|
+
} catch {
|
|
160
|
+
// A formatter that throws must cost one frame, not the session. The frame
|
|
161
|
+
// stays in place so the rail and the row count remain stable.
|
|
162
|
+
produced = [];
|
|
163
|
+
}
|
|
164
|
+
frame.rows = normalizeRows(produced);
|
|
165
|
+
frame.renderedWidth = width;
|
|
166
|
+
return frame.rows;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Every row of the transcript at this width, plus the marker index.
|
|
171
|
+
*
|
|
172
|
+
* Memoised on (width, revision): a repaint at an unchanged width and an
|
|
173
|
+
* unchanged transcript costs one comparison.
|
|
174
|
+
*/
|
|
175
|
+
flatten(width) {
|
|
176
|
+
const w = Math.max(1, Math.floor(width) || 1);
|
|
177
|
+
if (this.cache && this.cache.width === w && this.cache.revision === this.revision) {
|
|
178
|
+
return this.cache;
|
|
179
|
+
}
|
|
180
|
+
this.lastWidth = w;
|
|
181
|
+
|
|
182
|
+
const rows = [];
|
|
183
|
+
const markers = [];
|
|
184
|
+
for (const frame of this.frames) {
|
|
185
|
+
markers.push({ id: frame.id, kind: frame.kind, row: rows.length, meta: frame.meta, ts: frame.ts });
|
|
186
|
+
const produced = this.renderFrame(frame, w);
|
|
187
|
+
for (const row of produced) rows.push(row);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
this.cache = { width: w, revision: this.revision, rows, markers };
|
|
191
|
+
return this.cache;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
rows(width) {
|
|
195
|
+
return this.flatten(width).rows;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Frame start rows — what the rail draws and what [ / ] jump between.
|
|
200
|
+
*/
|
|
201
|
+
markers(width) {
|
|
202
|
+
return this.flatten(width).markers;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
height(width) {
|
|
206
|
+
return this.flatten(width).rows.length;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* A window of rows, clamped. The viewport asks for exactly what it can show.
|
|
211
|
+
*/
|
|
212
|
+
slice(width, start, count) {
|
|
213
|
+
const rows = this.flatten(width).rows;
|
|
214
|
+
const from = Math.max(0, Math.min(Math.floor(start) || 0, rows.length));
|
|
215
|
+
return rows.slice(from, from + Math.max(0, Math.floor(count) || 0));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Everything, for print-through on exit.
|
|
220
|
+
*
|
|
221
|
+
* This is the half opencode cannot have in either mode: its alternate-screen
|
|
222
|
+
* TUI leaves nothing behind, and its split-footer mode leaves everything but
|
|
223
|
+
* cannot scroll. Flushing the model on the way out gives both.
|
|
224
|
+
*/
|
|
225
|
+
snapshot(width) {
|
|
226
|
+
return this.flatten(width).rows.slice();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
clear() {
|
|
230
|
+
this.frames = [];
|
|
231
|
+
this.cache = null;
|
|
232
|
+
this.revision++;
|
|
233
|
+
this.changed();
|
|
234
|
+
}
|
|
235
|
+
}
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
// The scroll/follow state machine over a Transcript.
|
|
2
|
+
//
|
|
3
|
+
// This behavior is specified by the viewport contract: the wheel and the keyboard move
|
|
4
|
+
// the box, the terminal does not scroll, and while a task runs the view
|
|
5
|
+
// auto-scrolls — until you scroll up, at which point it stops and lets the
|
|
6
|
+
// content grow underneath you.
|
|
7
|
+
//
|
|
8
|
+
// THE ONE ALGORITHM WORTH PORTING VERBATIM, and the reason to port it rather
|
|
9
|
+
// than write the obvious version:
|
|
10
|
+
//
|
|
11
|
+
// The obvious version sets a "user scrolled away" flag when the user presses a
|
|
12
|
+
// key or turns the wheel. That is wrong in both directions — a programmatic
|
|
13
|
+
// scroll looks like user intent, and returning to the bottom by wheel does not
|
|
14
|
+
// clear it. @opentui/core's ScrollBox (ScrollBox.ts:224-242) instead DERIVES the
|
|
15
|
+
// flag from position on every scroll mutation:
|
|
16
|
+
//
|
|
17
|
+
// hasManualScroll = hasScrollableContent && !isAtStickyPosition()
|
|
18
|
+
//
|
|
19
|
+
// with a reentrancy guard around the auto-follow's own writes, so the follow
|
|
20
|
+
// logic cannot mistake itself for the user. Any scroll that leaves the bottom
|
|
21
|
+
// edge sets it; any scroll that lands on the bottom edge clears it; nobody has
|
|
22
|
+
// to enumerate the ways a scroll can happen. Re-engagement has a one-row
|
|
23
|
+
// tolerance (ScrollBox.ts:497-512), which is what makes "scroll back down and
|
|
24
|
+
// it starts following again" feel forgiving instead of pixel-precise.
|
|
25
|
+
//
|
|
26
|
+
// Wheel deltas: the wire always reports 1 (lib/parse.mouse.ts:166-171), so all
|
|
27
|
+
// speed lives here. Three rows per notch is opencode's default and it is
|
|
28
|
+
// genuinely the right number. The fractional accumulator is what allows a
|
|
29
|
+
// non-integer speed without drifting.
|
|
30
|
+
//
|
|
31
|
+
// Pure JS. No dependency, no shell.
|
|
32
|
+
|
|
33
|
+
// Rows per wheel notch. opencode's default, and its acceleration is opt-in for
|
|
34
|
+
// good reason: an accelerating wheel in a transcript overshoots the thing you
|
|
35
|
+
// were trying to read.
|
|
36
|
+
export const DEFAULT_SCROLL_SPEED = 3;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Opt-in wheel acceleration, the same shape opencode uses.
|
|
40
|
+
*
|
|
41
|
+
* (`@opentui/core` `lib/scroll-acceleration.ts:34-97`.) A streak of notches
|
|
42
|
+
* arriving close together multiplies the distance; a gap resets it. It stays
|
|
43
|
+
* OPT-IN because an accelerating wheel in a transcript overshoots the thing you
|
|
44
|
+
* were trying to read, which is worse than a wheel that is merely slow.
|
|
45
|
+
*
|
|
46
|
+
* `minTickInterval` is not a nicety: some terminals emit a wheel notch twice in
|
|
47
|
+
* the same few milliseconds, and counting both makes the curve run away.
|
|
48
|
+
*/
|
|
49
|
+
export class ScrollAcceleration {
|
|
50
|
+
constructor({ history = 3, streakTimeout = 150, minTickInterval = 6 } = {}) {
|
|
51
|
+
this.historySize = history;
|
|
52
|
+
this.streakTimeout = streakTimeout;
|
|
53
|
+
this.minTickInterval = minTickInterval;
|
|
54
|
+
this.intervals = [];
|
|
55
|
+
this.lastTick = 0;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
reset() {
|
|
59
|
+
this.intervals = [];
|
|
60
|
+
this.lastTick = 0;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
tick(now = Date.now()) {
|
|
64
|
+
const A = 0.8;
|
|
65
|
+
const tau = 3;
|
|
66
|
+
const maxMultiplier = 6;
|
|
67
|
+
|
|
68
|
+
const gap = this.lastTick ? now - this.lastTick : Infinity;
|
|
69
|
+
if (gap === Infinity || gap > this.streakTimeout) {
|
|
70
|
+
this.lastTick = now;
|
|
71
|
+
this.intervals = [];
|
|
72
|
+
return 1;
|
|
73
|
+
}
|
|
74
|
+
if (gap < this.minTickInterval) return 1;
|
|
75
|
+
|
|
76
|
+
this.lastTick = now;
|
|
77
|
+
this.intervals.push(gap);
|
|
78
|
+
if (this.intervals.length > this.historySize) this.intervals.shift();
|
|
79
|
+
|
|
80
|
+
const average = this.intervals.reduce((sum, v) => sum + v, 0) / this.intervals.length;
|
|
81
|
+
const velocity = 100 / average;
|
|
82
|
+
return Math.min(1 + A * (Math.exp(velocity / tau) - 1), maxMultiplier);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// How close to the bottom counts as "back at the bottom" for the purposes of
|
|
87
|
+
// resuming follow.
|
|
88
|
+
const REENGAGE_TOLERANCE = 1;
|
|
89
|
+
|
|
90
|
+
export class Viewport {
|
|
91
|
+
constructor({ transcript, height = 20, width = 80, speed = DEFAULT_SCROLL_SPEED, acceleration = null } = {}) {
|
|
92
|
+
this.transcript = transcript;
|
|
93
|
+
this.viewHeight = Math.max(1, Math.floor(height));
|
|
94
|
+
this.viewWidth = Math.max(1, Math.floor(width));
|
|
95
|
+
this.speed = speed > 0 ? speed : DEFAULT_SCROLL_SPEED;
|
|
96
|
+
// null means a flat speed, which is the default and the right one.
|
|
97
|
+
this.acceleration = acceleration || null;
|
|
98
|
+
|
|
99
|
+
this.scrollTop = 0;
|
|
100
|
+
// Derived, never assigned from an event. See the header.
|
|
101
|
+
this.manual = false;
|
|
102
|
+
this.applyingSticky = false;
|
|
103
|
+
this.accumulator = 0;
|
|
104
|
+
// Rows that arrived while the user was reading somewhere else.
|
|
105
|
+
this.unseen = 0;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
get height() { return this.viewHeight; }
|
|
109
|
+
get width() { return this.viewWidth; }
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Geometry changed — a resize, or the input box growing under the viewport.
|
|
113
|
+
*
|
|
114
|
+
* Resizing must not silently unfollow: growing the viewport reduces
|
|
115
|
+
* maxScrollTop, which would leave scrollTop above it and read as "the user
|
|
116
|
+
* scrolled away". refresh() re-derives from the new geometry instead.
|
|
117
|
+
*/
|
|
118
|
+
resize({ height, width } = {}) {
|
|
119
|
+
if (Number.isFinite(height) && height > 0) this.viewHeight = Math.floor(height);
|
|
120
|
+
if (Number.isFinite(width) && width > 0) this.viewWidth = Math.floor(width);
|
|
121
|
+
this.refresh();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
contentHeight() {
|
|
125
|
+
return this.transcript ? this.transcript.height(this.viewWidth) : 0;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
maxScrollTop() {
|
|
129
|
+
return Math.max(0, this.contentHeight() - this.viewHeight);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Is there enough content for scrolling to mean anything?
|
|
134
|
+
*
|
|
135
|
+
* The `> 1` rather than `> 0` is deliberate and comes from the reference: one
|
|
136
|
+
* row of overflow is not a scroll, and treating it as one makes the follow
|
|
137
|
+
* flag flicker on every wrapped line during streaming.
|
|
138
|
+
*/
|
|
139
|
+
scrollable() {
|
|
140
|
+
return this.maxScrollTop() > 1;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
atBottom() {
|
|
144
|
+
return this.scrollTop >= this.maxScrollTop();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
get following() {
|
|
148
|
+
return !this.manual;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Re-derive the follow flag from where we now are. Called after EVERY scroll
|
|
153
|
+
* mutation, whoever caused it.
|
|
154
|
+
*/
|
|
155
|
+
sync() {
|
|
156
|
+
if (this.applyingSticky) {
|
|
157
|
+
// A move we made ourselves. It is only allowed to CLEAR the flag — by
|
|
158
|
+
// arriving back at the bottom — never to set it.
|
|
159
|
+
if (this.manual && this.scrollable() && this.atBottom()) {
|
|
160
|
+
this.manual = false;
|
|
161
|
+
this.unseen = 0;
|
|
162
|
+
}
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
this.manual = this.scrollable() && !this.atBottom();
|
|
166
|
+
if (!this.manual) this.unseen = 0;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The content changed height — a token arrived, a tool printed, a frame was
|
|
171
|
+
* evicted. This is where auto-scroll actually happens.
|
|
172
|
+
*/
|
|
173
|
+
refresh(grownBy = 0) {
|
|
174
|
+
const wasApplying = this.applyingSticky;
|
|
175
|
+
this.applyingSticky = true;
|
|
176
|
+
try {
|
|
177
|
+
const max = this.maxScrollTop();
|
|
178
|
+
|
|
179
|
+
// The tolerance applies to GROWTH only, and this is where Causyne departs
|
|
180
|
+
// from the reference on purpose.
|
|
181
|
+
//
|
|
182
|
+
// opencode applies it on any size change (ScrollBox.ts:766-774), and
|
|
183
|
+
// refresh() runs here on every frame because the viewport is re-measured
|
|
184
|
+
// whenever the input box changes height. With a flat tolerance, scrolling
|
|
185
|
+
// up exactly one row leaves scrollTop at max - 1, and the very next
|
|
186
|
+
// keystroke re-engages and snaps it back — so a one-line scroll is a key
|
|
187
|
+
// that visibly does nothing. "You came back to the bottom while content
|
|
188
|
+
// was streaming" is a statement about growth; without growth, the bottom
|
|
189
|
+
// means the bottom.
|
|
190
|
+
const tolerance = grownBy > 0 ? REENGAGE_TOLERANCE : 0;
|
|
191
|
+
|
|
192
|
+
if (!this.manual) {
|
|
193
|
+
this.scrollTop = max;
|
|
194
|
+
this.unseen = 0;
|
|
195
|
+
} else if (max > 0 && this.scrollTop >= max - tolerance) {
|
|
196
|
+
// The user came back to the bottom while content was streaming.
|
|
197
|
+
this.manual = false;
|
|
198
|
+
this.scrollTop = max;
|
|
199
|
+
this.unseen = 0;
|
|
200
|
+
} else {
|
|
201
|
+
// Reading somewhere else: hold position, but never let a shrinking
|
|
202
|
+
// transcript strand the view past the end of it.
|
|
203
|
+
this.scrollTop = Math.min(this.scrollTop, max);
|
|
204
|
+
if (grownBy > 0) this.unseen += grownBy;
|
|
205
|
+
}
|
|
206
|
+
} finally {
|
|
207
|
+
this.applyingSticky = wasApplying;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
scrollTo(position, { snap = false } = {}) {
|
|
212
|
+
const max = this.maxScrollTop();
|
|
213
|
+
let next = Math.max(0, Math.min(Math.round(position), max));
|
|
214
|
+
|
|
215
|
+
// THE TOLERANCE BELONGS TO DOWNWARD MOTION, not to the repaint.
|
|
216
|
+
//
|
|
217
|
+
// The reference puts it in the growth handler (ScrollBox.ts:497-512), where
|
|
218
|
+
// it covers the case that actually annoys people: a wheel of three rows per
|
|
219
|
+
// notch lands you one row short of the bottom, and follow never resumes.
|
|
220
|
+
// But that handler runs on every repaint here, so a flat tolerance also
|
|
221
|
+
// undoes a deliberate one-row scroll UP — a key that visibly does nothing.
|
|
222
|
+
//
|
|
223
|
+
// The two are mechanically identical and differ only in intent, and the
|
|
224
|
+
// direction of travel is the one piece of intent that IS available. So the
|
|
225
|
+
// snap is applied to downward motion only: scrolling down to within a row
|
|
226
|
+
// of the end means the end; scrolling up by one row means one row.
|
|
227
|
+
if (snap && next > this.scrollTop && max - next <= REENGAGE_TOLERANCE) next = max;
|
|
228
|
+
|
|
229
|
+
if (next !== this.scrollTop) this.scrollTop = next;
|
|
230
|
+
this.sync();
|
|
231
|
+
return this.scrollTop;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
scrollBy(delta) {
|
|
235
|
+
return this.scrollTo(this.scrollTop + delta, { snap: delta > 0 });
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* One wheel notch. `direction` is 'up' or 'down'.
|
|
240
|
+
*
|
|
241
|
+
* The accumulator carries the fraction so a speed of, say, 1.5 does not
|
|
242
|
+
* quietly round to 1 or 2 forever.
|
|
243
|
+
*/
|
|
244
|
+
wheel(direction, notches = 1, now = Date.now()) {
|
|
245
|
+
const before = this.scrollTop;
|
|
246
|
+
const multiplier = this.acceleration ? this.acceleration.tick(now) : 1;
|
|
247
|
+
const amount = Math.abs(notches) * this.speed * multiplier;
|
|
248
|
+
this.accumulator += direction === 'up' ? -amount : amount;
|
|
249
|
+
const whole = Math.trunc(this.accumulator);
|
|
250
|
+
if (whole !== 0) {
|
|
251
|
+
this.accumulator -= whole;
|
|
252
|
+
this.scrollBy(whole);
|
|
253
|
+
}
|
|
254
|
+
// `moved` is what lets the caller tell "scrolled" from "already at the
|
|
255
|
+
// end". Without it the wheel was swallowed unconditionally, so reaching the
|
|
256
|
+
// top of the band meant the terminal's own scrollback — which holds the
|
|
257
|
+
// rest of the conversation — could not be reached by wheel at all.
|
|
258
|
+
this.lastWheelMoved = this.scrollTop !== before;
|
|
259
|
+
return this.scrollTop;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// A page keeps one row of overlap, which is what every pager does and what
|
|
263
|
+
// lets the eye carry across the jump. (opencode calls height/2 a "page" and
|
|
264
|
+
// height/4 a "half page"; both are half of what their names claim, and there
|
|
265
|
+
// is no reason to inherit that.)
|
|
266
|
+
pageUp() { return this.scrollBy(-Math.max(1, this.viewHeight - 1)); }
|
|
267
|
+
pageDown() { return this.scrollBy(Math.max(1, this.viewHeight - 1)); }
|
|
268
|
+
halfPageUp() { return this.scrollBy(-Math.max(1, Math.floor(this.viewHeight / 2))); }
|
|
269
|
+
halfPageDown() { return this.scrollBy(Math.max(1, Math.floor(this.viewHeight / 2))); }
|
|
270
|
+
lineUp(n = 1) { return this.scrollBy(-n); }
|
|
271
|
+
lineDown(n = 1) { return this.scrollBy(n); }
|
|
272
|
+
toTop() { return this.scrollTo(0); }
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Jump to the bottom and resume following.
|
|
276
|
+
*
|
|
277
|
+
* scrollTo() alone would land at the bottom and sync() would clear the flag,
|
|
278
|
+
* but only if the content is scrollable; going through the sticky path makes
|
|
279
|
+
* "follow again" unconditional, which is what the user means by pressing it.
|
|
280
|
+
*/
|
|
281
|
+
toBottom() {
|
|
282
|
+
this.manual = false;
|
|
283
|
+
this.unseen = 0;
|
|
284
|
+
this.refresh();
|
|
285
|
+
return this.scrollTop;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Frame boundaries, for step-to-step and turn-to-turn jumps.
|
|
290
|
+
*/
|
|
291
|
+
jump(direction, kinds = null) {
|
|
292
|
+
if (!this.transcript) return this.scrollTop;
|
|
293
|
+
const markers = this.transcript.markers(this.viewWidth)
|
|
294
|
+
.filter((m) => !kinds || kinds.includes(m.kind));
|
|
295
|
+
if (markers.length === 0) return this.scrollTop;
|
|
296
|
+
|
|
297
|
+
if (direction < 0) {
|
|
298
|
+
const previous = markers.filter((m) => m.row < this.scrollTop).pop();
|
|
299
|
+
return this.scrollTo(previous ? previous.row : 0);
|
|
300
|
+
}
|
|
301
|
+
const next = markers.find((m) => m.row > this.scrollTop);
|
|
302
|
+
return next ? this.scrollTo(next.row) : this.toBottom();
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* The rows on screen, top-aligned and padded to the full height.
|
|
307
|
+
*
|
|
308
|
+
* Top-aligned because that is what the reference does: content starts at the
|
|
309
|
+
* top of the box and the gap sits above the prompt, not below the first
|
|
310
|
+
* message.
|
|
311
|
+
*/
|
|
312
|
+
rows() {
|
|
313
|
+
const window = this.transcript
|
|
314
|
+
? this.transcript.slice(this.viewWidth, this.scrollTop, this.viewHeight)
|
|
315
|
+
: [];
|
|
316
|
+
const out = window.slice(0, this.viewHeight);
|
|
317
|
+
while (out.length < this.viewHeight) out.push('');
|
|
318
|
+
return out;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Where we are, for the rail and the "N new below" affordance.
|
|
323
|
+
*/
|
|
324
|
+
state() {
|
|
325
|
+
const max = this.maxScrollTop();
|
|
326
|
+
return {
|
|
327
|
+
scrollTop: this.scrollTop,
|
|
328
|
+
max,
|
|
329
|
+
height: this.viewHeight,
|
|
330
|
+
contentHeight: this.contentHeight(),
|
|
331
|
+
following: this.following,
|
|
332
|
+
unseen: this.unseen,
|
|
333
|
+
// 0..1, or null when there is nothing to scroll.
|
|
334
|
+
progress: max > 0 ? this.scrollTop / max : null
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
}
|