@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,254 @@
|
|
|
1
|
+
// The line that tells the user what is happening to their money and their time.
|
|
2
|
+
//
|
|
3
|
+
// While a turn ran, the chat printed one thing:
|
|
4
|
+
//
|
|
5
|
+
// ⠋ Thinking…
|
|
6
|
+
//
|
|
7
|
+
// and that was the entire signal for what could be a four-minute, forty-step,
|
|
8
|
+
// several-dollar run. The user could not see elapsed time, which model was
|
|
9
|
+
// spending, how many steps had gone by, how many tokens the turn had burned, or
|
|
10
|
+
|
|
11
|
+
// those numbers was already in memory — the loop broadcasts `usage_update` on
|
|
12
|
+
// every response and the config carries the model — and none of it was shown.
|
|
13
|
+
//
|
|
14
|
+
// openclaw puts elapsed and connection state on its waiting line; opencode keeps
|
|
15
|
+
// a permanent footer with cwd, LSP and MCP counts. This is the same idea in
|
|
16
|
+
// Causyne's register: no whimsy, just the four numbers a person needs to decide
|
|
17
|
+
// whether to let a run continue or press esc.
|
|
18
|
+
//
|
|
19
|
+
// The line degrades by DROPPING SEGMENTS, right to left, until it fits. A narrow
|
|
20
|
+
// terminal loses the cost before it loses the elapsed time, and loses the
|
|
21
|
+
// elapsed time before it loses the message. It never wraps, because a wrapping
|
|
22
|
+
// spinner leaves debris every tick.
|
|
23
|
+
//
|
|
24
|
+
// Pure Node over ./theme.js and ./text.js. No dependency, no shell.
|
|
25
|
+
|
|
26
|
+
import { paint, dim, GLYPHS } from './theme.js';
|
|
27
|
+
import { truncate, visibleWidth, safeDisplay, shortenHome } from './text.js';
|
|
28
|
+
import { contextMeter } from './block.js';
|
|
29
|
+
import { CHARS_PER_TOKEN } from '../contextManager.js';
|
|
30
|
+
|
|
31
|
+
/** 1234 -> "1.2k", 1234567 -> "1.2M". Token counts are read, not summed. */
|
|
32
|
+
export function compactCount(value) {
|
|
33
|
+
const n = Number(value) || 0;
|
|
34
|
+
if (n < 1000) return String(Math.round(n));
|
|
35
|
+
if (n < 1000000) return `${(n / 1000).toFixed(n < 10000 ? 1 : 0)}k`;
|
|
36
|
+
return `${(n / 1000000).toFixed(1)}M`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Money, at the precision the number deserves.
|
|
41
|
+
*
|
|
42
|
+
* Sub-cent runs printed as "$0.00" read as free, which is how a user learns not
|
|
43
|
+
* to look at the cost at all.
|
|
44
|
+
*/
|
|
45
|
+
export function formatCost(value) {
|
|
46
|
+
const n = Number(value) || 0;
|
|
47
|
+
if (n <= 0) return '$0';
|
|
48
|
+
if (n < 0.01) return `$${n.toFixed(4)}`;
|
|
49
|
+
if (n < 1) return `$${n.toFixed(3)}`;
|
|
50
|
+
return `$${n.toFixed(2)}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function formatElapsed(ms) {
|
|
54
|
+
const total = Math.max(0, Math.round(ms / 1000));
|
|
55
|
+
if (total < 60) return `${(ms / 1000).toFixed(1)}s`;
|
|
56
|
+
const minutes = Math.floor(total / 60);
|
|
57
|
+
const seconds = total % 60;
|
|
58
|
+
if (minutes < 60) return `${minutes}m${String(seconds).padStart(2, '0')}s`;
|
|
59
|
+
return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}m`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Strip a provider prefix so "anthropic/claude-opus-5" reads as the model. */
|
|
63
|
+
function shortModel(model) {
|
|
64
|
+
const name = String(model || '').split('/').pop() || '';
|
|
65
|
+
return name.length > 22 ? name.slice(0, 21) + GLYPHS.ellipsis : name;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export class StatusLine {
|
|
69
|
+
constructor() {
|
|
70
|
+
this.model = '';
|
|
71
|
+
this.provider = '';
|
|
72
|
+
this.tier = '';
|
|
73
|
+
this.cwd = '';
|
|
74
|
+
// Both in CHARACTERS — the unit the window is actually budgeted in.
|
|
75
|
+
// `contextLimit` always was; the numerator was the thing that was wrong.
|
|
76
|
+
this.contextLimit = 0;
|
|
77
|
+
this.contextChars = 0;
|
|
78
|
+
this.reset();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Session-lifetime facts, set once from the same info as the header. */
|
|
82
|
+
setSession(info = {}) {
|
|
83
|
+
this.model = shortModel(info.model);
|
|
84
|
+
this.provider = info.provider ? String(info.provider) : '';
|
|
85
|
+
this.tier = info.tier ? String(info.tier) : '';
|
|
86
|
+
// The idle footer answers "where am I and how much room is left", which is
|
|
87
|
+
// what opencode's permanent footer shows (cwd left, context right). Neither
|
|
88
|
+
// fact was reachable once the session header had scrolled away.
|
|
89
|
+
this.cwd = info.cwd ? String(info.cwd) : '';
|
|
90
|
+
this.contextLimit = Number(info.contextLimit) || 0;
|
|
91
|
+
return this;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Per-turn counters. */
|
|
95
|
+
reset() {
|
|
96
|
+
this.turnTokens = 0;
|
|
97
|
+
this.turnCost = 0;
|
|
98
|
+
this.cachedTokens = 0;
|
|
99
|
+
this.contextChars = 0;
|
|
100
|
+
return this;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The idle footer: where you are, and how much context is left.
|
|
105
|
+
*
|
|
106
|
+
* A separate shape from the working line on purpose. While a turn runs the
|
|
107
|
+
* user is deciding whether to let it continue, so the line carries elapsed,
|
|
108
|
+
* steps, tokens and cost. Between turns none of those are actionable, and the
|
|
109
|
+
* two facts that are — the working directory and the remaining context — had
|
|
110
|
+
* nowhere to live. Same justification as render(): left fact, right numbers.
|
|
111
|
+
*/
|
|
112
|
+
renderIdle({ width = 80, hint = '' } = {}) {
|
|
113
|
+
const budget = Math.max(10, width - 1);
|
|
114
|
+
// Characters in, characters out — then one conversion, so both halves of
|
|
115
|
+
// "24K (20%)" describe the same measurement. Feeding this the task's
|
|
116
|
+
// CUMULATIVE token spend and dividing by a character pool is what produced
|
|
117
|
+
// a permanent "290K (99%)" on a window that was nowhere near full.
|
|
118
|
+
const meter = contextMeter(
|
|
119
|
+
Math.round(this.contextChars / CHARS_PER_TOKEN),
|
|
120
|
+
Math.round(this.contextLimit / CHARS_PER_TOKEN)
|
|
121
|
+
);
|
|
122
|
+
const segments = [];
|
|
123
|
+
if (meter) segments.push(meter);
|
|
124
|
+
if (hint) segments.push(hint);
|
|
125
|
+
|
|
126
|
+
const separator = ` ${GLYPHS.dot} `;
|
|
127
|
+
let kept = segments.slice();
|
|
128
|
+
let tail = kept.join(separator);
|
|
129
|
+
// Drop the hint before the number: a truncated keybinding is a wrong one.
|
|
130
|
+
while (kept.length > 1 && visibleWidth(tail) > Math.floor(budget / 2)) {
|
|
131
|
+
kept.pop();
|
|
132
|
+
tail = kept.join(separator);
|
|
133
|
+
}
|
|
134
|
+
if (visibleWidth(tail) > budget) tail = '';
|
|
135
|
+
|
|
136
|
+
const room = tail ? Math.max(0, budget - visibleWidth(tail) - visibleWidth(separator)) : budget;
|
|
137
|
+
const where = this.cwd ? truncate(shortenHome(this.cwd), room, GLYPHS.ellipsis) : '';
|
|
138
|
+
const left = dim(where);
|
|
139
|
+
if (!tail) return left;
|
|
140
|
+
|
|
141
|
+
const gap = Math.max(
|
|
142
|
+
visibleWidth(separator),
|
|
143
|
+
width - 1 - visibleWidth(left) - visibleWidth(tail)
|
|
144
|
+
);
|
|
145
|
+
return `${left}${' '.repeat(gap)}${dim(tail)}`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Consume a `usage_update` frame.
|
|
150
|
+
*
|
|
151
|
+
* taskCostUsd is the whole task; costUsd is this response. The turn total is
|
|
152
|
+
* what the user is deciding about right now, so accumulate that.
|
|
153
|
+
*/
|
|
154
|
+
onUsage(msg = {}) {
|
|
155
|
+
// TASK scope, explicitly. `usedTokens` now carries the DAILY total, which is
|
|
156
|
+
|
|
157
|
+
// shows — the working line is about the turn the user is deciding on.
|
|
158
|
+
this.turnTokens = Number(msg.taskTokens) || Number(msg.usedTokens) || this.turnTokens;
|
|
159
|
+
// What the request actually carried, measured at the point it was built.
|
|
160
|
+
if (Number(msg.contextChars) > 0) this.contextChars = Number(msg.contextChars);
|
|
161
|
+
if (Number(msg.contextLimitChars) > 0) this.contextLimit = Number(msg.contextLimitChars);
|
|
162
|
+
const responseCost = Number(msg.costUsd) || 0;
|
|
163
|
+
const taskCost = Number(msg.taskCostUsd) || 0;
|
|
164
|
+
// Prefer the task total when the provider reports one — it already includes
|
|
165
|
+
// every response, and summing on top would double-count.
|
|
166
|
+
this.turnCost = taskCost > 0 ? taskCost : this.turnCost + responseCost;
|
|
167
|
+
this.cachedTokens = Number(msg.cachedTokens) || 0;
|
|
168
|
+
return this;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Build the line.
|
|
173
|
+
*
|
|
174
|
+
* `segments` are ordered most to least important. Anything that does not fit
|
|
175
|
+
* is dropped whole — never truncated into a misleading half-number.
|
|
176
|
+
*/
|
|
177
|
+
render({ message = '', elapsedMs = 0, steps = 0, width = 80, spinnerFrame = '' } = {}) {
|
|
178
|
+
const head = spinnerFrame ? `${paint('accent', spinnerFrame)} ` : '';
|
|
179
|
+
const budget = Math.max(10, width - visibleWidth(head) - 1);
|
|
180
|
+
|
|
181
|
+
const segments = [];
|
|
182
|
+
if (elapsedMs > 0) segments.push(formatElapsed(elapsedMs));
|
|
183
|
+
if (steps > 0) segments.push(`step ${steps}`);
|
|
184
|
+
if (this.turnTokens > 0) segments.push(`${compactCount(this.turnTokens)} tok`);
|
|
185
|
+
if (this.turnCost > 0) segments.push(formatCost(this.turnCost));
|
|
186
|
+
if (this.cachedTokens > 0) segments.push(`${compactCount(this.cachedTokens)} cached`);
|
|
187
|
+
if (this.model) segments.push(this.model);
|
|
188
|
+
|
|
189
|
+
const separator = ` ${GLYPHS.dot} `;
|
|
190
|
+
const text = safeDisplay(message);
|
|
191
|
+
|
|
192
|
+
// Drop segments from the right until the message fits at its natural width.
|
|
193
|
+
//
|
|
194
|
+
// The message is what the user is reading; the numbers are what they glance
|
|
195
|
+
// at. Reserving space for the message FIRST is what stops a wide terminal
|
|
196
|
+
// from printing "reading project fi…" next to six perfectly intact counters.
|
|
197
|
+
// The reservation is capped at half the line so a very long status cannot
|
|
198
|
+
// squeeze every number out.
|
|
199
|
+
let kept = segments.slice();
|
|
200
|
+
const wanted = Math.min(visibleWidth(text), Math.max(18, Math.floor(budget / 2)));
|
|
201
|
+
while (kept.length > 0) {
|
|
202
|
+
const tail = kept.join(separator);
|
|
203
|
+
if (visibleWidth(tail) + separator.length + wanted <= budget) break;
|
|
204
|
+
kept.pop();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const tail = kept.length > 0 ? kept.join(separator) : '';
|
|
208
|
+
const messageBudget = tail
|
|
209
|
+
? Math.max(1, budget - visibleWidth(tail) - separator.length)
|
|
210
|
+
: budget;
|
|
211
|
+
const body = truncate(text, messageBudget, GLYPHS.ellipsis);
|
|
212
|
+
|
|
213
|
+
const left = `${head}${paint('muted', body)}`;
|
|
214
|
+
if (!tail) return left;
|
|
215
|
+
|
|
216
|
+
// Justify: message left, numbers hard right.
|
|
217
|
+
//
|
|
218
|
+
// Packing everything against the left edge wastes the whole right half of a
|
|
219
|
+
// wide terminal and makes the counters drift horizontally as the status text
|
|
220
|
+
// changes length — the eye has to re-find them every frame. Pinning them to
|
|
221
|
+
// the right edge means they sit in the same column all session. This is the
|
|
222
|
+
// shape opencode's session footer uses (justifyContent="space-between", the
|
|
223
|
+
// directory left and the counters right).
|
|
224
|
+
//
|
|
225
|
+
// The fitting loop above already guarantees room for a separator between the
|
|
226
|
+
// two, so the gap can never collapse to nothing.
|
|
227
|
+
// Justify against width - 1, not width.
|
|
228
|
+
//
|
|
229
|
+
// The fitting loop above budgets `width - head - 1`, but this gap was
|
|
230
|
+
// computed against the full `width`, so the finished line came back exactly
|
|
231
|
+
// `width` columns — flush against the right edge, with no slack at all. A
|
|
232
|
+
// terminal that renders one of the ambiguous-width glyphs in this line (`·`
|
|
233
|
+
// appears twice) as two columns then wraps it, and a wrapped row in the live
|
|
234
|
+
// region corrupts erase()'s row accounting. One reserved column removes the
|
|
235
|
+
// whole class of failure at its source; ChatPrompt.clampRows() is the net.
|
|
236
|
+
const gap = Math.max(
|
|
237
|
+
visibleWidth(separator),
|
|
238
|
+
width - 1 - visibleWidth(left) - visibleWidth(tail)
|
|
239
|
+
);
|
|
240
|
+
return `${left}${' '.repeat(gap)}${dim(tail)}`;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* The non-TTY form: no spinner, no elapsed clock, printed once.
|
|
245
|
+
*
|
|
246
|
+
* Piped into a CI log an animated line becomes thousands of junk rows, and an
|
|
247
|
+
* elapsed time that never updates is worse than none.
|
|
248
|
+
*/
|
|
249
|
+
renderStatic(message, width = 80) {
|
|
250
|
+
const parts = [safeDisplay(message)];
|
|
251
|
+
if (this.model) parts.push(this.model);
|
|
252
|
+
return truncate(`${GLYPHS.dot} ${parts.join(` ${GLYPHS.dot} `)}`, width, GLYPHS.ellipsis);
|
|
253
|
+
}
|
|
254
|
+
}
|
package/src/tui/text.js
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
// ANSI-aware text primitives.
|
|
2
|
+
//
|
|
3
|
+
// Every one of these exists because the CLI got a terminal detail wrong:
|
|
4
|
+
//
|
|
5
|
+
// * `padEnd` on a coloured string pads by BYTE length, so escape sequences
|
|
6
|
+
// counted toward the column and every aligned table drifted.
|
|
7
|
+
// * `slice()` on a coloured string can cut through the middle of an escape,
|
|
8
|
+
// leaking `[38;2;96;205` as literal text and leaving the colour stuck on.
|
|
9
|
+
// * Tool output was printed raw — see sanitize() below, which is a security
|
|
10
|
+
// fix, not a cosmetic one.
|
|
11
|
+
//
|
|
12
|
+
// Pure JS. No dependency, no shell.
|
|
13
|
+
|
|
14
|
+
import os from 'os';
|
|
15
|
+
|
|
16
|
+
// CSI sequences (colour, cursor movement, erase) and OSC sequences (window
|
|
17
|
+
// title, hyperlinks — terminated by BEL or ST).
|
|
18
|
+
const CSI_PATTERN = /\x1b\[[0-?]*[ -/]*[@-~]/g;
|
|
19
|
+
const OSC_PATTERN = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g;
|
|
20
|
+
const OTHER_ESCAPE_PATTERN = /\x1b[@-Z\\-_]/g;
|
|
21
|
+
|
|
22
|
+
/** Everything that is an escape sequence rather than visible text. */
|
|
23
|
+
export function stripAnsi(text) {
|
|
24
|
+
return String(text == null ? '' : text)
|
|
25
|
+
.replace(OSC_PATTERN, '')
|
|
26
|
+
.replace(CSI_PATTERN, '')
|
|
27
|
+
.replace(OTHER_ESCAPE_PATTERN, '');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Does this code point occupy two terminal columns?
|
|
32
|
+
*
|
|
33
|
+
* CJK, Hangul, Kana and most emoji are double-width. Getting this wrong is what
|
|
34
|
+
* makes a table of Indonesian and Chinese filenames drift apart.
|
|
35
|
+
*/
|
|
36
|
+
function isWideCodePoint(code) {
|
|
37
|
+
return (
|
|
38
|
+
(code >= 0x1100 && code <= 0x115f) || // Hangul Jamo
|
|
39
|
+
(code >= 0x2e80 && code <= 0xa4cf) || // CJK radicals … Yi
|
|
40
|
+
(code >= 0xac00 && code <= 0xd7a3) || // Hangul syllables
|
|
41
|
+
(code >= 0xf900 && code <= 0xfaff) || // CJK compatibility ideographs
|
|
42
|
+
(code >= 0xfe30 && code <= 0xfe6f) || // CJK compatibility forms
|
|
43
|
+
(code >= 0xff00 && code <= 0xff60) || // Fullwidth forms
|
|
44
|
+
(code >= 0xffe0 && code <= 0xffe6) ||
|
|
45
|
+
(code >= 0x1f300 && code <= 0x1f64f) || // emoji
|
|
46
|
+
(code >= 0x1f900 && code <= 0x1f9ff)
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The East Asian AMBIGUOUS class (UAX #11).
|
|
52
|
+
*
|
|
53
|
+
* These render as ONE column beside Latin text and TWO beside CJK, depending on
|
|
54
|
+
* the terminal's font and locale — and every glyph in theme.js GLYPHS is in this
|
|
55
|
+
* class: `·` U+00B7, `▸` U+25B8, `✓` U+2713, `─` U+2500, `│` U+2502, `…` U+2026.
|
|
56
|
+
*
|
|
57
|
+
* Counting them narrow is right for a Western-configured terminal and is what
|
|
58
|
+
* every comparable library does, so that stays the default. But when the count
|
|
59
|
+
* is wrong the row is WIDER than measured, the terminal wraps it, and a wrapped
|
|
60
|
+
* row makes ChatPrompt.drawnRows (logical) disagree with the physical row count
|
|
61
|
+
* — which is what left a doubled hint row and glyph debris on screen. Callers
|
|
62
|
+
* that must not be wrong get the wide reading via the environment.
|
|
63
|
+
*/
|
|
64
|
+
function isAmbiguousCodePoint(code) {
|
|
65
|
+
return (
|
|
66
|
+
(code >= 0x00a1 && code <= 0x00ff && code !== 0x00ad) ||
|
|
67
|
+
(code >= 0x2010 && code <= 0x2027) ||
|
|
68
|
+
code === 0x2022 || code === 0x2026 ||
|
|
69
|
+
(code >= 0x2190 && code <= 0x21ff) || // arrows
|
|
70
|
+
(code >= 0x2200 && code <= 0x22ff) || // mathematical operators
|
|
71
|
+
(code >= 0x2460 && code <= 0x24ff) || // enclosed alphanumerics
|
|
72
|
+
(code >= 0x2500 && code <= 0x257f) || // box drawing
|
|
73
|
+
(code >= 0x2580 && code <= 0x259f) || // block elements
|
|
74
|
+
(code >= 0x25a0 && code <= 0x25ff) || // geometric shapes
|
|
75
|
+
(code >= 0x2600 && code <= 0x26ff) || // miscellaneous symbols
|
|
76
|
+
(code >= 0x2700 && code <= 0x27bf) // dingbats (✓ ✕)
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Should ambiguous-width code points be measured as two columns?
|
|
82
|
+
*
|
|
83
|
+
* Read once at import: an explicit override wins, otherwise a CJK locale is the
|
|
84
|
+
* signal, because that is the configuration in which a terminal's font actually
|
|
85
|
+
* renders these glyphs wide.
|
|
86
|
+
*/
|
|
87
|
+
function detectAmbiguousWide() {
|
|
88
|
+
const explicit = process.env.CAUSYNE_AMBIGUOUS_WIDE;
|
|
89
|
+
if (explicit === '1') return true;
|
|
90
|
+
if (explicit === '0') return false;
|
|
91
|
+
const locale = String(process.env.LC_ALL || process.env.LC_CTYPE || process.env.LANG || '');
|
|
92
|
+
return /^(ja|ko|zh)([_.-]|$)/i.test(locale);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export const AMBIGUOUS_IS_WIDE = detectAmbiguousWide();
|
|
96
|
+
|
|
97
|
+
/** Columns one code point occupies, under the active ambiguous-width policy. */
|
|
98
|
+
function columnsFor(code) {
|
|
99
|
+
if (isWideCodePoint(code)) return 2;
|
|
100
|
+
if (AMBIGUOUS_IS_WIDE && isAmbiguousCodePoint(code)) return 2;
|
|
101
|
+
return 1;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Columns this string will occupy once printed. Escapes count for nothing. */
|
|
105
|
+
export function visibleWidth(text) {
|
|
106
|
+
const plain = stripAnsi(text);
|
|
107
|
+
let width = 0;
|
|
108
|
+
for (const character of plain) {
|
|
109
|
+
const code = character.codePointAt(0);
|
|
110
|
+
if (code === 0x200d) continue; // zero-width joiner
|
|
111
|
+
if (code >= 0xfe00 && code <= 0xfe0f) continue; // variation selectors
|
|
112
|
+
width += columnsFor(code);
|
|
113
|
+
}
|
|
114
|
+
return width;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const RESET = '\x1b[0m';
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Cut to `width` columns without ever splitting an escape sequence.
|
|
121
|
+
*
|
|
122
|
+
* Walks the string escape-aware rather than slicing by index, and closes any
|
|
123
|
+
* style that was still open at the cut so the colour does not bleed into the
|
|
124
|
+
* next line.
|
|
125
|
+
*/
|
|
126
|
+
export function truncate(text, width, ellipsis = '…') {
|
|
127
|
+
const source = String(text == null ? '' : text);
|
|
128
|
+
if (width <= 0) return '';
|
|
129
|
+
if (visibleWidth(source) <= width) return source;
|
|
130
|
+
|
|
131
|
+
const ellipsisWidth = visibleWidth(ellipsis);
|
|
132
|
+
const budget = Math.max(0, width - ellipsisWidth);
|
|
133
|
+
|
|
134
|
+
let out = '';
|
|
135
|
+
let used = 0;
|
|
136
|
+
let sawEscape = false;
|
|
137
|
+
let index = 0;
|
|
138
|
+
|
|
139
|
+
while (index < source.length) {
|
|
140
|
+
if (source[index] === '\x1b') {
|
|
141
|
+
// Copy the whole sequence verbatim; it costs no columns.
|
|
142
|
+
const rest = source.slice(index);
|
|
143
|
+
const match = rest.match(/^\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/)
|
|
144
|
+
|| rest.match(/^\x1b\[[0-?]*[ -/]*[@-~]/)
|
|
145
|
+
|| rest.match(/^\x1b[@-Z\\-_]/);
|
|
146
|
+
if (match) {
|
|
147
|
+
out += match[0];
|
|
148
|
+
sawEscape = true;
|
|
149
|
+
index += match[0].length;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const character = String.fromCodePoint(source.codePointAt(index));
|
|
155
|
+
const charWidth = columnsFor(source.codePointAt(index));
|
|
156
|
+
if (used + charWidth > budget) break;
|
|
157
|
+
|
|
158
|
+
out += character;
|
|
159
|
+
used += charWidth;
|
|
160
|
+
index += character.length;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return out + (sawEscape ? RESET : '') + ellipsis;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Pad to an exact column count, measuring visible width rather than bytes. */
|
|
167
|
+
export function padEnd(text, width) {
|
|
168
|
+
const gap = width - visibleWidth(text);
|
|
169
|
+
return gap > 0 ? String(text) + ' '.repeat(gap) : String(text);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
export function wrap(text, width) {
|
|
174
|
+
if (width <= 0) return [String(text)];
|
|
175
|
+
const lines = [];
|
|
176
|
+
|
|
177
|
+
for (const paragraph of String(text == null ? '' : text).split('\n')) {
|
|
178
|
+
if (visibleWidth(paragraph) <= width) {
|
|
179
|
+
lines.push(paragraph);
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
let current = '';
|
|
184
|
+
for (const word of paragraph.split(' ')) {
|
|
185
|
+
const candidate = current ? `${current} ${word}` : word;
|
|
186
|
+
|
|
187
|
+
if (visibleWidth(candidate) <= width) {
|
|
188
|
+
current = candidate;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (current) {
|
|
192
|
+
lines.push(current);
|
|
193
|
+
current = '';
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
let remainder = word;
|
|
197
|
+
while (visibleWidth(remainder) > width) {
|
|
198
|
+
lines.push(truncate(remainder, width, ''));
|
|
199
|
+
// truncate() gives us the head; drop the same number of characters.
|
|
200
|
+
let consumed = 0;
|
|
201
|
+
let cut = 0;
|
|
202
|
+
while (cut < remainder.length && consumed < width) {
|
|
203
|
+
const code = remainder.codePointAt(cut);
|
|
204
|
+
consumed += columnsFor(code);
|
|
205
|
+
cut += String.fromCodePoint(code).length;
|
|
206
|
+
}
|
|
207
|
+
remainder = remainder.slice(cut);
|
|
208
|
+
}
|
|
209
|
+
current = remainder;
|
|
210
|
+
}
|
|
211
|
+
if (current) lines.push(current);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return lines;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Word-wrap a block of text while preserving each line's own indentation.
|
|
219
|
+
*
|
|
220
|
+
* wrap() flattens indentation, which turns a nested list into a wall. This keeps
|
|
221
|
+
* the leading whitespace a line already has and indents its continuation rows by
|
|
222
|
+
* `hang` more, so a wrapped `/skills` entry still reads as one entry.
|
|
223
|
+
*
|
|
224
|
+
* Exists because command output was written to the terminal completely unwrapped
|
|
225
|
+
* — a `/skills` description reached 195 columns on a 102-column terminal and the
|
|
226
|
+
* terminal broke it at the right edge, mid-word, at column 0.
|
|
227
|
+
*/
|
|
228
|
+
export function wrapBlock(text, width, { hang = 2 } = {}) {
|
|
229
|
+
const rows = [];
|
|
230
|
+
for (const line of String(text == null ? '' : text).split('\n')) {
|
|
231
|
+
const indent = (line.match(/^[ \t]*/) || [''])[0];
|
|
232
|
+
const body = line.slice(indent.length);
|
|
233
|
+
if (!body) { rows.push(''); continue; }
|
|
234
|
+
|
|
235
|
+
const continuation = indent + ' '.repeat(hang);
|
|
236
|
+
// Wrap at the NARROWER of the two budgets so neither the first row nor a
|
|
237
|
+
// continuation row can exceed `width`.
|
|
238
|
+
const budget = Math.max(8, width - visibleWidth(continuation));
|
|
239
|
+
const parts = wrap(body, budget);
|
|
240
|
+
rows.push(indent + parts[0]);
|
|
241
|
+
for (const part of parts.slice(1)) rows.push(continuation + part);
|
|
242
|
+
}
|
|
243
|
+
return rows;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Make untrusted text safe to print.
|
|
248
|
+
*
|
|
249
|
+
* SECURITY, not cosmetics. Tool output reaches the terminal directly: a file
|
|
250
|
+
* read by fs_read, or the stdout of sysRun, is attacker-influenced content. If
|
|
251
|
+
* it carries escape sequences it can move the cursor, erase the screen, or
|
|
252
|
+
* overwrite lines that are already printed — including repainting an approval
|
|
253
|
+
* prompt the user just answered. Stripping escapes is what makes "the user saw
|
|
254
|
+
* what happened" true.
|
|
255
|
+
*
|
|
256
|
+
* Newlines and tabs survive; every other control character does not.
|
|
257
|
+
*/
|
|
258
|
+
/** Columns between terminal tab stops. Eight is the POSIX default everywhere. */
|
|
259
|
+
const TAB_WIDTH = 8;
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Replace tabs with the spaces the terminal would have moved over.
|
|
263
|
+
*
|
|
264
|
+
* A TAB MOVES THE CURSOR WITHOUT WRITING ANYTHING. In a live region that
|
|
265
|
+
* redraws in place, that is not a formatting detail — it is corruption. The
|
|
266
|
+
* cursor jumps over cells the previous frame wrote and leaves them on screen,
|
|
267
|
+
* so `sw_vers` output rendered as:
|
|
268
|
+
*
|
|
269
|
+
* ProductName:P_FIX_PLAN.mdmacOS
|
|
270
|
+
* ProductVersion:EGOTIATION26.5.2
|
|
271
|
+
*
|
|
272
|
+
* where `P_FIX_PLAN.md` and `EGOTIATION` are the tails of filenames printed by
|
|
273
|
+
* the PREVIOUS command. Spaces overwrite; tabs do not.
|
|
274
|
+
*
|
|
275
|
+
* It also breaks measurement, which is worse. visibleWidth() counted a tab as
|
|
276
|
+
* one column while the terminal advanced up to eight — on that line the error
|
|
277
|
+
* was ten columns. A row the renderer believes fits therefore wraps, erase()
|
|
278
|
+
* miscounts how many rows to walk back, and it eats real conversation above the
|
|
279
|
+
* prompt. That is the live-region width invariant, broken by one byte.
|
|
280
|
+
*
|
|
281
|
+
* Tab stops are per LINE and reset after every newline, so this tracks the
|
|
282
|
+
* column rather than substituting a fixed run of spaces — which is what keeps
|
|
283
|
+
* columnar output actually lining up.
|
|
284
|
+
*/
|
|
285
|
+
export function expandTabs(text, tabWidth = TAB_WIDTH) {
|
|
286
|
+
const source = String(text == null ? '' : text);
|
|
287
|
+
if (!source.includes('\t')) return source;
|
|
288
|
+
|
|
289
|
+
let out = '';
|
|
290
|
+
let column = 0;
|
|
291
|
+
for (const char of source) {
|
|
292
|
+
if (char === '\t') {
|
|
293
|
+
const advance = tabWidth - (column % tabWidth);
|
|
294
|
+
out += ' '.repeat(advance);
|
|
295
|
+
column += advance;
|
|
296
|
+
} else if (char === '\n') {
|
|
297
|
+
out += char;
|
|
298
|
+
column = 0;
|
|
299
|
+
} else {
|
|
300
|
+
out += char;
|
|
301
|
+
column += 1;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return out;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function sanitize(text) {
|
|
308
|
+
// Tabs are expanded rather than stripped: removing them would collapse
|
|
309
|
+
// columnar output into an unreadable run, and leaving them corrupts the
|
|
310
|
+
// frame. Expansion is the only option that preserves what the user meant to
|
|
311
|
+
// see. Everything else in this class is genuinely unwanted and is removed.
|
|
312
|
+
return expandTabs(stripAnsi(text))
|
|
313
|
+
// eslint-disable-next-line no-control-regex
|
|
314
|
+
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '');
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** `/Users/example/Projects/x` → `~/Projects/x`. Long paths dominate a tool row. */
|
|
318
|
+
export function shortenHome(value) {
|
|
319
|
+
const home = os.homedir();
|
|
320
|
+
const text = String(value == null ? '' : value);
|
|
321
|
+
if (home && text.startsWith(home)) return '~' + text.slice(home.length);
|
|
322
|
+
return text;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
const SECRET_PATTERNS = [
|
|
327
|
+
[/\b(sk|pk|rk)-[A-Za-z0-9_-]{12,}/g, (m) => `${m.slice(0, 5)}…${m.slice(-4)}`],
|
|
328
|
+
[/\b(gh[pousr]|xox[baprs])_[A-Za-z0-9]{12,}/g, (m) => `${m.slice(0, 7)}…`],
|
|
329
|
+
[/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{12,}/gi, (m) => `${m.split(/\s+/)[0]} …`],
|
|
330
|
+
[/\b(api[_-]?key|token|secret|password|passwd)\s*[=:]\s*["']?[^\s"']{6,}/gi,
|
|
331
|
+
(m) => `${m.split(/[=:]/)[0]}=…`]
|
|
332
|
+
];
|
|
333
|
+
|
|
334
|
+
export function redactSecrets(text) {
|
|
335
|
+
let out = String(text == null ? '' : text);
|
|
336
|
+
for (const [pattern, replace] of SECRET_PATTERNS) {
|
|
337
|
+
out = out.replace(pattern, replace);
|
|
338
|
+
}
|
|
339
|
+
return out;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** sanitize + redact — the pipeline every untrusted string goes through. */
|
|
343
|
+
export function safeDisplay(text) {
|
|
344
|
+
return redactSecrets(sanitize(text));
|
|
345
|
+
}
|