@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,335 @@
|
|
|
1
|
+
// The filterable list every dialog is built out of.
|
|
2
|
+
//
|
|
3
|
+
// openclaw has components/filterable-select-list.ts and searchable-select-list.ts;
|
|
4
|
+
// opencode has ui/dialog-select.tsx, and its command palette, model picker,
|
|
5
|
+
// session list, theme list, agent list and MCP list are all that one component
|
|
6
|
+
// with different options. The lesson is the component, not the dialogs: build
|
|
7
|
+
// this once and well, and every dialog after it costs twenty lines.
|
|
8
|
+
//
|
|
9
|
+
// The fuzzy matcher is the part that decides whether a palette feels sharp or
|
|
10
|
+
// useless. A plain `includes()` cannot find "session list" from "sesl", and an
|
|
11
|
+
// unscored subsequence match ranks garbage first. This scores the way people
|
|
12
|
+
// expect: consecutive letters and word starts win, and a match at the beginning
|
|
13
|
+
// of the label beats the same letters buried in the middle.
|
|
14
|
+
//
|
|
15
|
+
// Pure Node over ./theme.js and ./text.js. No dependency, no shell.
|
|
16
|
+
|
|
17
|
+
import { paint, bold, dim, GLYPHS } from './theme.js';
|
|
18
|
+
import { truncate, visibleWidth, safeDisplay } from './text.js';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Score `query` against `text` as a fuzzy subsequence.
|
|
22
|
+
*
|
|
23
|
+
* Returns { score, positions } or null when the query is not a subsequence.
|
|
24
|
+
* Higher is better. The weights are the conventional ones and they matter:
|
|
25
|
+
*
|
|
26
|
+
* +16 the match continues the previous one (consecutive letters)
|
|
27
|
+
* +12 the match is at a word start (after a space, -, _, /, or a case bump)
|
|
28
|
+
* +8 the match is the very first character
|
|
29
|
+
* -1 per character skipped, so a tight match beats a scattered one
|
|
30
|
+
*/
|
|
31
|
+
export function fuzzyScore(query, text) {
|
|
32
|
+
const needle = String(query || '').toLowerCase();
|
|
33
|
+
const haystack = String(text || '');
|
|
34
|
+
if (needle === '') return { score: 0, positions: [] };
|
|
35
|
+
|
|
36
|
+
const lower = haystack.toLowerCase();
|
|
37
|
+
const positions = [];
|
|
38
|
+
let score = 0;
|
|
39
|
+
let cursor = 0;
|
|
40
|
+
let previous = -2;
|
|
41
|
+
|
|
42
|
+
for (const character of needle) {
|
|
43
|
+
if (character === ' ') continue; // spaces separate words, they do not match
|
|
44
|
+
const found = lower.indexOf(character, cursor);
|
|
45
|
+
if (found === -1) return null;
|
|
46
|
+
|
|
47
|
+
if (found === previous + 1) score += 16;
|
|
48
|
+
if (found === 0) score += 8;
|
|
49
|
+
|
|
50
|
+
const before = haystack[found - 1];
|
|
51
|
+
if (before !== undefined) {
|
|
52
|
+
const isBoundary = /[\s\-_/.:]/.test(before)
|
|
53
|
+
|| (before === before.toLowerCase() && haystack[found] === haystack[found].toUpperCase()
|
|
54
|
+
&& haystack[found] !== haystack[found].toLowerCase());
|
|
55
|
+
if (isBoundary) score += 12;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
score -= Math.min(found - cursor, 8);
|
|
59
|
+
positions.push(found);
|
|
60
|
+
previous = found;
|
|
61
|
+
cursor = found + 1;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// A shorter label containing the same letters is the more likely target.
|
|
65
|
+
score -= Math.floor(haystack.length / 12);
|
|
66
|
+
return { score, positions };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Filter and rank items. Items keep their original order when the query is empty. */
|
|
70
|
+
export function fuzzyFilter(items, query) {
|
|
71
|
+
if (!query) return items.map((item, index) => ({ item, score: 0, positions: [], index }));
|
|
72
|
+
|
|
73
|
+
const scored = [];
|
|
74
|
+
for (let index = 0; index < items.length; index++) {
|
|
75
|
+
const item = items[index];
|
|
76
|
+
const subject = `${item.label || ''} ${item.searchText || ''} ${item.category || ''}`.trim();
|
|
77
|
+
const result = fuzzyScore(query, subject);
|
|
78
|
+
if (result) scored.push({ item, score: result.score, positions: result.positions, index });
|
|
79
|
+
}
|
|
80
|
+
// Stable: equal scores keep the order they were declared in.
|
|
81
|
+
scored.sort((a, b) => (b.score - a.score) || (a.index - b.index));
|
|
82
|
+
return scored;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export class SelectList {
|
|
86
|
+
/**
|
|
87
|
+
* items: { label, description, category, hint, value, searchText, disabled }
|
|
88
|
+
*/
|
|
89
|
+
constructor(items = [], options = {}) {
|
|
90
|
+
this.setItems(items);
|
|
91
|
+
this.query = '';
|
|
92
|
+
this.selected = 0;
|
|
93
|
+
this.offset = 0;
|
|
94
|
+
this.placeholder = options.placeholder || 'type to filter';
|
|
95
|
+
this.emptyText = options.emptyText || 'no matches';
|
|
96
|
+
this.showFilter = options.showFilter !== false;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
setItems(items) {
|
|
100
|
+
this.all = Array.isArray(items) ? items.filter(Boolean) : [];
|
|
101
|
+
this.refilter();
|
|
102
|
+
return this;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
refilter() {
|
|
106
|
+
this.filtered = fuzzyFilter(this.all, this.query);
|
|
107
|
+
this.selected = Math.min(this.selected || 0, Math.max(0, this.filtered.length - 1));
|
|
108
|
+
this.offset = 0;
|
|
109
|
+
return this;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
get current() {
|
|
113
|
+
const entry = this.filtered[this.selected];
|
|
114
|
+
return entry ? entry.item : null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
move(delta, visibleRows) {
|
|
118
|
+
if (this.filtered.length === 0) return this;
|
|
119
|
+
// Wrapping is what makes a short list feel responsive: pressing up on the
|
|
120
|
+
// first row lands on the last, instead of doing nothing.
|
|
121
|
+
this.selected = (this.selected + delta + this.filtered.length) % this.filtered.length;
|
|
122
|
+
this.ensureVisible(visibleRows);
|
|
123
|
+
return this;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
moveTo(index, visibleRows) {
|
|
127
|
+
if (this.filtered.length === 0) return this;
|
|
128
|
+
this.selected = Math.max(0, Math.min(index, this.filtered.length - 1));
|
|
129
|
+
this.ensureVisible(visibleRows);
|
|
130
|
+
return this;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* How many items starting at `from` actually fit in `rows` rendered rows.
|
|
135
|
+
*
|
|
136
|
+
* render() emits a category header whenever the group changes, so a window of
|
|
137
|
+
* N items does NOT occupy N rows. Counting items and rendering rows are two
|
|
138
|
+
* different quantities, and treating them as one is what made the selection
|
|
139
|
+
* marker disappear: ensureVisible() put the selected item at the end of an
|
|
140
|
+
* item-counted window, the render loop ran out of ROWS on the headers before
|
|
141
|
+
* it reached that item, and the marker was simply never drawn. Pressing up
|
|
142
|
+
* moved the selection back into the part that had been drawn, which is why it
|
|
143
|
+
* came back.
|
|
144
|
+
*/
|
|
145
|
+
renderableCount(from, rows) {
|
|
146
|
+
const budget = Math.max(1, rows);
|
|
147
|
+
let used = 0;
|
|
148
|
+
let count = 0;
|
|
149
|
+
let lastCategory = null;
|
|
150
|
+
for (let index = from; index < this.filtered.length; index++) {
|
|
151
|
+
const item = this.filtered[index].item;
|
|
152
|
+
const needed = (item.category && item.category !== lastCategory) ? 2 : 1;
|
|
153
|
+
if (used + needed > budget) break;
|
|
154
|
+
if (item.category) lastCategory = item.category;
|
|
155
|
+
used += needed;
|
|
156
|
+
count++;
|
|
157
|
+
}
|
|
158
|
+
return count;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
ensureVisible(visibleRows) {
|
|
162
|
+
const rows = Math.max(1, visibleRows || 10);
|
|
163
|
+
if (this.filtered.length === 0) { this.offset = 0; return this; }
|
|
164
|
+
|
|
165
|
+
if (this.selected < this.offset) this.offset = this.selected;
|
|
166
|
+
|
|
167
|
+
// Scroll down until the selected item is genuinely among the ones that will
|
|
168
|
+
// be drawn — measured in rows, headers included, not in items.
|
|
169
|
+
let guard = this.filtered.length;
|
|
170
|
+
while (
|
|
171
|
+
guard-- > 0 &&
|
|
172
|
+
this.offset < this.selected &&
|
|
173
|
+
this.selected >= this.offset + this.renderableCount(this.offset, rows)
|
|
174
|
+
) {
|
|
175
|
+
this.offset++;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Never scroll past the selection: the old clamp to
|
|
179
|
+
// `filtered.length - rows` was item-counted too, and could pull the offset
|
|
180
|
+
// back to a position where the selection was off-window again.
|
|
181
|
+
this.offset = Math.max(0, Math.min(this.offset, this.selected));
|
|
182
|
+
return this;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Feed a decoded key. Returns 'select' | 'cancel' | 'handled' | null.
|
|
187
|
+
*
|
|
188
|
+
* null means the key was not ours, which is how the caller knows it may pass
|
|
189
|
+
* the key on. A dialog that swallows every key is a dialog you cannot escape.
|
|
190
|
+
*/
|
|
191
|
+
handleKey(key, visibleRows) {
|
|
192
|
+
if (!key) return null;
|
|
193
|
+
const rows = Math.max(1, visibleRows || 10);
|
|
194
|
+
|
|
195
|
+
switch (key.name) {
|
|
196
|
+
case 'up': this.move(-1, rows); return 'handled';
|
|
197
|
+
case 'down': this.move(1, rows); return 'handled';
|
|
198
|
+
// A page is the number of ITEMS on screen, not the number of rows.
|
|
199
|
+
// Category headers spend rows without being selectable, so paging by the
|
|
200
|
+
// row budget jumped further than a screenful and left a band of items that
|
|
201
|
+
// could not be reached by paging at all.
|
|
202
|
+
case 'pageup':
|
|
203
|
+
case 'pagedown': {
|
|
204
|
+
const page = Math.max(1, this.renderableCount(this.offset, rows));
|
|
205
|
+
this.move(key.name === 'pageup' ? -page : page, rows);
|
|
206
|
+
return 'handled';
|
|
207
|
+
}
|
|
208
|
+
case 'home': this.moveTo(0, rows); return 'handled';
|
|
209
|
+
case 'end': this.moveTo(this.filtered.length - 1, rows); return 'handled';
|
|
210
|
+
// keys.js emits 'enter' (see makeKey('enter', …) for both CR and LF).
|
|
211
|
+
// This listened only for 'return', a name the decoder never produces, so
|
|
212
|
+
// Enter fell through to the printable-character branch, was rejected there
|
|
213
|
+
// for being a control byte, and returned null — a completely dead key.
|
|
214
|
+
// Nothing in the picker or the command palette could ever be selected.
|
|
215
|
+
// 'return' is kept so a decoder that emits it still works.
|
|
216
|
+
case 'enter':
|
|
217
|
+
case 'return': return this.current ? 'select' : 'handled';
|
|
218
|
+
case 'escape': return 'cancel';
|
|
219
|
+
case 'backspace':
|
|
220
|
+
if (this.query.length > 0) {
|
|
221
|
+
this.query = this.query.slice(0, -1);
|
|
222
|
+
this.refilter();
|
|
223
|
+
return 'handled';
|
|
224
|
+
}
|
|
225
|
+
return 'cancel';
|
|
226
|
+
default: break;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Emacs navigation, which both references bind and terminal users expect.
|
|
230
|
+
if (key.ctrl) {
|
|
231
|
+
if (key.name === 'p') { this.move(-1, rows); return 'handled'; }
|
|
232
|
+
if (key.name === 'n') { this.move(1, rows); return 'handled'; }
|
|
233
|
+
if (key.name === 'u') { this.query = ''; this.refilter(); return 'handled'; }
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (key.sequence && key.sequence.length === 1 && key.sequence >= ' ' && !key.meta) {
|
|
238
|
+
this.query += key.sequence;
|
|
239
|
+
this.refilter();
|
|
240
|
+
return 'handled';
|
|
241
|
+
}
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Render into an array of styled rows for a panel `width` x `height`.
|
|
247
|
+
*
|
|
248
|
+
* Returns { rows, cursorColumn } — the caret sits in the filter field, which
|
|
249
|
+
* is the only place a caret belongs in a picker.
|
|
250
|
+
*/
|
|
251
|
+
render(width, height) {
|
|
252
|
+
const rows = [];
|
|
253
|
+
const inner = Math.max(4, width);
|
|
254
|
+
|
|
255
|
+
if (this.showFilter) {
|
|
256
|
+
const shown = this.query || dim(this.placeholder);
|
|
257
|
+
rows.push(`${paint('accent', GLYPHS.arrow)} ${truncate(shown, inner - 2, GLYPHS.ellipsis)}`);
|
|
258
|
+
rows.push(paint('rule', GLYPHS.hLine.repeat(inner)));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Rows the ITEM loop may use.
|
|
262
|
+
//
|
|
263
|
+
// Two rows are already spent on the filter and its rule, and one more is
|
|
264
|
+
// reserved at the bottom for the position counter — which is written with
|
|
265
|
+
// `rows[height - 1] = line`, i.e. it OVERWRITES whatever is on the last row.
|
|
266
|
+
// Letting the loop fill that row meant the counter could clobber the very
|
|
267
|
+
// item the marker was on, which is a second, independent way for the
|
|
268
|
+
// selection to vanish.
|
|
269
|
+
const itemRows = Math.max(1, height - rows.length - 1);
|
|
270
|
+
|
|
271
|
+
// Remember what the renderer actually budgeted, so key handling scrolls by
|
|
272
|
+
// the same number of rows it draws. SelectLayer used to derive its own
|
|
273
|
+
// figure from the layer's configured height, which is not the height the
|
|
274
|
+
// panel ends up giving the body — 17 against the renderer's 15 here.
|
|
275
|
+
this.lastItemRows = itemRows;
|
|
276
|
+
this.ensureVisible(itemRows);
|
|
277
|
+
|
|
278
|
+
if (this.filtered.length === 0) {
|
|
279
|
+
rows.push('');
|
|
280
|
+
rows.push(` ${dim(this.emptyText)}`);
|
|
281
|
+
return { rows, listRows: itemRows };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const window = this.filtered.slice(this.offset, this.offset + itemRows);
|
|
285
|
+
// The last row belongs to the position counter whenever one is shown.
|
|
286
|
+
const ceiling = this.filtered.length > itemRows ? height - 1 : height;
|
|
287
|
+
let lastCategory = null;
|
|
288
|
+
|
|
289
|
+
for (let index = 0; index < window.length; index++) {
|
|
290
|
+
const entry = window[index];
|
|
291
|
+
const item = entry.item;
|
|
292
|
+
const isSelected = this.offset + index === this.selected;
|
|
293
|
+
|
|
294
|
+
if (item.category && item.category !== lastCategory) {
|
|
295
|
+
lastCategory = item.category;
|
|
296
|
+
rows.push(dim(` ${safeDisplay(item.category)}`));
|
|
297
|
+
if (rows.length >= ceiling) break;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const marker = isSelected ? paint('accent', GLYPHS.arrow) : ' ';
|
|
301
|
+
const label = safeDisplay(item.label || '');
|
|
302
|
+
const hint = item.hint ? dim(` ${safeDisplay(item.hint)}`) : '';
|
|
303
|
+
const description = item.description ? safeDisplay(item.description) : '';
|
|
304
|
+
|
|
305
|
+
// The hint (a keybinding) is right-aligned, which is what makes a palette
|
|
306
|
+
// scannable: the names line up on the left, the keys on the right.
|
|
307
|
+
const left = `${marker} ${isSelected ? bold(paint('ink', label)) : paint('ink', label)}`;
|
|
308
|
+
const hintWidth = visibleWidth(hint);
|
|
309
|
+
const room = inner - visibleWidth(left) - hintWidth - 2;
|
|
310
|
+
|
|
311
|
+
const middle = description && room > 6
|
|
312
|
+
? dim(' ' + truncate(description, room, GLYPHS.ellipsis))
|
|
313
|
+
: '';
|
|
314
|
+
const padding = Math.max(0, inner - visibleWidth(left) - visibleWidth(middle) - hintWidth);
|
|
315
|
+
|
|
316
|
+
rows.push(left + middle + ' '.repeat(padding) + hint);
|
|
317
|
+
if (rows.length >= ceiling) break;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// A count is the cheapest possible scroll indicator and it never lies.
|
|
321
|
+
if (this.filtered.length > itemRows) {
|
|
322
|
+
const position = `${this.selected + 1}/${this.filtered.length}`;
|
|
323
|
+
const line = dim(position.padStart(inner));
|
|
324
|
+
if (rows.length >= height) rows[height - 1] = line;
|
|
325
|
+
else rows.push(line);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return { rows, listRows: itemRows };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Column the caret sits at within the filter row. */
|
|
332
|
+
cursorColumn() {
|
|
333
|
+
return 2 + visibleWidth(this.query);
|
|
334
|
+
}
|
|
335
|
+
}
|
package/src/tui/stage.js
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
// The Stage — a bounded, bottom-anchored region Causyne owns and repaints.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS IS NOT AN ALTERNATE SCREEN.
|
|
4
|
+
//
|
|
5
|
+
// opencode's full TUI gets its "the chat scrolls inside a box, the terminal
|
|
6
|
+
// does not scroll" behaviour the blunt way: it writes \x1b[?1049h and owns the
|
|
7
|
+
// whole screen, so there is no terminal scrollback left to scroll
|
|
8
|
+
// (packages/tui/src/app.tsx:190-205, screenMode defaults to "alternate-screen").
|
|
9
|
+
// UX-01 forbids Causyne doing that, and the reason is specific and correct: the
|
|
10
|
+
// old src/tui/screen.js wrote ALT_ENTER + HOME + CLEAR_ALL unconditionally,
|
|
11
|
+
// there is no capability detection for the alternate buffer anywhere, and a
|
|
12
|
+
// terminal that silently IGNORES ?1049h does not throw — so the \x1b[2J then
|
|
13
|
+
// wiped the user's real screen.
|
|
14
|
+
//
|
|
15
|
+
// The Stage owns a REGION instead of a screen. Every escape it emits is
|
|
16
|
+
// relative, so it is structurally incapable of touching a row it did not write:
|
|
17
|
+
//
|
|
18
|
+
// * the band is reserved by emitting newlines, never by absolute addressing;
|
|
19
|
+
// * movement is CUU/CUD only — a newline inside paint() would scroll the
|
|
20
|
+
// terminal out from under the block, so paint() emits none;
|
|
21
|
+
// * one seam row is left above the band, which is the visible proof that this
|
|
22
|
+
// is not an alternate screen;
|
|
23
|
+
// * on exit the band is collapsed and the transcript is flushed to real
|
|
24
|
+
// scrollback (printThrough), so the session stays permanent terminal
|
|
25
|
+
// history — the property UX-01 was actually protecting.
|
|
26
|
+
//
|
|
27
|
+
// Rendering is a ROW diff, not a cell diff. opencode can afford per-cell
|
|
28
|
+
// diffing because @opentui/core ships per-platform Zig binaries over FFI;
|
|
29
|
+
// ZM-06 bars that here, so the region is bounded and the granularity is coarser.
|
|
30
|
+
// A spinner tick touches one row; a scroll touches all of them, at a
|
|
31
|
+
// user-driven rate. Both are cheap at 40x200 in pure JS.
|
|
32
|
+
//
|
|
33
|
+
// Pure Node: no dependency, no shell.
|
|
34
|
+
|
|
35
|
+
import { visibleWidth, truncate } from './text.js';
|
|
36
|
+
|
|
37
|
+
// Below this the band cannot hold a viewport plus an input box plus a footer,
|
|
38
|
+
// and a cramped stage is worse than honest scrollback. The fallback is not a
|
|
39
|
+
// degraded stage; it is the scrollback renderer, unchanged.
|
|
40
|
+
export const MIN_STAGE_ROWS = 16;
|
|
41
|
+
|
|
42
|
+
// Synchronized output. A terminal that does not implement it ignores both
|
|
43
|
+
// halves, so this is free; one that does composites the whole repaint at once,
|
|
44
|
+
// which is what stops a scroll from tearing.
|
|
45
|
+
const SYNC_ON = '\x1b[?2026h';
|
|
46
|
+
const SYNC_OFF = '\x1b[?2026l';
|
|
47
|
+
const HIDE_CURSOR = '\x1b[?25l';
|
|
48
|
+
const SHOW_CURSOR = '\x1b[?25h';
|
|
49
|
+
const ERASE_LINE = '\x1b[K';
|
|
50
|
+
const ERASE_BELOW = '\x1b[J';
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* How tall the band should be, or 0 for "do not use a stage".
|
|
54
|
+
*
|
|
55
|
+
* CAUSYNE_STAGE: off | auto | <rows> | <pct>%
|
|
56
|
+
*/
|
|
57
|
+
export function stageHeight(out = process.stdout, env = process.env) {
|
|
58
|
+
const setting = String(env.CAUSYNE_STAGE ?? 'auto').trim().toLowerCase();
|
|
59
|
+
if (setting === 'off' || setting === '0' || setting === 'false') return 0;
|
|
60
|
+
|
|
61
|
+
const rows = Number(out && out.rows);
|
|
62
|
+
if (!Number.isFinite(rows) || rows <= 0) return 0;
|
|
63
|
+
|
|
64
|
+
// The seam: never take the last row of the user's screen. It costs one row
|
|
65
|
+
// and it is the difference between "a region" and "the screen".
|
|
66
|
+
const ceiling = rows - 1;
|
|
67
|
+
|
|
68
|
+
let target = ceiling;
|
|
69
|
+
const percent = /^(\d{1,3})%$/.exec(setting);
|
|
70
|
+
if (percent) target = Math.round((rows * Number(percent[1])) / 100);
|
|
71
|
+
else if (/^\d+$/.test(setting)) target = Number(setting);
|
|
72
|
+
|
|
73
|
+
target = Math.min(target, ceiling);
|
|
74
|
+
if (!Number.isFinite(target) || target < MIN_STAGE_ROWS) return 0;
|
|
75
|
+
return target;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* May we run a stage on this stream at all?
|
|
80
|
+
*
|
|
81
|
+
* Everything here is a reason the region could not be repainted safely, so the
|
|
82
|
+
* answer is the scrollback renderer rather than a stage that misbehaves.
|
|
83
|
+
*/
|
|
84
|
+
export function stageEnabled(out = process.stdout, env = process.env) {
|
|
85
|
+
if (!out || !out.isTTY) return false;
|
|
86
|
+
if (String(env.TERM || '') === 'dumb') return false;
|
|
87
|
+
if (env.CAUSYNE_SIMPLE_INPUT === '1') return false;
|
|
88
|
+
return stageHeight(out, env) > 0;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export class Stage {
|
|
92
|
+
constructor({ out = process.stdout, env = process.env, height = 0 } = {}) {
|
|
93
|
+
this.out = out;
|
|
94
|
+
this.env = env;
|
|
95
|
+
|
|
96
|
+
// The CEILING, not the size. A band claimed by printing newlines costs the
|
|
97
|
+
// user a real row of terminal for every row it takes, so it takes only what
|
|
98
|
+
// it has something to put in and grows from there. Sizing it to the screen
|
|
99
|
+
// is free on an alternate screen — which is what opencode is — and on a
|
|
100
|
+
// reserved band it is 73 rows of void and a banner scrolled out of sight.
|
|
101
|
+
this.maxHeight = height > 0 ? Math.floor(height) : stageHeight(out, env);
|
|
102
|
+
// Rows actually claimed. Zero until reserve().
|
|
103
|
+
this.height = 0;
|
|
104
|
+
|
|
105
|
+
this.reserved = false;
|
|
106
|
+
// Where the caret is, as a row index inside the band. reserve() leaves it
|
|
107
|
+
// on the band's last row, because that is where the newlines end.
|
|
108
|
+
this.cursor = 0;
|
|
109
|
+
// What is currently on screen, row for row. The diff is against this.
|
|
110
|
+
this.prev = [];
|
|
111
|
+
this.caret = { row: 0, col: 0 };
|
|
112
|
+
|
|
113
|
+
// Measured, so the tests can assert the diff actually diffs.
|
|
114
|
+
// `claimed` is what makes the newline invariant checkable: every newline the
|
|
115
|
+
// Stage ever emits is a row it claimed, and paint() emits none. The old
|
|
116
|
+
// phrasing — "nothing after reserve emits a newline" — could not survive a
|
|
117
|
+
// band that grows, and conflated the claim path with the paint path.
|
|
118
|
+
this.stats = { paints: 0, rowsWritten: 0, bytes: 0, claimed: 0 };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
width() {
|
|
122
|
+
const columns = Number(this.out && this.out.columns);
|
|
123
|
+
return Number.isFinite(columns) && columns > 0 ? Math.max(20, columns) : 80;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Rows available to the caller for content, i.e. the whole band. */
|
|
127
|
+
get rows() {
|
|
128
|
+
return this.height;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
write(text) {
|
|
132
|
+
if (!text) return;
|
|
133
|
+
this.stats.bytes += text.length;
|
|
134
|
+
this.out.write(text);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Claim the band.
|
|
139
|
+
*
|
|
140
|
+
* Emitting `height` newlines is the whole mechanism. It scrolls whatever was
|
|
141
|
+
* on screen up into the terminal's own scrollback — the same thing any
|
|
142
|
+
* program that prints does — and leaves the caret on the band's last row.
|
|
143
|
+
* With height = rows - 1 this lands on the bottom row from any starting
|
|
144
|
+
* position, which is what anchors the band without absolute addressing.
|
|
145
|
+
*/
|
|
146
|
+
reserve(rows = this.maxHeight) {
|
|
147
|
+
if (this.reserved || this.maxHeight < 1) return false;
|
|
148
|
+
const claim = Math.max(1, Math.min(Math.floor(rows), this.maxHeight));
|
|
149
|
+
this.write('\n'.repeat(claim));
|
|
150
|
+
this.stats.claimed += claim;
|
|
151
|
+
this.reserved = true;
|
|
152
|
+
this.height = claim;
|
|
153
|
+
this.cursor = claim - 1;
|
|
154
|
+
this.prev = new Array(claim).fill(null);
|
|
155
|
+
this.caret = { row: claim - 1, col: 0 };
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Claim more rows, up to the ceiling.
|
|
161
|
+
*
|
|
162
|
+
* Newlines emitted from the band's last row scroll the screen and leave the
|
|
163
|
+
* new rows at the BOTTOM, so in band coordinates growth appends: rows 0..h-1
|
|
164
|
+
* keep what they had and the new ones arrive blank. That is why `prev` is
|
|
165
|
+
* extended with nulls rather than rebuilt — the diff keeps everything it
|
|
166
|
+
* already knows and only pays for what actually changed.
|
|
167
|
+
*
|
|
168
|
+
* Growth scrolls whatever is above the band up by the same amount, which is
|
|
169
|
+
* what any program that prints does, and is the only thing UX-01 rev 2 clause
|
|
170
|
+
* (b) permits to move it.
|
|
171
|
+
*/
|
|
172
|
+
grow(delta) {
|
|
173
|
+
if (!this.reserved || delta <= 0) return 0;
|
|
174
|
+
const room = this.maxHeight - this.height;
|
|
175
|
+
const claim = Math.min(Math.floor(delta), room);
|
|
176
|
+
if (claim <= 0) return 0;
|
|
177
|
+
|
|
178
|
+
this.moveTo(this.height - 1);
|
|
179
|
+
this.write('\n'.repeat(claim));
|
|
180
|
+
this.stats.claimed += claim;
|
|
181
|
+
this.height += claim;
|
|
182
|
+
this.cursor = this.height - 1;
|
|
183
|
+
for (let i = 0; i < claim; i++) this.prev.push(null);
|
|
184
|
+
return claim;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Normalise a caller's rows to exactly `height` rows, each safe to write.
|
|
189
|
+
*
|
|
190
|
+
* The width clamp is the invariant everything rests on and it is the same one
|
|
191
|
+
* ChatPrompt.clampRows() enforces: a row wider than the terminal occupies TWO
|
|
192
|
+
* physical rows, and one wrap desynchronises every relative move that follows
|
|
193
|
+
* it. The spare column also avoids the terminal's deferred-wrap state at the
|
|
194
|
+
* exact margin.
|
|
195
|
+
*/
|
|
196
|
+
normalize(rows) {
|
|
197
|
+
const limit = Math.max(1, this.width() - 1);
|
|
198
|
+
const out = new Array(this.height);
|
|
199
|
+
for (let i = 0; i < this.height; i++) {
|
|
200
|
+
let row = rows && rows[i] != null ? String(rows[i]) : '';
|
|
201
|
+
if (row.includes('\n')) row = row.split('\n')[0];
|
|
202
|
+
out[i] = visibleWidth(row) <= limit ? row : truncate(row, limit, '');
|
|
203
|
+
}
|
|
204
|
+
return out;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
moveTo(row) {
|
|
208
|
+
const target = Math.max(0, Math.min(row, this.height - 1));
|
|
209
|
+
const delta = target - this.cursor;
|
|
210
|
+
// CUU/CUD only. Never a newline: on the band's last row a newline scrolls
|
|
211
|
+
// the terminal, which would move the whole block out from under the diff.
|
|
212
|
+
if (delta < 0) this.write(`\x1b[${-delta}A`);
|
|
213
|
+
else if (delta > 0) this.write(`\x1b[${delta}B`);
|
|
214
|
+
this.cursor = target;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Repaint the band. Rows that did not change cost nothing.
|
|
219
|
+
*
|
|
220
|
+
* Returns the number of rows actually written, so a test can assert that an
|
|
221
|
+
* idle frame is free.
|
|
222
|
+
*/
|
|
223
|
+
paint(rows, caret = null) {
|
|
224
|
+
if (!this.reserved) return 0;
|
|
225
|
+
|
|
226
|
+
const next = this.normalize(rows);
|
|
227
|
+
const target = caret && Number.isFinite(caret.row)
|
|
228
|
+
? { row: Math.max(0, Math.min(Math.floor(caret.row), this.height - 1)), col: Math.max(0, Math.floor(caret.col) || 0) }
|
|
229
|
+
: { row: this.height - 1, col: 0 };
|
|
230
|
+
|
|
231
|
+
const changed = [];
|
|
232
|
+
for (let i = 0; i < this.height; i++) {
|
|
233
|
+
if (next[i] !== this.prev[i]) changed.push(i);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const caretMoved = target.row !== this.caret.row || target.col !== this.caret.col;
|
|
237
|
+
// A frame with nothing to say emits zero bytes. opencode's renderer makes
|
|
238
|
+
// the same bet and calls it out as its main anti-flicker mechanism.
|
|
239
|
+
if (changed.length === 0 && !caretMoved) return 0;
|
|
240
|
+
|
|
241
|
+
this.write(SYNC_ON + HIDE_CURSOR);
|
|
242
|
+
for (const i of changed) {
|
|
243
|
+
this.moveTo(i);
|
|
244
|
+
this.write('\r' + next[i] + ERASE_LINE);
|
|
245
|
+
}
|
|
246
|
+
this.moveTo(target.row);
|
|
247
|
+
this.write('\r');
|
|
248
|
+
if (target.col > 0) this.write(`\x1b[${target.col}C`);
|
|
249
|
+
this.write(SHOW_CURSOR + SYNC_OFF);
|
|
250
|
+
|
|
251
|
+
this.prev = next;
|
|
252
|
+
this.caret = target;
|
|
253
|
+
this.stats.paints++;
|
|
254
|
+
this.stats.rowsWritten += changed.length;
|
|
255
|
+
return changed.length;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Give the band back.
|
|
260
|
+
*
|
|
261
|
+
* Erases from the band's top row down. ED 0 clears from the caret to the end
|
|
262
|
+
* of the screen, which is exactly the band and nothing above it. It is not
|
|
263
|
+
* the whole-screen erase (ED 2) that UX-01 bans, and the source scan in
|
|
264
|
+
* tests/chatSurface.test.js is what keeps the two from being confused.
|
|
265
|
+
*/
|
|
266
|
+
collapse() {
|
|
267
|
+
if (!this.reserved) return;
|
|
268
|
+
this.write(SYNC_ON);
|
|
269
|
+
this.moveTo(0);
|
|
270
|
+
this.write('\r' + ERASE_BELOW + SHOW_CURSOR + SYNC_OFF);
|
|
271
|
+
this.reserved = false;
|
|
272
|
+
this.cursor = 0;
|
|
273
|
+
this.prev = [];
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* The exit contract: the session becomes permanent terminal history.
|
|
278
|
+
*
|
|
279
|
+
* Call after collapse(). opencode's alternate-screen TUI leaves nothing
|
|
280
|
+
* behind and its split-footer mode leaves everything but cannot scroll;
|
|
281
|
+
* doing both is the point of the Stage.
|
|
282
|
+
*/
|
|
283
|
+
printThrough(lines) {
|
|
284
|
+
if (!Array.isArray(lines) || lines.length === 0) return 0;
|
|
285
|
+
let written = 0;
|
|
286
|
+
let chunk = '';
|
|
287
|
+
for (const line of lines) {
|
|
288
|
+
chunk += (line == null ? '' : String(line)) + '\n';
|
|
289
|
+
written++;
|
|
290
|
+
if (chunk.length > 16384) { this.write(chunk); chunk = ''; }
|
|
291
|
+
}
|
|
292
|
+
if (chunk) this.write(chunk);
|
|
293
|
+
return written;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Terminal resized.
|
|
298
|
+
*
|
|
299
|
+
* Collapse and re-reserve rather than trying to grow or shrink in place. The
|
|
300
|
+
* band's geometry is the one thing the diff cannot recover from being wrong
|
|
301
|
+
* about, and a resize already costs a full reflow of the transcript; one
|
|
302
|
+
* extra scroll per settled gesture is the cheap half. Callers must debounce
|
|
303
|
+
* (ChatPrompt already settles resize at 60ms).
|
|
304
|
+
*/
|
|
305
|
+
resize(height = 0, claim = 0) {
|
|
306
|
+
const next = height > 0 ? Math.floor(height) : stageHeight(this.out, this.env);
|
|
307
|
+
const wasReserved = this.reserved;
|
|
308
|
+
if (wasReserved) this.collapse();
|
|
309
|
+
this.maxHeight = next;
|
|
310
|
+
this.height = 0;
|
|
311
|
+
if (next < 1) return false;
|
|
312
|
+
if (wasReserved) return this.reserve(claim > 0 ? claim : next);
|
|
313
|
+
return false;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Force the next paint to redraw every row.
|
|
318
|
+
*
|
|
319
|
+
* Anything that writes to the terminal behind the Stage's back (an evicted
|
|
320
|
+
* frame flushed to scrollback, a print-through) invalidates the diff.
|
|
321
|
+
*/
|
|
322
|
+
invalidate() {
|
|
323
|
+
this.prev = new Array(this.height).fill(null);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Rows still available before the band hits its ceiling. */
|
|
327
|
+
get room() {
|
|
328
|
+
return Math.max(0, this.maxHeight - this.height);
|
|
329
|
+
}
|
|
330
|
+
}
|