@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,953 @@
|
|
|
1
|
+
// Chat transcript renderer.
|
|
2
|
+
//
|
|
3
|
+
// This replaces a 50-line if/else chain inside LocalCLIConnection.send() that
|
|
4
|
+
// wrote raw escape codes inline. Every frame the agent loop emits now has a
|
|
5
|
+
// method here, and every byte that reaches the terminal goes through the theme
|
|
6
|
+
// (so it degrades) and through text.safeDisplay (so untrusted tool output
|
|
7
|
+
// cannot move the cursor or repaint the screen).
|
|
8
|
+
//
|
|
9
|
+
// Pure JS over ./theme.js and ./text.js. No dependency, no shell.
|
|
10
|
+
|
|
11
|
+
import readline from 'readline';
|
|
12
|
+
import {
|
|
13
|
+
paint, bold, dim, badge, GLYPHS, terminalWidth, readableWidth, HAS_COLOR, fileLink,
|
|
14
|
+
safeLink, HAS_HYPERLINKS
|
|
15
|
+
} from './theme.js';
|
|
16
|
+
import {
|
|
17
|
+
visibleWidth, truncate, wrap, wrapBlock, padEnd, safeDisplay, shortenHome
|
|
18
|
+
} from './text.js';
|
|
19
|
+
import { sessionBanner } from './banner.js';
|
|
20
|
+
import { describeTool } from './toolDisplay.js';
|
|
21
|
+
import { approvalDiff } from './diff.js';
|
|
22
|
+
import { StatusLine } from './statusLine.js';
|
|
23
|
+
|
|
24
|
+
// Bound the preview AFTER wrapping, not by source newlines: one 400 KB line of
|
|
25
|
+
// minified output would otherwise become thousands of rows and lock the
|
|
26
|
+
// terminal. (The failure openclaw's tool-execution component documents.)
|
|
27
|
+
const PREVIEW_LINES = 12;
|
|
28
|
+
|
|
29
|
+
// Cut the SOURCE before sanitising it, not after.
|
|
30
|
+
//
|
|
31
|
+
// safeDisplay() runs sanitize() plus four redaction passes over whatever it is
|
|
32
|
+
// given. Handing it a 900 KB tool result meant regex-scanning 900 KB to print
|
|
33
|
+
// twelve lines. openclaw's tool-execution component bounds the source first for
|
|
34
|
+
// exactly this reason; 256 columns per previewed line is far more than any
|
|
35
|
+
// terminal shows and still bounds the work at a few KB.
|
|
36
|
+
const PREVIEW_SOURCE_BUDGET = PREVIEW_LINES * 256;
|
|
37
|
+
|
|
38
|
+
// How much of a model's chain of thought to show inline.
|
|
39
|
+
//
|
|
40
|
+
// Reasoning models emit thousands of tokens of it. Printed in full it buries
|
|
41
|
+
// the answer it was reasoning towards, which is the opposite of useful — and on
|
|
42
|
+
// the Stage it would push the reply off the viewport entirely. The frame keeps
|
|
43
|
+
// the whole thing, so nothing is lost; this is only what is shown at rest.
|
|
44
|
+
const REASONING_LINES = 12;
|
|
45
|
+
|
|
46
|
+
// Column where the timing/status gutter starts, so tool rows line up.
|
|
47
|
+
const STATUS_GUTTER = 10;
|
|
48
|
+
|
|
49
|
+
function formatDuration(ms) {
|
|
50
|
+
if (ms < 1000) return `${ms}ms`;
|
|
51
|
+
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
|
52
|
+
return `${Math.floor(ms / 60000)}m ${Math.round((ms % 60000) / 1000)}s`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Minimal markdown for a terminal.
|
|
57
|
+
*
|
|
58
|
+
* Agent replies are markdown; printing them raw means the user reads literal
|
|
59
|
+
* `**bold**` and ``` fences. This is deliberately small — headings, emphasis,
|
|
60
|
+
* inline code, fenced blocks and list bullets. Anything fancier belongs in a
|
|
61
|
+
* pager, not a chat transcript.
|
|
62
|
+
*/
|
|
63
|
+
function renderMarkdown(text, width) {
|
|
64
|
+
const lines = [];
|
|
65
|
+
let inFence = false;
|
|
66
|
+
|
|
67
|
+
for (const rawLine of String(text).split('\n')) {
|
|
68
|
+
const line = rawLine.replace(/\s+$/, '');
|
|
69
|
+
|
|
70
|
+
if (/^\s*```/.test(line)) {
|
|
71
|
+
inFence = !inFence;
|
|
72
|
+
const language = line.replace(/^\s*```/, '').trim();
|
|
73
|
+
lines.push(dim(`${GLYPHS.hLine.repeat(3)}${language ? ' ' + language + ' ' : ' '}${GLYPHS.hLine.repeat(Math.max(0, width - 6 - language.length))}`));
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (inFence) {
|
|
77
|
+
lines.push(paint('accent', ' ' + truncate(line, width - 2, '')));
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const heading = line.match(/^(#{1,6})\s+(.*)$/);
|
|
82
|
+
if (heading) {
|
|
83
|
+
lines.push('');
|
|
84
|
+
lines.push(bold(paint('ink', heading[2])));
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const bullet = line.match(/^(\s*)[-*+]\s+(.*)$/);
|
|
89
|
+
if (bullet) {
|
|
90
|
+
const indent = bullet[1] + ' ';
|
|
91
|
+
const wrapped = wrap(inline(bullet[2]), width - indent.length - 2);
|
|
92
|
+
wrapped.forEach((part, index) => {
|
|
93
|
+
lines.push(index === 0 ? `${indent}${paint('muted', GLYPHS.bullet)} ${part}` : `${indent} ${part}`);
|
|
94
|
+
});
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (line.trim() === '') { lines.push(''); continue; }
|
|
99
|
+
for (const part of wrap(inline(line), width)) lines.push(part);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// A fence that never closed.
|
|
103
|
+
//
|
|
104
|
+
// `inFence` simply toggles, so an unbalanced ``` used to swallow every
|
|
105
|
+
// remaining line into code styling and end without a closing rule — the block
|
|
106
|
+
// read as still open, forever, with nothing saying so. The model does emit
|
|
107
|
+
// these: a reply cut short by a token limit ends mid-fence, and reasoning
|
|
108
|
+
// text quotes fences it never closes.
|
|
109
|
+
//
|
|
110
|
+
// This is also the invariant a streaming renderer would need, and it is the
|
|
111
|
+
// one opencode's split-footer mode solves by holding an unstable markdown
|
|
112
|
+
// block back until it closes (scrollback.surface.ts:278-307). It cannot hold
|
|
113
|
+
// anything back here, because a frame is re-rendered rather than committed —
|
|
114
|
+
// so it says what happened instead.
|
|
115
|
+
if (inFence) {
|
|
116
|
+
lines.push(dim(`${GLYPHS.hLine.repeat(3)} unclosed code block ${GLYPHS.hLine.repeat(Math.max(0, width - 24))}`));
|
|
117
|
+
}
|
|
118
|
+
return lines;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// A bare URL, allowing one level of balanced parentheses so a link to
|
|
122
|
+
// `.../URL_(disambiguation)` is not cut at the first `)`.
|
|
123
|
+
const BARE_URL = /https?:\/\/[^\s<>()\[\]"'`]+(?:\([^\s<>()"'`]*\)[^\s<>()\[\]"'`]*)*/g;
|
|
124
|
+
const MD_LINK = /\[([^\]\n]{1,80})\]\((https?:\/\/[^\s)]+(?:\([^\s)]*\)[^\s)]*)*)\)/g;
|
|
125
|
+
|
|
126
|
+
// The longest label a link may show. Anything longer would reach wrap()'s
|
|
127
|
+
// hard-split branch, which cuts a long unbroken token by column — and cutting
|
|
128
|
+
// through an OSC 8 sequence leaves the link open across the break.
|
|
129
|
+
const MAX_LINK_LABEL = 48;
|
|
130
|
+
|
|
131
|
+
/** Trailing sentence punctuation belongs to the sentence, not to the URL. */
|
|
132
|
+
function trimUrlPunctuation(url) {
|
|
133
|
+
let depth = 0;
|
|
134
|
+
for (let index = 0; index < url.length; index++) {
|
|
135
|
+
const character = url[index];
|
|
136
|
+
if (character === '(') depth++;
|
|
137
|
+
else if (character === ')') {
|
|
138
|
+
if (depth === 0) return trimUrlPunctuation(url.slice(0, index));
|
|
139
|
+
depth--;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return url.replace(/[?!.,:;*_~]+$/u, '');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** `https://docs.example.com/a/very/long/path?x=1` → `docs.example.com/a/…`. */
|
|
146
|
+
function compactUrlLabel(url) {
|
|
147
|
+
let label = url.replace(/^https?:\/\//, '').replace(/\/$/, '');
|
|
148
|
+
if (label.length > MAX_LINK_LABEL) label = label.slice(0, MAX_LINK_LABEL - 1) + GLYPHS.ellipsis;
|
|
149
|
+
return label;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Inline emphasis, code spans and links.
|
|
154
|
+
*
|
|
155
|
+
* Links are stashed as \u0000N\u0000 placeholders before the emphasis passes run
|
|
156
|
+
* and substituted back afterwards, so an escape sequence can never be matched
|
|
157
|
+
* by the code-span or bold regex. The placeholder is safe because this function
|
|
158
|
+
* only ever sees text that has been through text.safeDisplay(), and sanitize()
|
|
159
|
+
* strips \x00 — so a model cannot forge one.
|
|
160
|
+
*
|
|
161
|
+
* Every URL goes through theme.safeLink(), which re-serialises it from a parsed
|
|
162
|
+
* URL object and refuses anything that is not plain http/https.
|
|
163
|
+
*/
|
|
164
|
+
function inline(text) {
|
|
165
|
+
const stashed = [];
|
|
166
|
+
const stash = (rendered) => `\u0000${stashed.push(rendered) - 1}\u0000`;
|
|
167
|
+
|
|
168
|
+
let out = String(text);
|
|
169
|
+
|
|
170
|
+
if (HAS_HYPERLINKS) {
|
|
171
|
+
out = out.replace(MD_LINK, (whole, label, url) => {
|
|
172
|
+
const target = trimUrlPunctuation(url);
|
|
173
|
+
const shown = label.length > MAX_LINK_LABEL
|
|
174
|
+
? label.slice(0, MAX_LINK_LABEL - 1) + GLYPHS.ellipsis
|
|
175
|
+
: label;
|
|
176
|
+
const rendered = safeLink(paint('accent', shown), target);
|
|
177
|
+
return rendered === paint('accent', shown) ? whole : stash(rendered);
|
|
178
|
+
});
|
|
179
|
+
out = out.replace(BARE_URL, (whole) => {
|
|
180
|
+
const target = trimUrlPunctuation(whole);
|
|
181
|
+
const tail = whole.slice(target.length);
|
|
182
|
+
const rendered = safeLink(paint('accent', compactUrlLabel(target)), target);
|
|
183
|
+
// safeLink returns its input untouched when the URL does not clear the
|
|
184
|
+
// bar; in that case leave the original text exactly as written.
|
|
185
|
+
return rendered.includes('\x1b]8') ? stash(rendered) + tail : whole;
|
|
186
|
+
});
|
|
187
|
+
} else {
|
|
188
|
+
// No OSC 8 here, so a markdown link must still show where it points.
|
|
189
|
+
out = out.replace(MD_LINK, (whole, label, url) => `${label} (${url})`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
out = HAS_COLOR
|
|
193
|
+
? out
|
|
194
|
+
.replace(/`([^`]+)`/g, (m, code) => paint('accent', code))
|
|
195
|
+
.replace(/\*\*([^*]+)\*\*/g, (m, strong) => bold(strong))
|
|
196
|
+
: out.replace(/`([^`]+)`/g, '$1').replace(/\*\*([^*]+)\*\*/g, '$1');
|
|
197
|
+
|
|
198
|
+
if (stashed.length > 0) {
|
|
199
|
+
out = out.replace(/\u0000(\d+)\u0000/g, (m, index) => stashed[Number(index)] || '');
|
|
200
|
+
}
|
|
201
|
+
return out;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export class ChatRenderer {
|
|
205
|
+
constructor(options = {}) {
|
|
206
|
+
this.out = options.out || process.stdout;
|
|
207
|
+
this.isTTY = Boolean(this.out.isTTY);
|
|
208
|
+
this.stepCount = 0;
|
|
209
|
+
this.stepStartedAt = null;
|
|
210
|
+
this.spinnerTimer = null;
|
|
211
|
+
this.spinnerIndex = 0;
|
|
212
|
+
this.currentStatus = '';
|
|
213
|
+
this.pendingApprovals = new Map();
|
|
214
|
+
this.status = new StatusLine();
|
|
215
|
+
this.turnStartedAt = null;
|
|
216
|
+
|
|
217
|
+
// Full-screen mode owns both of these. The shell hands the renderer the
|
|
218
|
+
// width of the transcript viewport (which is not the terminal width — a
|
|
219
|
+
// border and a gutter come off it), and takes the status message for its
|
|
220
|
+
// footer so no spinner is ever animated into the transcript itself.
|
|
221
|
+
this.widthOf = typeof options.width === 'function' ? options.width : null;
|
|
222
|
+
this.quietStatus = Boolean(options.quietStatus);
|
|
223
|
+
this.onStatusChange = options.onStatusChange || null;
|
|
224
|
+
|
|
225
|
+
// Stage mode. When a Transcript is attached the renderer stops being a
|
|
226
|
+
// formatter into stdout and becomes a producer of re-renderable frames:
|
|
227
|
+
// rows go into the model, and the Stage decides which of them are on
|
|
228
|
+
// screen. Null keeps every byte on the path it has always taken.
|
|
229
|
+
this.transcript = options.transcript || null;
|
|
230
|
+
// Set only while a frame is being produced. write() collects into it
|
|
231
|
+
// instead of reaching the terminal.
|
|
232
|
+
this.rowSink = null;
|
|
233
|
+
// Set only while a frame is being RE-produced at a width that is not the
|
|
234
|
+
// terminal's — a reflow after a resize, or a measurement in a test.
|
|
235
|
+
this.forcedWidth = 0;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
write(line = '') {
|
|
239
|
+
// Producing a frame: the rows belong to the model, not to the terminal.
|
|
240
|
+
if (this.rowSink) { this.rowSink.push(line); return; }
|
|
241
|
+
|
|
242
|
+
// Stage mode: a direct write is a caller that formatted its own rows —
|
|
243
|
+
// /report, /mcp, the home status card. It cannot be allowed to reach stdout,
|
|
244
|
+
// because the band occupies the bottom of the screen and a bare write lands
|
|
245
|
+
// in the middle of it. Wrapped as a frame, it scrolls with everything else.
|
|
246
|
+
//
|
|
247
|
+
// The text is captured, not re-derived, so this is a pure producer: these
|
|
248
|
+
// callers have already committed to a width and cannot be asked for another.
|
|
249
|
+
if (this.transcript) {
|
|
250
|
+
const text = String(line);
|
|
251
|
+
this.transcript.append({ kind: 'notice', produce: () => text.split('\n') });
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// The prompt occupies the bottom rows of the terminal. It has to lift them
|
|
256
|
+
// out of the way before the transcript prints and put them back after, or
|
|
257
|
+
// the two writers interleave and leave debris on screen. Nothing is
|
|
258
|
+
// attached in non-interactive mode, where both hooks stay undefined.
|
|
259
|
+
if (this.onBeforeWrite) this.onBeforeWrite();
|
|
260
|
+
this.out.write(line + '\n');
|
|
261
|
+
if (this.onAfterWrite) this.onAfterWrite();
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Record one transcript frame, or print it — whichever mode we are in.
|
|
266
|
+
*
|
|
267
|
+
* `produce` must be a PURE function of width. Every value that depends on
|
|
268
|
+
* wall-clock time or on renderer state (the step number, an elapsed
|
|
269
|
+
* duration, the model name at the time) has to be resolved by the caller
|
|
270
|
+
* before the closure is built, because the closure is called again on every
|
|
271
|
+
* resize. That is why the handlers below mutate first and render second: it
|
|
272
|
+
* is not style, it is the contract the Transcript rests on.
|
|
273
|
+
*
|
|
274
|
+
* In scrollback mode this is `produce()` and nothing else, so the byte
|
|
275
|
+
* stream is identical to what it has always been.
|
|
276
|
+
*/
|
|
277
|
+
frame(kind, produce, meta = null) {
|
|
278
|
+
if (!this.transcript) { produce(); return; }
|
|
279
|
+
this.transcript.append({
|
|
280
|
+
kind,
|
|
281
|
+
meta,
|
|
282
|
+
produce: (width) => this.capture(width, produce)
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Run a producer with write() redirected and the width pinned.
|
|
288
|
+
*/
|
|
289
|
+
capture(width, produce) {
|
|
290
|
+
const sink = [];
|
|
291
|
+
const previousSink = this.rowSink;
|
|
292
|
+
const previousWidth = this.forcedWidth;
|
|
293
|
+
this.rowSink = sink;
|
|
294
|
+
this.forcedWidth = width;
|
|
295
|
+
try {
|
|
296
|
+
produce();
|
|
297
|
+
} finally {
|
|
298
|
+
this.rowSink = previousSink;
|
|
299
|
+
this.forcedWidth = previousWidth;
|
|
300
|
+
}
|
|
301
|
+
return sink;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Columns available on the stream this renderer actually writes to.
|
|
306
|
+
*
|
|
307
|
+
* This used to fall straight through to terminalWidth(), which reads the
|
|
308
|
+
* global process.stdout.columns. That is right in production — `out` IS
|
|
309
|
+
* stdout — but it means the renderer cannot be measured against anything
|
|
310
|
+
* else, and every row it produces is sized from the wrong width the moment it
|
|
311
|
+
* is pointed at another stream. ChatPrompt.width() already reasons this way
|
|
312
|
+
* for exactly the same reason; the two now agree.
|
|
313
|
+
*/
|
|
314
|
+
width() {
|
|
315
|
+
// A reflow asks "what would this frame look like at 96 columns" about rows
|
|
316
|
+
// that were produced when the terminal was 200 wide. The pinned width has
|
|
317
|
+
// to win over every other source, or a resize re-renders at the old width
|
|
318
|
+
// and the viewport silently misreports its own height.
|
|
319
|
+
if (this.forcedWidth) return Math.max(40, this.forcedWidth);
|
|
320
|
+
if (this.widthOf) return this.widthOf();
|
|
321
|
+
const columns = this.out && this.out.columns;
|
|
322
|
+
return Number.isFinite(columns) && columns > 0 ? Math.max(40, columns) : terminalWidth();
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* The width PROSE is wrapped to — narrower than the terminal on a wide one.
|
|
327
|
+
*
|
|
328
|
+
* Chrome fills the window; paragraphs must not. See readableWidth() in
|
|
329
|
+
* theme.js for why, and note the two are deliberately different so that
|
|
330
|
+
* stretching the window widens the box without turning the agent's replies
|
|
331
|
+
* into 200-character lines.
|
|
332
|
+
*/
|
|
333
|
+
measure() {
|
|
334
|
+
return readableWidth(this.width());
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// -------------------------------------------------------------------------
|
|
338
|
+
// Session framing
|
|
339
|
+
// -------------------------------------------------------------------------
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* The context the CLI never showed. Which model is about to spend the user's
|
|
343
|
+
* money, in which autonomy mode, over which paths.
|
|
344
|
+
*/
|
|
345
|
+
/**
|
|
346
|
+
* Returns the width of the widest row it wrote.
|
|
347
|
+
*
|
|
348
|
+
* The Stage lays its opening composition out to this, so the input box below
|
|
349
|
+
* the banner ends where the banner's text ends instead of stretching past it.
|
|
350
|
+
* MEASURED rather than guessed, because the banner is adaptive: it drops hints
|
|
351
|
+
* and truncates the workspace list to fit whatever width it is given, so its
|
|
352
|
+
* natural width depends on the user's own paths and cannot be a constant. At
|
|
353
|
+
* 156 columns it comes out at 122 — and composing it AT 117 already hides
|
|
354
|
+
* ~/Causyne, which is why the banner is never the thing that gets narrowed.
|
|
355
|
+
*/
|
|
356
|
+
sessionHeader(info = {}) {
|
|
357
|
+
// The status line shows the same facts during the turn, where the header
|
|
358
|
+
// has already scrolled away. Mutation first: the closure below runs again
|
|
359
|
+
// on every reflow and must not re-apply it.
|
|
360
|
+
this.status.setSession(info);
|
|
361
|
+
|
|
362
|
+
// Tap the rows on their way past, without disturbing either sink.
|
|
363
|
+
const seen = [];
|
|
364
|
+
const write = this.write.bind(this);
|
|
365
|
+
this.write = (line = '') => { seen.push(line); return write(line); };
|
|
366
|
+
try {
|
|
367
|
+
this.frame('session', () => this.renderSessionHeader(info));
|
|
368
|
+
} finally {
|
|
369
|
+
this.write = write;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
let widest = 0;
|
|
373
|
+
for (const line of seen) widest = Math.max(widest, visibleWidth(String(line)));
|
|
374
|
+
return widest;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
renderSessionHeader(info = {}) {
|
|
378
|
+
const width = this.width();
|
|
379
|
+
|
|
380
|
+
// ONE block: the wordmark with the session facts set beside it, then one
|
|
381
|
+
// hint row. This used to be the art, a blank, a bordered panel of four
|
|
382
|
+
// label/value rows, a command count, a blank and a separate key-hint row —
|
|
383
|
+
// nineteen rows of chrome before the user could type. Both references open
|
|
384
|
+
// in eight or fewer, with the identity and the facts as a single object.
|
|
385
|
+
this.write();
|
|
386
|
+
for (const row of sessionBanner(info, width)) this.write(row);
|
|
387
|
+
this.write();
|
|
388
|
+
|
|
389
|
+
const hints = [
|
|
390
|
+
`${info.commandCount || 0} commands`, '/help', 'enter send',
|
|
391
|
+
'alt+enter newline', 'ctrl+p palette', 'esc interrupt', 'exit to leave'
|
|
392
|
+
];
|
|
393
|
+
const separator = ` ${GLYPHS.dot} `;
|
|
394
|
+
const kept = hints.slice();
|
|
395
|
+
while (kept.length > 1 && visibleWidth(kept.join(separator)) > width - 1) kept.pop();
|
|
396
|
+
this.write(dim(kept.join(separator)));
|
|
397
|
+
this.write();
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Closing line for one turn: who answered, on what, in how long.
|
|
402
|
+
*
|
|
403
|
+
* The reference closes each exchange with `▪ Build · MiMo V2.5 Free · 5.8s`
|
|
404
|
+
* (screenshot 15.02.21) — a filled marker, the agent, the model, the elapsed
|
|
405
|
+
* time. It is the seam that says "this exchange is finished", which a list of
|
|
406
|
+
* four dim hints never did.
|
|
407
|
+
*/
|
|
408
|
+
turnFooter(durationMs) {
|
|
409
|
+
// Resolved now, not inside the closure: by the time a resize re-renders
|
|
410
|
+
// this frame the step counter has been reset and the model may have been
|
|
411
|
+
// switched, and a turn footer that reports the CURRENT state instead of
|
|
412
|
+
// the state it closed is a lie that only appears after a resize.
|
|
413
|
+
const model = this.status.model;
|
|
414
|
+
const steps = this.stepCount;
|
|
415
|
+
|
|
416
|
+
this.frame('turn', () => {
|
|
417
|
+
const parts = [];
|
|
418
|
+
if (model) parts.push(paint('ink', model));
|
|
419
|
+
parts.push(`${steps} step${steps === 1 ? '' : 's'}`);
|
|
420
|
+
parts.push(formatDuration(durationMs));
|
|
421
|
+
|
|
422
|
+
const separator = dim(` ${GLYPHS.dot} `);
|
|
423
|
+
this.write();
|
|
424
|
+
this.write(`${paint('accent', GLYPHS.step)} ${dim(parts.join(separator))}`);
|
|
425
|
+
this.write(dim(` /usage for cost ${GLYPHS.dot} /undo to roll back`));
|
|
426
|
+
this.write();
|
|
427
|
+
}, { steps, durationMs });
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
resetTurn() {
|
|
431
|
+
this.stepCount = 0;
|
|
432
|
+
this.stepStartedAt = null;
|
|
433
|
+
this.turnStartedAt = Date.now();
|
|
434
|
+
this.status.reset();
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// -------------------------------------------------------------------------
|
|
438
|
+
// Spinner — only where it can be erased again
|
|
439
|
+
// -------------------------------------------------------------------------
|
|
440
|
+
|
|
441
|
+
startSpinner(message) {
|
|
442
|
+
this.stopSpinner();
|
|
443
|
+
this.currentStatus = safeDisplay(message);
|
|
444
|
+
|
|
445
|
+
// In full-screen mode the status belongs to the footer, which repaints on
|
|
446
|
+
// the shell's own frame clock. Animating here too would write a spinner
|
|
447
|
+
// into the scrollback of a transcript that is already being repainted.
|
|
448
|
+
if (this.quietStatus) {
|
|
449
|
+
if (this.onStatusChange) this.onStatusChange(this.currentStatus);
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// A spinner needs cursor control. Piped into a file or a CI log there is no
|
|
454
|
+
// cursor, and the animation becomes thousands of junk lines — so print the
|
|
455
|
+
// status once instead.
|
|
456
|
+
if (!this.isTTY) {
|
|
457
|
+
this.write(paint('muted', this.status.renderStatic(this.currentStatus, this.width())));
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Elapsed is measured from the START OF THE TURN, not from this spinner.
|
|
462
|
+
// A forty-step run restarts the spinner on every frame, so a per-spinner
|
|
463
|
+
// clock would reset to 0.0s each time and never show the real wait.
|
|
464
|
+
const since = this.turnStartedAt || Date.now();
|
|
465
|
+
|
|
466
|
+
this.spinnerTimer = setInterval(() => {
|
|
467
|
+
readline.cursorTo(this.out, 0);
|
|
468
|
+
const frame = GLYPHS.spinner[this.spinnerIndex % GLYPHS.spinner.length];
|
|
469
|
+
this.out.write(this.status.render({
|
|
470
|
+
message: this.currentStatus,
|
|
471
|
+
elapsedMs: Date.now() - since,
|
|
472
|
+
steps: this.stepCount,
|
|
473
|
+
width: this.width() - 1,
|
|
474
|
+
spinnerFrame: frame
|
|
475
|
+
}));
|
|
476
|
+
readline.clearLine(this.out, 1);
|
|
477
|
+
this.spinnerIndex++;
|
|
478
|
+
}, 90);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
stopSpinner() {
|
|
482
|
+
if (this.quietStatus) {
|
|
483
|
+
this.currentStatus = '';
|
|
484
|
+
if (this.onStatusChange) this.onStatusChange('');
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
if (!this.spinnerTimer) return;
|
|
488
|
+
clearInterval(this.spinnerTimer);
|
|
489
|
+
this.spinnerTimer = null;
|
|
490
|
+
if (this.isTTY) {
|
|
491
|
+
readline.clearLine(this.out, 0);
|
|
492
|
+
readline.cursorTo(this.out, 0);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// -------------------------------------------------------------------------
|
|
497
|
+
// Frames
|
|
498
|
+
// -------------------------------------------------------------------------
|
|
499
|
+
|
|
500
|
+
onAgentMessage(msg) {
|
|
501
|
+
const content = safeDisplay(msg.content || '');
|
|
502
|
+
this.frame('assistant', () => {
|
|
503
|
+
// Prose, not chrome: wrapped to a readable measure even on a wide terminal.
|
|
504
|
+
const width = this.measure();
|
|
505
|
+
this.write();
|
|
506
|
+
this.write(`${paint('agent', GLYPHS.arrow)} ${bold(paint('agent', 'Causyne'))}`);
|
|
507
|
+
for (const line of renderMarkdown(content, width - 2)) {
|
|
508
|
+
this.write(line ? ' ' + line : '');
|
|
509
|
+
}
|
|
510
|
+
this.write();
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* The chain of thought the user paid for.
|
|
516
|
+
*
|
|
517
|
+
* This frame was emitted by the loop (agentLoop.js:1113) and handled by
|
|
518
|
+
* NOBODY — not here, not in the Android app. openai-compatible.js was fixed
|
|
519
|
+
* to stop discarding `reasoning_content` because "the reasoning the user paid
|
|
520
|
+
* for never reached them", and then it was discarded one layer further down.
|
|
521
|
+
*
|
|
522
|
+
* It is rendered subordinate to the answer on purpose: a dim rule and a cap.
|
|
523
|
+
* Reasoning that shouts as loudly as the reply trains the user to read
|
|
524
|
+
* neither, and the reply is the part they asked for.
|
|
525
|
+
*/
|
|
526
|
+
onAgentReasoning(msg) {
|
|
527
|
+
const content = safeDisplay(msg.content || '');
|
|
528
|
+
if (!content.trim()) return;
|
|
529
|
+
this.frame('reasoning', () => this.renderReasoning(content), { reasoning: true });
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
renderReasoning(content) {
|
|
533
|
+
const width = this.measure();
|
|
534
|
+
const rows = wrapBlock(content, Math.max(20, width - 4));
|
|
535
|
+
|
|
536
|
+
this.write();
|
|
537
|
+
this.write(`${paint('muted', GLYPHS.vLine)} ${dim('thinking')}`);
|
|
538
|
+
for (const row of rows.slice(0, REASONING_LINES)) {
|
|
539
|
+
this.write(`${paint('rule', GLYPHS.vLine)} ${dim(row)}`);
|
|
540
|
+
}
|
|
541
|
+
if (rows.length > REASONING_LINES) {
|
|
542
|
+
const hidden = rows.length - REASONING_LINES;
|
|
543
|
+
this.write(`${paint('rule', GLYPHS.corner)} ${dim(`${GLYPHS.ellipsis} ${hidden} more line${hidden === 1 ? '' : 's'} of reasoning`)}`);
|
|
544
|
+
}
|
|
545
|
+
this.write();
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
onAgentPlan(msg) {
|
|
549
|
+
if (!Array.isArray(msg.plan) || msg.plan.length === 0) return;
|
|
550
|
+
const plan = msg.plan.slice();
|
|
551
|
+
this.frame('plan', () => this.renderPlan(plan), { items: plan.length });
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
renderPlan(plan) {
|
|
555
|
+
const width = this.width();
|
|
556
|
+
this.write();
|
|
557
|
+
this.write(paint('muted', 'plan'));
|
|
558
|
+
for (const item of plan) {
|
|
559
|
+
const done = item.status === 'completed';
|
|
560
|
+
const active = item.status === 'in_progress';
|
|
561
|
+
const mark = done ? paint('success', GLYPHS.check)
|
|
562
|
+
: active ? paint('accent', GLYPHS.arrow)
|
|
563
|
+
: paint('muted', GLYPHS.dot);
|
|
564
|
+
const label = done ? dim(safeDisplay(item.text)) : paint('ink', safeDisplay(item.text));
|
|
565
|
+
this.write(` ${mark} ${truncate(label, width - 5, GLYPHS.ellipsis)}`);
|
|
566
|
+
}
|
|
567
|
+
this.write();
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
onStepStart(msg) {
|
|
571
|
+
this.stepStartedAt = Date.now();
|
|
572
|
+
this.stepCount++;
|
|
573
|
+
|
|
574
|
+
const { verb, detail } = describeTool(msg.tool, msg.args);
|
|
575
|
+
const step = this.stepCount;
|
|
576
|
+
|
|
577
|
+
this.frame('step', () => {
|
|
578
|
+
const width = this.width();
|
|
579
|
+
const index = paint('muted', String(step).padStart(2, '0'));
|
|
580
|
+
const name = paint('accent', padEnd(verb || msg.tool, STATUS_GUTTER));
|
|
581
|
+
|
|
582
|
+
const used = 2 + 2 + 1 + Math.max(visibleWidth(verb || msg.tool), STATUS_GUTTER) + 1;
|
|
583
|
+
const detailText = detail ? paint('ink', truncate(detail, Math.max(10, width - used - 12), GLYPHS.ellipsis)) : '';
|
|
584
|
+
|
|
585
|
+
this.write(`${paint('muted', GLYPHS.arrow)} ${index} ${name} ${detailText}`);
|
|
586
|
+
}, { step, tool: msg.tool });
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
onStepComplete(msg) {
|
|
590
|
+
const elapsed = this.stepStartedAt ? formatDuration(Date.now() - this.stepStartedAt) : '';
|
|
591
|
+
const result = msg.result || {};
|
|
592
|
+
|
|
593
|
+
// Kept so Ctrl+O can show the whole thing, and set here rather than inside
|
|
594
|
+
// the closure — a reflow must not re-point "the last output" at a tool
|
|
595
|
+
// result the user finished with ten turns ago.
|
|
596
|
+
this.captureLastOutput(result);
|
|
597
|
+
|
|
598
|
+
this.frame('result', () => {
|
|
599
|
+
if (result.success) {
|
|
600
|
+
this.write(` ${paint('success', GLYPHS.check)} ${dim(elapsed)}`);
|
|
601
|
+
this.renderOutputPreview(result);
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
this.write(` ${paint('danger', GLYPHS.cross)} ${paint('danger', truncate(safeDisplay(result.error || 'failed'), this.width() - 12, GLYPHS.ellipsis))} ${dim(elapsed)}`);
|
|
605
|
+
}, { success: Boolean(result.success) });
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* A bounded look at what the tool actually returned.
|
|
610
|
+
*
|
|
611
|
+
* The old renderer printed only a tick, so the user never saw any tool output
|
|
612
|
+
* at all — they were approving and trusting blind.
|
|
613
|
+
*/
|
|
614
|
+
/**
|
|
615
|
+
* Print the last tool output in full — bound to Ctrl+O.
|
|
616
|
+
*
|
|
617
|
+
* The preview is deliberately twelve lines, which is right until it is not.
|
|
618
|
+
* Before this the cut was permanent and the only recovery was re-running the
|
|
619
|
+
* tool, which costs tokens to see something already fetched.
|
|
620
|
+
*/
|
|
621
|
+
expandLastOutput() {
|
|
622
|
+
if (!this.lastOutput || !this.lastOutput.text) {
|
|
623
|
+
this.frame('notice', () => this.write(dim(' nothing to expand')));
|
|
624
|
+
return false;
|
|
625
|
+
}
|
|
626
|
+
// Bound to the output as it is NOW. Ctrl+O twice, ten turns apart, must
|
|
627
|
+
// leave two frames showing two different outputs, not one shown twice.
|
|
628
|
+
const output = this.lastOutput;
|
|
629
|
+
this.frame('result', () => {
|
|
630
|
+
const width = this.width() - 7;
|
|
631
|
+
this.write('');
|
|
632
|
+
for (const line of String(output.text).split('\n')) {
|
|
633
|
+
for (const part of wrap(safeDisplay(line), width)) {
|
|
634
|
+
this.write(` ${paint('rule', GLYPHS.vLine)} ${dim(part)}`);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
if (output.spillPath) {
|
|
638
|
+
this.write(this.clipHint(output.spillPath));
|
|
639
|
+
}
|
|
640
|
+
this.write('');
|
|
641
|
+
}, { expanded: true });
|
|
642
|
+
return true;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* Remember the payload Ctrl+O expands. Separate from rendering it, because
|
|
647
|
+
* rendering runs again on every reflow and this must not.
|
|
648
|
+
*/
|
|
649
|
+
captureLastOutput(result = {}) {
|
|
650
|
+
const payload = typeof result.output === 'string' ? result.output
|
|
651
|
+
: typeof result.log === 'string' ? result.log
|
|
652
|
+
: '';
|
|
653
|
+
if (!payload) return;
|
|
654
|
+
this.lastOutput = { text: payload, spillPath: result.spillPath || null };
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
renderOutputPreview(result) {
|
|
658
|
+
const payload = typeof result.output === 'string' ? result.output
|
|
659
|
+
: typeof result.log === 'string' ? result.log
|
|
660
|
+
: '';
|
|
661
|
+
if (!payload) return;
|
|
662
|
+
|
|
663
|
+
const source = payload.length > PREVIEW_SOURCE_BUDGET
|
|
664
|
+
? payload.slice(0, PREVIEW_SOURCE_BUDGET)
|
|
665
|
+
: payload;
|
|
666
|
+
if (!source.trim()) return;
|
|
667
|
+
|
|
668
|
+
const width = this.width() - 7;
|
|
669
|
+
const cleaned = safeDisplay(source).replace(/\n{3,}/g, '\n\n').trim();
|
|
670
|
+
|
|
671
|
+
// Wrap first, THEN cut — bounding by source newlines lets a single long
|
|
672
|
+
// line through as thousands of wrapped rows.
|
|
673
|
+
const wrapped = [];
|
|
674
|
+
for (const line of cleaned.split('\n')) {
|
|
675
|
+
for (const part of wrap(line, width)) {
|
|
676
|
+
wrapped.push(part);
|
|
677
|
+
if (wrapped.length > PREVIEW_LINES + 1) break;
|
|
678
|
+
}
|
|
679
|
+
if (wrapped.length > PREVIEW_LINES + 1) break;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
for (const line of wrapped.slice(0, PREVIEW_LINES)) {
|
|
683
|
+
this.write(` ${paint('rule', GLYPHS.vLine)} ${dim(line)}`);
|
|
684
|
+
}
|
|
685
|
+
const clipped = wrapped.length > PREVIEW_LINES || source.length < payload.length;
|
|
686
|
+
if (clipped) this.write(this.clipHint(result.spillPath));
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* The "there is more than this" footer, fitted to the terminal.
|
|
691
|
+
*
|
|
692
|
+
* It was a fixed string. At 40 columns it came back six columns too wide and
|
|
693
|
+
* wrapped — which in scrollback is merely ugly, and inside the Stage's band is
|
|
694
|
+
* the exact failure the width clamp exists to prevent. Found by sweeping the
|
|
695
|
+
* clamp at stage geometry, not by anyone looking at a narrow terminal.
|
|
696
|
+
*
|
|
697
|
+
* A path is shortened BEFORE it is made into a link, never after: truncate()
|
|
698
|
+
* is escape-aware, but cutting a finished OSC 8 sequence in half leaves the
|
|
699
|
+
* hyperlink open across the break.
|
|
700
|
+
*/
|
|
701
|
+
clipHint(spillPath) {
|
|
702
|
+
const corner = paint('rule', GLYPHS.corner);
|
|
703
|
+
// Five spaces of gutter, the corner, and the space after it.
|
|
704
|
+
const room = Math.max(12, this.width() - 8);
|
|
705
|
+
|
|
706
|
+
if (spillPath) {
|
|
707
|
+
const label = truncate(shortenHome(spillPath), Math.max(8, room - 13), GLYPHS.ellipsis);
|
|
708
|
+
return ` ${corner} ${dim(`full output: ${fileLink(label, spillPath)}`)}`;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// Say the shorter true thing rather than the longer half-cut one.
|
|
712
|
+
const full = `${GLYPHS.ellipsis} output truncated — ctrl+o to expand`;
|
|
713
|
+
const short = `${GLYPHS.ellipsis} truncated — ctrl+o`;
|
|
714
|
+
const hint = visibleWidth(full) <= room ? full
|
|
715
|
+
: visibleWidth(short) <= room ? short
|
|
716
|
+
: truncate(full, room, GLYPHS.ellipsis);
|
|
717
|
+
return ` ${corner} ${dim(hint)}`;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* A restore point was taken before a tool changed a file.
|
|
722
|
+
*
|
|
723
|
+
* Emitted between the step row and its result, and indented to the result
|
|
724
|
+
* gutter so it reads as part of that step rather than as a new event. Quiet
|
|
725
|
+
* on purpose: it happens on every mutating call, and an aside that shouts as
|
|
726
|
+
* loudly as the work trains the user to read neither.
|
|
727
|
+
*/
|
|
728
|
+
onCheckpoint(msg) {
|
|
729
|
+
const where = typeof msg.path === 'string' ? shortenHome(msg.path) : '';
|
|
730
|
+
const count = Number(msg.count) || 1;
|
|
731
|
+
this.frame('checkpoint', () => {
|
|
732
|
+
const label = where
|
|
733
|
+
? `restore point ${GLYPHS.dot} ${where}`
|
|
734
|
+
: `restore point ${GLYPHS.dot} ${count} file${count === 1 ? '' : 's'}`;
|
|
735
|
+
this.write(` ${paint('muted', GLYPHS.dot)} ${dim(truncate(label, Math.max(10, this.width() - 8), GLYPHS.ellipsis))}`);
|
|
736
|
+
}, { tool: msg.tool, path: msg.path || null, step: msg.step ?? null, taskId: msg.taskId || null });
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
onStepError(msg) {
|
|
740
|
+
// Wrapped and hanging-indented, not written as one long string.
|
|
741
|
+
//
|
|
742
|
+
// Loop-level failures now carry a real explanation across several lines —
|
|
743
|
+
// what could not be reached, why, and what to check. Emitting that as a
|
|
744
|
+
// single write put the continuation lines at column 0, under a marker
|
|
745
|
+
// indented five columns, so the whole block read as unrelated fragments.
|
|
746
|
+
const label = `${msg.tool || ''}`.trim();
|
|
747
|
+
const detail = safeDisplay(`${msg.error || ''}`.trim());
|
|
748
|
+
const head = label ? `${label} ${detail}` : detail;
|
|
749
|
+
|
|
750
|
+
this.frame('error', () => {
|
|
751
|
+
const rows = wrapBlock(head, Math.max(20, this.width() - 7));
|
|
752
|
+
this.write(` ${paint('danger', GLYPHS.cross)} ${paint('danger', rows[0] || '')}`);
|
|
753
|
+
for (const row of rows.slice(1)) this.write(` ${dim(row)}`);
|
|
754
|
+
}, { tool: msg.tool });
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* Keep the approval detail so the prompt can name what is being approved.
|
|
759
|
+
* L6 in the local-path audit: a bare "Proceed?" is not human-in-the-loop.
|
|
760
|
+
*/
|
|
761
|
+
onApprovalRequest(msg) {
|
|
762
|
+
this.pendingApprovals.set(msg.toolCallId, msg);
|
|
763
|
+
|
|
764
|
+
// When an interactive dialog will ask the question, DO NOT also print the
|
|
765
|
+
// whole request into the transcript. It shipped doing both: the same risk
|
|
766
|
+
// badge, command and diff appeared once in the scrollback and again inside
|
|
767
|
+
// the modal, so the user read the same alarming block twice and the panel
|
|
768
|
+
// looked like a duplicate of a thing that had already happened.
|
|
769
|
+
//
|
|
770
|
+
// The transcript still gets a record — see renderApprovalDecision(), which
|
|
771
|
+
// writes ONE line after the user answers. That is the audit trail; the
|
|
772
|
+
// panel is the question. Printing the question twice is not an audit trail.
|
|
773
|
+
if (this.dialogWillAsk) return;
|
|
774
|
+
|
|
775
|
+
// The diff is resolved ONCE, here, and closed over.
|
|
776
|
+
//
|
|
777
|
+
// approvalDiff() reads the file from disk. If it ran from inside the
|
|
778
|
+
// producer, a resize would re-read the file — and by then the write has
|
|
779
|
+
// been approved and applied, so the transcript would show the user a diff
|
|
780
|
+
// of the change against itself. A record of what someone consented to may
|
|
781
|
+
// not be re-derived from the world after they consented.
|
|
782
|
+
const diff = approvalDiff(msg.tool, msg.args, { width: this.width() });
|
|
783
|
+
|
|
784
|
+
this.frame('approval', () => this.renderApprovalRequest(msg, diff), {
|
|
785
|
+
tool: msg.tool,
|
|
786
|
+
risk: String(msg.risk || 'unknown').toUpperCase()
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
renderApprovalRequest(msg, diff = undefined) {
|
|
791
|
+
// Colour carries the tier; the word never reaches the user. See the note
|
|
792
|
+
// in dialogs.js ApprovalLayer — 'DANGEROUS' printed at somebody installing a
|
|
793
|
+
// package teaches them to stop reading these.
|
|
794
|
+
const risk = String(msg.risk || 'unknown').toUpperCase();
|
|
795
|
+
const role = risk === 'EXTREME' ? 'danger' : risk === 'DANGEROUS' ? 'warn' : 'accent';
|
|
796
|
+
const { verb, detail } = describeTool(msg.tool, msg.args);
|
|
797
|
+
|
|
798
|
+
this.write();
|
|
799
|
+
this.write(`${paint(role, GLYPHS.warnMark)} ${paint('ink', 'permission required')} ${bold(safeDisplay(msg.tool || ''))}${verb ? dim(` ${verb}`) : ''}`);
|
|
800
|
+
if (detail) {
|
|
801
|
+
this.write(` ${paint('ink', truncate(detail, this.width() - 4, GLYPHS.ellipsis))}`);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// What the user is actually consenting to. Without this the approval gate
|
|
805
|
+
// asks a human to authorise a file mutation they have never seen — which is
|
|
806
|
+
// consent in form only. null for every tool that does not change a file.
|
|
807
|
+
//
|
|
808
|
+
// Resolved by the caller so it is computed once, at the moment consent was
|
|
809
|
+
// asked for; a direct caller that passes nothing still gets the live read.
|
|
810
|
+
if (diff === undefined) diff = approvalDiff(msg.tool, msg.args, { width: this.width() });
|
|
811
|
+
if (diff) {
|
|
812
|
+
const stat = [
|
|
813
|
+
diff.added ? paint('success', `+${diff.added}`) : '',
|
|
814
|
+
diff.removed ? paint('danger', `-${diff.removed}`) : ''
|
|
815
|
+
].filter(Boolean).join(' ');
|
|
816
|
+
const note = diff.note ? dim(` ${GLYPHS.dot} ${diff.note}`) : '';
|
|
817
|
+
// The detail row above already names the file; repeating it here just
|
|
818
|
+
// prints the same path twice. Only fall back to the diff's own path when
|
|
819
|
+
// the tool had no readable detail to show.
|
|
820
|
+
const where = detail ? '' : `${dim(diff.path)} `;
|
|
821
|
+
this.write(` ${where}${stat}${note}`);
|
|
822
|
+
this.write();
|
|
823
|
+
for (const row of diff.rows) this.write(row);
|
|
824
|
+
if (diff.clipped) {
|
|
825
|
+
this.write(` ${paint('rule', GLYPHS.corner)} ${dim(`${GLYPHS.ellipsis} diff truncated`)}`);
|
|
826
|
+
}
|
|
827
|
+
this.write();
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* One line recording what the user actually decided.
|
|
833
|
+
*
|
|
834
|
+
* Written AFTER the answer, so the scrollback reads as history rather than
|
|
835
|
+
* as a second copy of the prompt. Only used when a dialog asked the
|
|
836
|
+
* question; the non-interactive path still prints the full block, because
|
|
837
|
+
* there the scrollback IS the only place the request was ever shown.
|
|
838
|
+
*/
|
|
839
|
+
renderApprovalDecision(msg, approved, scope = 'once') {
|
|
840
|
+
// A standing grant must be VISIBLE in the transcript. Someone who lifts the
|
|
841
|
+
// gate for a whole session should be able to scroll back and see that they
|
|
842
|
+
// did, and `/approvals` tells them how to take it back.
|
|
843
|
+
const verdict = !approved
|
|
844
|
+
? paint('danger', 'rejected')
|
|
845
|
+
: scope === 'always'
|
|
846
|
+
? paint('warn', 'allowed always')
|
|
847
|
+
: paint('success', 'allowed once');
|
|
848
|
+
const { verb } = describeTool(msg.tool, msg.args);
|
|
849
|
+
this.frame('approval', () => {
|
|
850
|
+
this.write(` ${verdict} ${bold(safeDisplay(msg.tool || ''))}${verb ? dim(` ${verb}`) : ''}`);
|
|
851
|
+
}, { tool: msg.tool, approved: Boolean(approved), scope });
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
onError(msg) {
|
|
855
|
+
const message = safeDisplay(msg.message || '');
|
|
856
|
+
this.frame('error', () => {
|
|
857
|
+
this.write();
|
|
858
|
+
this.write(`${badge('danger', 'ERROR')} ${paint('danger', message)}`);
|
|
859
|
+
this.write();
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
onBudgetWarning(msg) {
|
|
864
|
+
const message = safeDisplay(msg.message || '');
|
|
865
|
+
this.frame('notice', () => {
|
|
866
|
+
this.write(`${badge('warn', 'BUDGET')} ${paint('warn', message)}`);
|
|
867
|
+
}, { tone: 'warn' });
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
onStatus(msg) {
|
|
871
|
+
this.startSpinner(msg.message || '');
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/**
|
|
875
|
+
* A skill was invoked — say so in one line, do not print the skill.
|
|
876
|
+
*
|
|
877
|
+
* The reported behaviour: invoking a skill dumped the whole SKILL.md into the
|
|
878
|
+
* terminal, frontmatter and all, and that was the ENTIRE effect. None of the
|
|
879
|
+
* references do that — a skill is a working method the model follows, not a
|
|
880
|
+
* document it reads back at you. The body now goes into the request; what the
|
|
881
|
+
* user sees here is a marker that it was picked up, in the same shape as a
|
|
882
|
+
* step row.
|
|
883
|
+
*/
|
|
884
|
+
skillInvoked(id, instruction) {
|
|
885
|
+
this.frame('skill', () => {
|
|
886
|
+
const width = this.width();
|
|
887
|
+
const head = `${paint('accent', GLYPHS.step)} ${bold(paint('ink', 'skill'))}`
|
|
888
|
+
+ `${dim(` ${GLYPHS.dot} `)}${paint('agent', safeDisplay(id))}`;
|
|
889
|
+
this.write();
|
|
890
|
+
this.write(truncate(head, width - 1, GLYPHS.ellipsis));
|
|
891
|
+
if (instruction) {
|
|
892
|
+
for (const line of wrapBlock(safeDisplay(instruction), width - 4)) {
|
|
893
|
+
this.write(dim(` ${line}`));
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
}, { skill: String(id || '') });
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/**
|
|
900
|
+
* A one-line aside from the loop: tier, packs, compaction, a cache hit.
|
|
901
|
+
*
|
|
902
|
+
* These were bare console.log calls in agentLoop.js — a second writer to a
|
|
903
|
+
* terminal whose bottom rows the prompt repaints in place, which is why they
|
|
904
|
+
* left debris. Routed here they pass through write(), so the live region is
|
|
905
|
+
* lifted out of the way first and put back after, and they inherit the theme
|
|
906
|
+
* instead of carrying a hardcoded \x1b[1m\x1b[36m that ignored NO_COLOR.
|
|
907
|
+
*
|
|
908
|
+
* Deliberately quiet: a dim marker and one wrapped line. An aside that shouts
|
|
909
|
+
* as loudly as the agent's answer trains the user to stop reading both.
|
|
910
|
+
*/
|
|
911
|
+
onNotice(msg) {
|
|
912
|
+
const tone = ['accent', 'success', 'warn', 'muted'].includes(msg.tone) ? msg.tone : 'muted';
|
|
913
|
+
const message = safeDisplay(msg.message || '');
|
|
914
|
+
this.frame('notice', () => {
|
|
915
|
+
const width = this.measure();
|
|
916
|
+
const marker = paint(tone === 'muted' ? 'rule' : tone, GLYPHS.vLine);
|
|
917
|
+
for (const line of wrapBlock(message, width - 2)) {
|
|
918
|
+
this.write(`${marker} ${dim(line)}`);
|
|
919
|
+
}
|
|
920
|
+
}, { tone });
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
/**
|
|
924
|
+
* Route one frame. Returns true when handled.
|
|
925
|
+
*
|
|
926
|
+
* Unknown frame types are ignored rather than printed raw — the loop emits
|
|
927
|
+
* several the terminal has no business showing (usage_update, task_complete).
|
|
928
|
+
*/
|
|
929
|
+
handle(msg) {
|
|
930
|
+
if (!msg || typeof msg.type !== 'string') return false;
|
|
931
|
+
if (msg.type !== 'status') this.stopSpinner();
|
|
932
|
+
|
|
933
|
+
switch (msg.type) {
|
|
934
|
+
case 'status': this.onStatus(msg); return true;
|
|
935
|
+
case 'agent_message': this.onAgentMessage(msg); return true;
|
|
936
|
+
case 'agent_reasoning': this.onAgentReasoning(msg); return true;
|
|
937
|
+
case 'agent_plan': this.onAgentPlan(msg); return true;
|
|
938
|
+
case 'step_start': this.onStepStart(msg); return true;
|
|
939
|
+
case 'step_complete': this.onStepComplete(msg); return true;
|
|
940
|
+
case 'step_error': this.onStepError(msg); return true;
|
|
941
|
+
case 'checkpoint': this.onCheckpoint(msg); return true;
|
|
942
|
+
case 'approval_request': this.onApprovalRequest(msg); return true;
|
|
943
|
+
case 'error': this.onError(msg); return true;
|
|
944
|
+
case 'budget_warning': this.onBudgetWarning(msg); return true;
|
|
945
|
+
case 'notice': this.onNotice(msg); return true;
|
|
946
|
+
// Not printed — it feeds the live status line, which is where a running
|
|
947
|
+
// token and cost total belongs. Printing it would interleave a counter
|
|
948
|
+
// into the transcript on every single model response.
|
|
949
|
+
case 'usage_update': this.status.onUsage(msg); return true;
|
|
950
|
+
default: return false;
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
}
|