@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,609 @@
|
|
|
1
|
+
// StageSurface — what ChatPrompt draws into when the Stage is running.
|
|
2
|
+
//
|
|
3
|
+
// It owns the three pieces and nothing else owns any of them: the Transcript
|
|
4
|
+
// (the model), the Viewport (where we are in it), and the Stage (the band of
|
|
5
|
+
// terminal rows). ChatPrompt keeps composing exactly the rows it always
|
|
6
|
+
// composed; instead of writing them to stdout it hands them here, and this
|
|
7
|
+
// decides how much of the band is left over for the transcript.
|
|
8
|
+
//
|
|
9
|
+
// The layout of the band, top to bottom:
|
|
10
|
+
//
|
|
11
|
+
// [ viewport rows ] height - promptRows.length, app-scrolled
|
|
12
|
+
// [ prompt block ] the input box, picker, notice and status footer
|
|
13
|
+
//
|
|
14
|
+
// So the viewport shrinks as the input box grows, which is the behaviour you
|
|
15
|
+
// want and which falls out of the arithmetic rather than needing a rule.
|
|
16
|
+
//
|
|
17
|
+
// THE EXIT CONTRACT lives here (UX-01 rev 2 clause d): close() collapses the
|
|
18
|
+
// band and flushes the whole session into real terminal scrollback. opencode's
|
|
19
|
+
// alternate-screen TUI leaves nothing behind and its split-footer mode leaves
|
|
20
|
+
// everything but cannot scroll; doing both is the entire point.
|
|
21
|
+
//
|
|
22
|
+
// Pure Node: no dependency, no shell.
|
|
23
|
+
|
|
24
|
+
import { Stage, stageHeight } from './stage.js';
|
|
25
|
+
import { Transcript } from './transcript.js';
|
|
26
|
+
import { Viewport, ScrollAcceleration, DEFAULT_SCROLL_SPEED } from './viewport.js';
|
|
27
|
+
import { paint, GLYPHS } from './theme.js';
|
|
28
|
+
import { mouseTracking } from './keys.js';
|
|
29
|
+
import { truncate, padEnd, visibleWidth } from './text.js';
|
|
30
|
+
|
|
31
|
+
// Rows of evicted transcript held for the exit log. Eviction only happens after
|
|
32
|
+
// MAX_FRAMES, so this is a bound on a pathological session, not a working limit.
|
|
33
|
+
const MAX_EVICTED_ROWS = 40000;
|
|
34
|
+
|
|
35
|
+
// Below this the prompt block gets the whole band and the transcript is not
|
|
36
|
+
// shown at all — which is a broken screen, so the prompt is trimmed instead.
|
|
37
|
+
const MIN_VIEWPORT_ROWS = 2;
|
|
38
|
+
|
|
39
|
+
// Fallback measure for the opening composition, used only when the caller does
|
|
40
|
+
// not hand over the banner's real width.
|
|
41
|
+
//
|
|
42
|
+
// The session banner comes out ~82 columns wide (the wordmark beside its facts
|
|
43
|
+
// column, and the workspace path under it). While the input box stretched the
|
|
44
|
+
// full width of a large terminal, the two read as unrelated objects — a thin line
|
|
45
|
+
// pulled across a wide emptiness under a block that stops two-thirds of the way.
|
|
46
|
+
// Ninety sits just above the banner and just below the 100-column prose measure
|
|
47
|
+
// theme.js already uses, so the opening reads as ONE panel.
|
|
48
|
+
//
|
|
49
|
+
// It applies ONLY while the transcript is empty. The session input box keeps
|
|
50
|
+
// filling the window, which is an established layout invariant — tuiWidth
|
|
51
|
+
// asserts "the input box fills a stretched terminal" after a hardcoded 120-column
|
|
52
|
+
// ceiling was torn out of three modules for leaving a wide terminal two-thirds
|
|
53
|
+
// covered. This does not reopen that: it is a different rule for a different
|
|
54
|
+
// moment, and it lifts the instant a session begins.
|
|
55
|
+
const OPENING_FALLBACK_WIDTH = 100;
|
|
56
|
+
|
|
57
|
+
// The rail costs a column plus a column of air. On a narrow terminal those two
|
|
58
|
+
// are worth more as text, so it turns itself off rather than crowding the
|
|
59
|
+
// transcript it exists to help you navigate.
|
|
60
|
+
//
|
|
61
|
+
// It costs THREE columns of content, not two: a composed row is
|
|
62
|
+
// contentWidth + air + rail, and the Stage clamps every row to width - 1 so a
|
|
63
|
+
// wrap can never desynchronise the diff. Budgeting only two put the rail in the
|
|
64
|
+
// column that gets truncated away, which is a rail that renders and is then
|
|
65
|
+
// silently cut off — the tests caught it, which is what they are for.
|
|
66
|
+
const RAIL_MIN_WIDTH = 60;
|
|
67
|
+
const RAIL_WIDTH = 2;
|
|
68
|
+
const RAIL_BUDGET = RAIL_WIDTH + 1;
|
|
69
|
+
|
|
70
|
+
// Which marker wins when several frames fall in the same rail row. A proportional
|
|
71
|
+
// rail on a long session compresses dozens of frames per row, and what you want
|
|
72
|
+
// to see at a glance is the thing that went wrong or the thing you can roll back
|
|
73
|
+
// to — not the forty step rows around them.
|
|
74
|
+
// Inside the thumb, only a marker this loud overrides the position indicator.
|
|
75
|
+
//
|
|
76
|
+
// Two passes to get this right. The first draft let ANY marker win, and a dense
|
|
77
|
+
// session put a marker in every slot — so the thumb, the one thing on the rail
|
|
78
|
+
// that says WHERE YOU ARE, was invisible exactly when the rail mattered most.
|
|
79
|
+
// The second draft set the bar at checkpoints, and a real session on a pty
|
|
80
|
+
// showed why that is still wrong: a restore point is taken on every file write,
|
|
81
|
+
// so the thumb came out as `▌▌▪▪▪▌` — shredded by the most routine event there
|
|
82
|
+
// is. Only what is rare AND urgent is worth a row of position: an error, or an
|
|
83
|
+
// approval waiting on the user.
|
|
84
|
+
const RAIL_THUMB_YIELD = 80;
|
|
85
|
+
|
|
86
|
+
const RAIL_PRIORITY = {
|
|
87
|
+
error: 90, approval: 80, checkpoint: 70, turn: 60, user: 50,
|
|
88
|
+
assistant: 40, reasoning: 30, skill: 25, plan: 20,
|
|
89
|
+
session: 15, step: 10, result: 5, notice: 1
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* One row of the position indicator: a continuous rule with ONE dot at its
|
|
94
|
+
* middle. Line, dot, line — the same motif the input box carries.
|
|
95
|
+
*
|
|
96
|
+
* Alternating every other row was the first attempt and it was wrong: over a
|
|
97
|
+
* thumb of any length it reads as a long dashed line, which is the thing it was
|
|
98
|
+
* supposed to stop looking like. One dot at the centre makes even a full-track
|
|
99
|
+
* thumb read as a single object rather than as a run of broken marks.
|
|
100
|
+
*
|
|
101
|
+
* `│` is used by no marker, which is what lets a test find the thumb at all.
|
|
102
|
+
*/
|
|
103
|
+
function thumbGlyph(offsetInThumb, thumbLength) {
|
|
104
|
+
const middle = thumbLength >= 3 ? Math.floor(thumbLength / 2) : -1;
|
|
105
|
+
return offsetInThumb === middle ? GLYPHS.dot : GLYPHS.vLine;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* One rail cell. Every glyph is an existing GLYPH — the ambiguous-width set is
|
|
110
|
+
* already accounted for by visibleWidth(), and a new character here would be a
|
|
111
|
+
* new way for a row to measure one column and render two.
|
|
112
|
+
*/
|
|
113
|
+
function railGlyph(kind) {
|
|
114
|
+
switch (kind) {
|
|
115
|
+
case 'error': return paint('danger', GLYPHS.cross);
|
|
116
|
+
case 'approval': return paint('warn', GLYPHS.step);
|
|
117
|
+
case 'checkpoint': return paint('success', GLYPHS.step);
|
|
118
|
+
case 'turn': return paint('rule', GLYPHS.hLine);
|
|
119
|
+
case 'user': return paint('user', GLYPHS.arrow);
|
|
120
|
+
case 'assistant': return paint('agent', GLYPHS.arrow);
|
|
121
|
+
case 'reasoning': return paint('muted', GLYPHS.dot);
|
|
122
|
+
default: return paint('rule', GLYPHS.dot);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Wheel tuning, from the environment first and the saved config second.
|
|
128
|
+
*
|
|
129
|
+
* Env wins because every other Stage switch is an env var (CAUSYNE_STAGE,
|
|
130
|
+
* CAUSYNE_MOUSE, CAUSYNE_RAIL) and a user debugging a terminal wants one place to
|
|
131
|
+
* turn things off. The config file is where a preference lives once it is
|
|
132
|
+
* settled.
|
|
133
|
+
*/
|
|
134
|
+
export function scrollSettings(env = process.env, config = null) {
|
|
135
|
+
const tui = (config && typeof config === 'object' && config.tui) || {};
|
|
136
|
+
|
|
137
|
+
let speed = Number(env.CAUSYNE_SCROLL_SPEED);
|
|
138
|
+
if (!Number.isFinite(speed) || speed <= 0) speed = Number(tui.scrollSpeed);
|
|
139
|
+
if (!Number.isFinite(speed) || speed <= 0) speed = DEFAULT_SCROLL_SPEED;
|
|
140
|
+
// A wheel that moves half the screen per notch is not a preference, it is a
|
|
141
|
+
// misconfiguration; clamp rather than obey it.
|
|
142
|
+
speed = Math.min(speed, 20);
|
|
143
|
+
|
|
144
|
+
const raw = env.CAUSYNE_SCROLL_ACCEL;
|
|
145
|
+
const accelerate = raw !== undefined && raw !== ''
|
|
146
|
+
? raw === '1' || raw === 'true'
|
|
147
|
+
: Boolean(tui.scrollAcceleration);
|
|
148
|
+
|
|
149
|
+
return { speed, accelerate };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export class StageSurface {
|
|
153
|
+
constructor({ out = process.stdout, env = process.env, config = null, speed, accelerate, openingWidth = 0 } = {}) {
|
|
154
|
+
this.out = out;
|
|
155
|
+
this.env = env;
|
|
156
|
+
|
|
157
|
+
this.stage = new Stage({ out, env });
|
|
158
|
+
this.transcript = new Transcript({
|
|
159
|
+
onEvict: (rows) => this.absorbEvicted(rows),
|
|
160
|
+
onChange: () => this.handleChange()
|
|
161
|
+
});
|
|
162
|
+
const tuning = scrollSettings(env, config);
|
|
163
|
+
this.viewport = new Viewport({
|
|
164
|
+
transcript: this.transcript,
|
|
165
|
+
height: Math.max(MIN_VIEWPORT_ROWS, this.stage.height - 2),
|
|
166
|
+
width: this.stage.width(),
|
|
167
|
+
speed: speed !== undefined ? speed : tuning.speed,
|
|
168
|
+
acceleration: (accelerate !== undefined ? accelerate : tuning.accelerate)
|
|
169
|
+
? new ScrollAcceleration()
|
|
170
|
+
: null
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// Rows that fell out of the model. Not shown, not lost: they are printed
|
|
174
|
+
// ahead of the transcript on the way out.
|
|
175
|
+
this.evicted = [];
|
|
176
|
+
this.lastHeight = 0;
|
|
177
|
+
// Set by ChatPrompt.attachSurface.
|
|
178
|
+
this.onDirty = null;
|
|
179
|
+
this.exitHook = null;
|
|
180
|
+
this.signalHooks = [];
|
|
181
|
+
// UX-02: opt out entirely, for anyone whose terminal or workflow this gets
|
|
182
|
+
// in the way of.
|
|
183
|
+
this.mouseWanted = String(env.CAUSYNE_MOUSE ?? '1') !== '0';
|
|
184
|
+
this.mouseOn = false;
|
|
185
|
+
|
|
186
|
+
// The width the banner above the band actually used, handed over by the
|
|
187
|
+
// caller that printed it. Zero means "nobody measured", and the fallback
|
|
188
|
+
// applies.
|
|
189
|
+
this.openingWidth = Number.isFinite(openingWidth) && openingWidth > 0
|
|
190
|
+
? Math.floor(openingWidth)
|
|
191
|
+
: 0;
|
|
192
|
+
|
|
193
|
+
// Has anything ever landed in this session?
|
|
194
|
+
//
|
|
195
|
+
// NOT the same question as "is the transcript empty", and the difference is
|
|
196
|
+
// a real defect: /print and ctrl+L empty the transcript, so keying on
|
|
197
|
+
// emptiness narrowed the input box back to the banner's measure long after
|
|
198
|
+
// the banner had scrolled out of view — the box visibly shrinking for a
|
|
199
|
+
// reason the user cannot see. The opening measure belongs to THE opening,
|
|
200
|
+
// once, not to every empty moment after it.
|
|
201
|
+
this.opened = false;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
get width() {
|
|
205
|
+
return this.stage.width();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* The width the prompt block should be composed to.
|
|
210
|
+
*
|
|
211
|
+
* Deliberately NOT centred, which is where Causyne parts company with the
|
|
212
|
+
* reference on purpose. opencode centres its logo and prompt because its logo
|
|
213
|
+
* is centred in a screen it owns (routes/home.tsx:72-90). Causyne's banner is
|
|
214
|
+
* left-aligned in the user's real scrollback, so a centred input box below it
|
|
215
|
+
* would be misaligned with the very thing it is supposed to belong to. Same
|
|
216
|
+
* origin, same measure: the opening reads as a document block, not a splash.
|
|
217
|
+
*/
|
|
218
|
+
composeWidth() {
|
|
219
|
+
const width = this.width;
|
|
220
|
+
if (this.opened || !this.transcript.isEmpty()) return width;
|
|
221
|
+
const measure = this.openingWidth > 0 ? this.openingWidth : OPENING_FALLBACK_WIDTH;
|
|
222
|
+
return Math.min(width, measure);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Claim the band and arm the exit safety net.
|
|
227
|
+
*
|
|
228
|
+
* The hook matters: if the process dies without going through stop(), the
|
|
229
|
+
* band's rows are ordinary terminal text and nothing is corrupted, but the
|
|
230
|
+
* session would not reach scrollback. A synchronous exit handler is enough
|
|
231
|
+
* because collapse and print-through are plain writes.
|
|
232
|
+
*/
|
|
233
|
+
open() {
|
|
234
|
+
// ONE row, not the whole screen.
|
|
235
|
+
//
|
|
236
|
+
// The band used to be born at `rows - 1` regardless of what it had to show.
|
|
237
|
+
// Measured at 156x78: the banner's 12 rows plus a 77-row band came to 89 on
|
|
238
|
+
// a 78-row screen, so the wordmark was scrolled 11 rows out of sight before
|
|
239
|
+
// the user had typed anything, and 73 of the 77 claimed rows were empty.
|
|
240
|
+
// present() grows it to fit what there actually is.
|
|
241
|
+
if (!this.stage.reserve(1)) return false;
|
|
242
|
+
this.enableMouse();
|
|
243
|
+
this.armTeardown();
|
|
244
|
+
return true;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
enableMouse() {
|
|
248
|
+
if (!this.mouseWanted || this.mouseOn) return false;
|
|
249
|
+
this.out.write(mouseTracking(true));
|
|
250
|
+
this.mouseCaptured = true;
|
|
251
|
+
this.mouseOn = true;
|
|
252
|
+
return true;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
disableMouse() {
|
|
256
|
+
if (!this.mouseOn) return false;
|
|
257
|
+
this.out.write(mouseTracking(false));
|
|
258
|
+
this.mouseCaptured = false;
|
|
259
|
+
this.mouseOn = false;
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
armTeardown() {
|
|
265
|
+
this.exitHook = () => { try { this.close(); } catch { /* nothing left to do */ } };
|
|
266
|
+
process.once('exit', this.exitHook);
|
|
267
|
+
|
|
268
|
+
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
|
|
269
|
+
const handler = () => {
|
|
270
|
+
try { this.close(); } catch { /* keep going: the exit matters more */ }
|
|
271
|
+
// Re-raise the conventional status so a caller waiting on us still sees
|
|
272
|
+
// that we were signalled rather than that we exited cleanly.
|
|
273
|
+
process.exit(128 + { SIGINT: 2, SIGTERM: 15, SIGHUP: 1 }[signal]);
|
|
274
|
+
};
|
|
275
|
+
process.on(signal, handler);
|
|
276
|
+
this.signalHooks.push([signal, handler]);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
disarmTeardown() {
|
|
281
|
+
if (this.exitHook) {
|
|
282
|
+
process.removeListener('exit', this.exitHook);
|
|
283
|
+
this.exitHook = null;
|
|
284
|
+
}
|
|
285
|
+
for (const [signal, handler] of this.signalHooks) process.removeListener(signal, handler);
|
|
286
|
+
this.signalHooks = [];
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
close() {
|
|
290
|
+
// Mouse first, unconditionally: it is the one piece of state that outlives
|
|
291
|
+
// the process and damages the terminal the user goes back to.
|
|
292
|
+
this.disableMouse();
|
|
293
|
+
if (!this.stage.reserved) { this.disarmTeardown(); return false; }
|
|
294
|
+
const rows = this.snapshot();
|
|
295
|
+
this.stage.collapse();
|
|
296
|
+
this.stage.printThrough(rows);
|
|
297
|
+
this.disarmTeardown();
|
|
298
|
+
return true;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Hand the terminal back to something that is about to write to it directly,
|
|
303
|
+
* then take it again.
|
|
304
|
+
*
|
|
305
|
+
* Anything spawned with stdio: 'inherit' writes straight into the band and
|
|
306
|
+
* the diff has no idea it happened. Causyne has four such call sites today
|
|
307
|
+
* (autoUpdate's two npm installs, the cortex provisioner, the capability
|
|
308
|
+
* provisioner); none of them is wired to this yet, and that is recorded as an
|
|
309
|
+
* open item rather than left to be discovered.
|
|
310
|
+
*/
|
|
311
|
+
suspend() {
|
|
312
|
+
if (!this.stage.reserved) return false;
|
|
313
|
+
this.disableMouse();
|
|
314
|
+
this.stage.collapse();
|
|
315
|
+
return true;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
resume() {
|
|
319
|
+
if (this.stage.reserved) return false;
|
|
320
|
+
if (!this.stage.reserve()) return false;
|
|
321
|
+
this.stage.invalidate();
|
|
322
|
+
this.enableMouse();
|
|
323
|
+
return true;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Everything the session produced, oldest first. */
|
|
327
|
+
snapshot() {
|
|
328
|
+
return this.evicted.concat(this.transcript.snapshot(this.width));
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
absorbEvicted(rows) {
|
|
332
|
+
for (const row of rows) this.evicted.push(row);
|
|
333
|
+
if (this.evicted.length > MAX_EVICTED_ROWS) {
|
|
334
|
+
this.evicted.splice(0, this.evicted.length - MAX_EVICTED_ROWS);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
handleChange() {
|
|
339
|
+
if (!this.transcript.isEmpty()) this.opened = true;
|
|
340
|
+
const before = this.lastHeight;
|
|
341
|
+
const after = this.transcript.height(this.width);
|
|
342
|
+
this.lastHeight = after;
|
|
343
|
+
// Growth is what drives auto-scroll. Handing the delta over is also what
|
|
344
|
+
// lets the "N new below" affordance count only what arrived while the user
|
|
345
|
+
// was reading somewhere else.
|
|
346
|
+
this.viewport.refresh(Math.max(0, after - before));
|
|
347
|
+
if (this.onDirty) this.onDirty();
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// -------------------------------------------------------------------------
|
|
351
|
+
// The interface ChatPrompt calls
|
|
352
|
+
// -------------------------------------------------------------------------
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Append a permanent frame. `produce` must be a pure function of width — see
|
|
356
|
+
* the contract in transcript.js.
|
|
357
|
+
*/
|
|
358
|
+
commit(kind, produce, meta = null) {
|
|
359
|
+
return this.transcript.append({ kind, produce, meta });
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Paint the band: viewport on top, the prompt's own rows underneath.
|
|
364
|
+
*/
|
|
365
|
+
present(promptRows, caret = null) {
|
|
366
|
+
if (!this.stage.reserved) return 0;
|
|
367
|
+
|
|
368
|
+
const width = this.width;
|
|
369
|
+
const rail = this.railEnabled(width);
|
|
370
|
+
const contentWidth = rail ? Math.max(20, width - RAIL_BUDGET) : width;
|
|
371
|
+
|
|
372
|
+
let block = promptRows;
|
|
373
|
+
let caretRow = caret && Number.isFinite(caret.row) ? caret.row : Math.max(0, block.length - 1);
|
|
374
|
+
|
|
375
|
+
// Grow the band to what there is to show, before deciding anything else.
|
|
376
|
+
//
|
|
377
|
+
// An empty session wants exactly the prompt and no more — no viewport, no
|
|
378
|
+
// void, and no rows taken from the user's terminal that nothing is going to
|
|
379
|
+
// fill. As the transcript grows the band follows it, up to the ceiling; it
|
|
380
|
+
// never shrinks, because shrinking would mean erasing rows the diff believes
|
|
381
|
+
// it owns and fighting the resize path for them.
|
|
382
|
+
const contentRows = this.transcript.height(contentWidth);
|
|
383
|
+
const desired = Math.min(this.stage.maxHeight, block.length + contentRows);
|
|
384
|
+
if (desired > this.stage.height) this.stage.grow(desired - this.stage.height);
|
|
385
|
+
|
|
386
|
+
const height = this.stage.height;
|
|
387
|
+
|
|
388
|
+
// The prompt may not eat the whole band — but only once there is a
|
|
389
|
+
// transcript for it to eat. Trim from the TOP when it would: the status
|
|
390
|
+
// footer and the caret row are the last rows and the ones worth keeping,
|
|
391
|
+
// and an input box that has scrolled its own oldest row off is a smaller lie
|
|
392
|
+
// than a transcript that has vanished.
|
|
393
|
+
const floor = contentRows > 0 ? MIN_VIEWPORT_ROWS : 0;
|
|
394
|
+
const maxBlock = Math.max(1, height - floor);
|
|
395
|
+
if (block.length > maxBlock) {
|
|
396
|
+
const cut = block.length - maxBlock;
|
|
397
|
+
block = block.slice(cut);
|
|
398
|
+
caretRow = Math.max(0, caretRow - cut);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const budget = Math.max(0, height - block.length);
|
|
402
|
+
let rows = [];
|
|
403
|
+
if (budget > 0) {
|
|
404
|
+
this.viewport.resize({ height: budget, width: contentWidth });
|
|
405
|
+
rows = this.viewport.rows();
|
|
406
|
+
this.decorate(rows, contentWidth);
|
|
407
|
+
if (rail) this.attachRail(rows, contentWidth);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// DO NOT PAINT WHILE THE TERMINAL OWNS THE SCROLL.
|
|
411
|
+
//
|
|
412
|
+
// The Stage repaints by DIFF against the rows it believes are on screen. It
|
|
413
|
+
// has no way to know the user scrolled the terminal's own scrollback, and
|
|
414
|
+
// when they do, the band's absolute rows move while the diff keeps writing
|
|
415
|
+
// to the old coordinates — which is how a session ends up with a stray rail
|
|
416
|
+
// column down the middle and fragments of old frames at the edges.
|
|
417
|
+
//
|
|
418
|
+
// We cannot detect a terminal scroll directly. What we CAN detect is the
|
|
419
|
+
// moment we handed the wheel over (viewport at its top, mouse reporting
|
|
420
|
+
// released), and from then until the user types again the terminal is the
|
|
421
|
+
// thing scrolling. Painting nothing is correct: the band is not where we
|
|
422
|
+
// left it, so every write would land somewhere wrong.
|
|
423
|
+
if (this.detached) return false;
|
|
424
|
+
|
|
425
|
+
return this.stage.paint(rows.concat(block), {
|
|
426
|
+
row: rows.length + caretRow,
|
|
427
|
+
col: caret ? caret.col : 0
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* The one thing on screen that opencode does not have: while the view is held
|
|
433
|
+
* away from the bottom, say so and say how much arrived.
|
|
434
|
+
*
|
|
435
|
+
* Without it, un-following is invisible — the transcript simply stops moving
|
|
436
|
+
* and the user cannot tell whether the agent stalled or they scrolled.
|
|
437
|
+
*/
|
|
438
|
+
decorate(rows, width) {
|
|
439
|
+
const state = this.viewport.state();
|
|
440
|
+
if (state.following) return rows;
|
|
441
|
+
|
|
442
|
+
const count = state.unseen;
|
|
443
|
+
const label = count > 0
|
|
444
|
+
? `${GLYPHS.arrow} ${count} new below`
|
|
445
|
+
: `${GLYPHS.arrow} scrolled up`;
|
|
446
|
+
const hint = `${label} ${GLYPHS.dot} shift+pgdn to follow`;
|
|
447
|
+
rows[rows.length - 1] = paint('accent', truncate(hint, Math.max(8, width - 1), GLYPHS.ellipsis));
|
|
448
|
+
return rows;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Ctrl+L: flush what is on the stage into real scrollback, then start clean.
|
|
453
|
+
*
|
|
454
|
+
* In a terminal, ctrl+L clears the screen and your history is still in
|
|
455
|
+
* scrollback. Clearing the transcript alone would destroy the session record,
|
|
456
|
+
* which is not what the key means — so print it through first. This is also
|
|
457
|
+
* the escape hatch for getting a transcript into the terminal without exiting.
|
|
458
|
+
*/
|
|
459
|
+
flushAndClear() {
|
|
460
|
+
const rows = this.snapshot();
|
|
461
|
+
this.stage.collapse();
|
|
462
|
+
this.stage.printThrough(rows);
|
|
463
|
+
this.evicted = [];
|
|
464
|
+
this.transcript.clear();
|
|
465
|
+
this.lastHeight = 0;
|
|
466
|
+
this.stage.reserve();
|
|
467
|
+
this.stage.invalidate();
|
|
468
|
+
this.viewport.toBottom();
|
|
469
|
+
return rows.length;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* The terminal changed size. Callers must debounce — ChatPrompt already
|
|
474
|
+
* settles resize at RESIZE_SETTLE_MS.
|
|
475
|
+
*/
|
|
476
|
+
resize() {
|
|
477
|
+
const next = stageHeight(this.out, this.env);
|
|
478
|
+
if (next < 1) {
|
|
479
|
+
// The terminal became too short to hold a stage. Give the band back and
|
|
480
|
+
// let the caller fall through to the scrollback renderer rather than
|
|
481
|
+
// painting into a region that cannot hold a viewport.
|
|
482
|
+
this.close();
|
|
483
|
+
return false;
|
|
484
|
+
}
|
|
485
|
+
// Re-claim only what the content justifies; present() grows it from there.
|
|
486
|
+
this.stage.resize(next, 1);
|
|
487
|
+
this.stage.invalidate();
|
|
488
|
+
// Re-derive from the new geometry: growing the viewport lowers maxScrollTop,
|
|
489
|
+
// and leaving scrollTop above it would read as "the user scrolled away".
|
|
490
|
+
this.viewport.resize({ height: Math.max(MIN_VIEWPORT_ROWS, this.stage.height - 2), width: this.width });
|
|
491
|
+
this.lastHeight = this.transcript.height(this.width);
|
|
492
|
+
return this.stage.reserved;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Does this terminal get a rail?
|
|
497
|
+
*/
|
|
498
|
+
railEnabled(width) {
|
|
499
|
+
if (String(this.env.CAUSYNE_RAIL ?? '1') === '0') return false;
|
|
500
|
+
return width >= RAIL_MIN_WIDTH;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* The rail: the WHOLE session compressed into one column, plus where you are
|
|
505
|
+
* in it.
|
|
506
|
+
*
|
|
507
|
+
* Proportional, not per-row. A per-row gutter would only mark what is already
|
|
508
|
+
* on screen, which tells you nothing you cannot see; mapping the entire
|
|
509
|
+
* transcript onto the column means a glance answers "how far in am I, and is
|
|
510
|
+
* there anything back there worth going to". opencode has no equivalent —
|
|
511
|
+
* its scrollbar reports position and nothing about content.
|
|
512
|
+
*
|
|
513
|
+
* Returns `height` single-column strings.
|
|
514
|
+
*/
|
|
515
|
+
buildRail(height, contentWidth) {
|
|
516
|
+
const total = Math.max(1, this.transcript.height(contentWidth));
|
|
517
|
+
const slotOf = (row) => Math.max(0, Math.min(height - 1, Math.floor((row / total) * height)));
|
|
518
|
+
|
|
519
|
+
const kinds = new Array(height).fill(null);
|
|
520
|
+
for (const marker of this.transcript.markers(contentWidth)) {
|
|
521
|
+
const slot = slotOf(marker.row);
|
|
522
|
+
const held = kinds[slot];
|
|
523
|
+
if (!held || (RAIL_PRIORITY[marker.kind] || 0) > (RAIL_PRIORITY[held] || 0)) {
|
|
524
|
+
kinds[slot] = marker.kind;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const state = this.viewport.state();
|
|
529
|
+
// Nothing to scroll, no thumb. A scrollbar whose track is full says the same
|
|
530
|
+
// thing as no scrollbar at all, and on an empty session it filled the whole
|
|
531
|
+
// column with position indicator for a position that could not change —
|
|
532
|
+
// noise where the rail is supposed to be information.
|
|
533
|
+
const scrollable = state.max > 0;
|
|
534
|
+
const from = scrollable ? slotOf(state.scrollTop) : -1;
|
|
535
|
+
const to = scrollable ? slotOf(Math.max(state.scrollTop, state.scrollTop + state.height - 1)) : -2;
|
|
536
|
+
|
|
537
|
+
const cells = new Array(height);
|
|
538
|
+
for (let i = 0; i < height; i++) {
|
|
539
|
+
const inThumb = i >= from && i <= to;
|
|
540
|
+
const kind = kinds[i];
|
|
541
|
+
const loud = kind && (RAIL_PRIORITY[kind] || 0) >= RAIL_THUMB_YIELD;
|
|
542
|
+
if (kind && (!inThumb || loud)) cells[i] = railGlyph(kind);
|
|
543
|
+
else if (inThumb) cells[i] = paint('scroll', thumbGlyph(i - from, to - from + 1));
|
|
544
|
+
else cells[i] = ' ';
|
|
545
|
+
}
|
|
546
|
+
return cells;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Lay the rail beside the viewport rows.
|
|
551
|
+
*
|
|
552
|
+
* The rows were rendered at contentWidth, so padding them out to it is what
|
|
553
|
+
* keeps the rail in one column instead of ragged against the text.
|
|
554
|
+
*/
|
|
555
|
+
attachRail(rows, contentWidth) {
|
|
556
|
+
const rail = this.buildRail(rows.length, contentWidth);
|
|
557
|
+
for (let i = 0; i < rows.length; i++) {
|
|
558
|
+
rows[i] = `${padEnd(rows[i], contentWidth)} ${rail[i]}`;
|
|
559
|
+
}
|
|
560
|
+
return rows;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Hand the wheel back to the terminal.
|
|
565
|
+
*
|
|
566
|
+
* THE SEAM THIS EXISTS FOR. Two things scroll: this band's viewport, and the
|
|
567
|
+
* terminal's own scrollback above it, which holds everything already flushed.
|
|
568
|
+
* With ?1000 on, the app receives every wheel report and the terminal receives
|
|
569
|
+
* none — so once the viewport reached its top, scrolling further did nothing
|
|
570
|
+
* at all and the earlier conversation was unreachable by wheel. Releasing at
|
|
571
|
+
* the boundary is what makes the two halves feel like one scroll.
|
|
572
|
+
*/
|
|
573
|
+
releaseMouse() {
|
|
574
|
+
if (!this.mouseCaptured) return false;
|
|
575
|
+
this.out.write(mouseTracking(false));
|
|
576
|
+
this.mouseCaptured = false;
|
|
577
|
+
this.detached = true;
|
|
578
|
+
return true;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/** Take it back, once the user is driving the band again. */
|
|
582
|
+
captureMouse() {
|
|
583
|
+
if (this.mouseCaptured) return false;
|
|
584
|
+
this.out.write(mouseTracking(true));
|
|
585
|
+
this.mouseCaptured = true;
|
|
586
|
+
this.detached = false;
|
|
587
|
+
// Every row the diff remembers is stale — the terminal has scrolled under
|
|
588
|
+
// us. Redraw the whole band rather than trusting a single cached row.
|
|
589
|
+
this.stage.invalidate();
|
|
590
|
+
return true;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Build a surface, or null when this terminal should keep the scrollback
|
|
596
|
+
* renderer. Every null here is a reason the region could not be repainted
|
|
597
|
+
* safely, and the fallback is the behaviour Causyne has always had.
|
|
598
|
+
*/
|
|
599
|
+
export function createStageSurface({ out = process.stdout, env = process.env, config = null, speed, openingWidth = 0 } = {}) {
|
|
600
|
+
if (!out || !out.isTTY) return null;
|
|
601
|
+
if (String(env.TERM || '') === 'dumb') return null;
|
|
602
|
+
if (env.CAUSYNE_SIMPLE_INPUT === '1') return null;
|
|
603
|
+
if (stageHeight(out, env) < 1) return null;
|
|
604
|
+
|
|
605
|
+
const surface = new StageSurface({ out, env, config, speed, openingWidth });
|
|
606
|
+
if (!surface.open()) return null;
|
|
607
|
+
return surface;
|
|
608
|
+
}
|
|
609
|
+
|