@semalt-ai/code 1.8.5 → 1.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +7 -1
- package/.github/workflows/ci.yml +69 -0
- package/ARCHITECTURE.md +6 -95
- package/CLAUDE.md +196 -316
- package/README.md +148 -4
- package/docs/ARCHITECTURE.md +1321 -0
- package/docs/CONFIG.md +340 -0
- package/docs/HISTORY.md +245 -0
- package/examples/embed.js +74 -0
- package/index.js +251 -10
- package/lib/agent.js +856 -120
- package/lib/api.js +239 -50
- package/lib/args.js +74 -2
- package/lib/audit.js +23 -1
- package/lib/background.js +584 -0
- package/lib/checkpoints.js +757 -0
- package/lib/commands/auth.js +94 -0
- package/lib/commands/chat-session.js +489 -0
- package/lib/commands/chat-slash.js +415 -0
- package/lib/commands/chat-turn.js +669 -0
- package/lib/commands/chat.js +407 -0
- package/lib/commands/custom.js +157 -0
- package/lib/commands/history-utils.js +66 -0
- package/lib/commands/index.js +268 -0
- package/lib/commands/mcp.js +113 -0
- package/lib/commands/oneshot.js +193 -0
- package/lib/commands/registry.js +269 -0
- package/lib/commands/tasks.js +89 -0
- package/lib/compact.js +87 -0
- package/lib/config.js +360 -11
- package/lib/constants.js +401 -3
- package/lib/deny.js +199 -0
- package/lib/doctor.js +160 -0
- package/lib/headless.js +202 -0
- package/lib/hooks.js +286 -0
- package/lib/images.js +270 -0
- package/lib/internals.js +49 -0
- package/lib/mcp/boundary.js +131 -0
- package/lib/mcp/client.js +270 -0
- package/lib/mcp/oauth.js +134 -0
- package/lib/memory.js +209 -0
- package/lib/metrics.js +37 -2
- package/lib/payload.js +54 -0
- package/lib/permission-rules.js +401 -0
- package/lib/permissions.js +123 -26
- package/lib/pricing.js +67 -0
- package/lib/proc.js +62 -0
- package/lib/prompts.js +99 -8
- package/lib/sandbox.js +568 -0
- package/lib/sdk.js +328 -0
- package/lib/secrets.js +211 -0
- package/lib/skills.js +223 -0
- package/lib/subagents.js +516 -0
- package/lib/tool_registry.js +2862 -0
- package/lib/tool_specs.js +263 -9
- package/lib/tools.js +352 -1039
- package/lib/ui/anim.js +86 -0
- package/lib/ui/ansi.js +17 -27
- package/lib/ui/chat-history.js +253 -71
- package/lib/ui/create-ui.js +67 -24
- package/lib/ui/diff.js +90 -25
- package/lib/ui/file-activity.js +236 -0
- package/lib/ui/format.js +195 -29
- package/lib/ui/input-field.js +21 -11
- package/lib/ui/md-stream.js +234 -0
- package/lib/ui/render-operation.js +113 -0
- package/lib/ui/select.js +1 -4
- package/lib/ui/status-bar.js +146 -36
- package/lib/ui/stream.js +20 -13
- package/lib/ui/theme.js +190 -44
- package/lib/ui/tool-operation.js +190 -0
- package/lib/ui/utils.js +9 -5
- package/lib/ui/web-activity.js +270 -0
- package/lib/ui/writer.js +159 -45
- package/lib/ui.js +1 -1
- package/lib/verify.js +229 -0
- package/lib/web-extract.js +213 -0
- package/lib/web-summarize.js +68 -0
- package/package.json +19 -4
- package/scripts/lint.js +57 -0
- package/test/agent-loop.test.js +389 -0
- package/test/anim-driver.test.js +153 -0
- package/test/ask-user-display.test.js +226 -0
- package/test/ask-user-gate.test.js +231 -0
- package/test/background.test.js +414 -0
- package/test/chat-history-nocolor.test.js +155 -0
- package/test/chat-relogin.test.js +207 -0
- package/test/chat.test.js +114 -0
- package/test/checkpoints-agent.test.js +181 -0
- package/test/checkpoints.test.js +650 -0
- package/test/command-registry.test.js +160 -0
- package/test/compact.test.js +116 -0
- package/test/completion-lazy.test.js +52 -0
- package/test/config-merge.test.js +324 -0
- package/test/config-quarantine.test.js +128 -0
- package/test/config-write-guard-allow-anywhere.test.js +56 -0
- package/test/config-write-guard-skip.test.js +46 -0
- package/test/config-write-guard.test.js +153 -0
- package/test/context-split.test.js +215 -0
- package/test/cost-doctor.test.js +142 -0
- package/test/custom-commands-chat.test.js +106 -0
- package/test/custom-commands.test.js +230 -0
- package/test/defer-detail-band.test.js +403 -0
- package/test/deny-windows.test.js +120 -0
- package/test/deny.test.js +83 -0
- package/test/detail-band-tab-flatten.test.js +242 -0
- package/test/download-allow-anywhere.test.js +66 -0
- package/test/download-confine.test.js +153 -0
- package/test/exec-diff.test.js +268 -0
- package/test/executors.test.js +599 -0
- package/test/extract-tool-calls.test.js +349 -0
- package/test/fetch-url-validation.test.js +219 -0
- package/test/file-activity.test.js +522 -0
- package/test/fixtures/tool-calls.js +57 -0
- package/test/fixtures/web-page.js +91 -0
- package/test/git-tools.test.js +384 -0
- package/test/grep-glob-serialize.test.js +242 -0
- package/test/grep-glob.test.js +268 -0
- package/test/grep-path-target.test.js +227 -0
- package/test/harness/README.md +57 -0
- package/test/harness/chat-harness.js +143 -0
- package/test/harness/memwarn-headless-child.js +65 -0
- package/test/harness/mock-llm.js +120 -0
- package/test/harness/mock-mcp-server.js +142 -0
- package/test/harness/sse-server.js +69 -0
- package/test/headless.test.js +348 -0
- package/test/history-utils.test.js +88 -0
- package/test/hooks-agent.test.js +238 -0
- package/test/hooks-verify-sandbox.test.js +232 -0
- package/test/hooks.test.js +216 -0
- package/test/http-get-user-agent.test.js +142 -0
- package/test/images-api.test.js +208 -0
- package/test/images.test.js +238 -0
- package/test/input-field-ctrl-o.test.js +37 -0
- package/test/live-height-physical.test.js +281 -0
- package/test/max-iterations.test.js +218 -0
- package/test/mcp-boundary.test.js +57 -0
- package/test/mcp-client.test.js +267 -0
- package/test/mcp-oauth.test.js +86 -0
- package/test/md-stream.test.js +183 -0
- package/test/memory-truncation-warning.test.js +222 -0
- package/test/memory.test.js +198 -0
- package/test/native-dispatch.test.js +409 -0
- package/test/native-live-narration.test.js +254 -0
- package/test/output-chokepoint.test.js +188 -0
- package/test/output-heredoc-leak.test.js +195 -0
- package/test/output-preview.test.js +245 -0
- package/test/path-guards.test.js +134 -0
- package/test/payload.test.js +99 -0
- package/test/permission-rules-agent.test.js +210 -0
- package/test/permission-rules.test.js +297 -0
- package/test/permissions.test.js +362 -0
- package/test/plan-mode.test.js +167 -0
- package/test/read-paginate.test.js +275 -0
- package/test/readonly-tools.test.js +177 -0
- package/test/render-operation.test.js +317 -0
- package/test/replay-descriptor-xml.test.js +216 -0
- package/test/replay-descriptor.test.js +189 -0
- package/test/replay-web-aggregate.test.js +291 -0
- package/test/replay-web-persist.test.js +241 -0
- package/test/result-cap.test.js +233 -0
- package/test/running-glyph-anim.test.js +111 -0
- package/test/sandbox-agent.test.js +147 -0
- package/test/sandbox-integration.test.js +216 -0
- package/test/sandbox.test.js +408 -0
- package/test/sdk.test.js +234 -0
- package/test/shell-output-cap.test.js +181 -0
- package/test/skills-chat.test.js +110 -0
- package/test/skills.test.js +295 -0
- package/test/smoke.test.js +68 -0
- package/test/status-bar-driver.test.js +93 -0
- package/test/status-bar-pause.test.js +164 -0
- package/test/status-bar-resync.test.js +188 -0
- package/test/stream-parser.test.js +171 -0
- package/test/subagents-agent.test.js +178 -0
- package/test/subagents.test.js +222 -0
- package/test/theme-palette.test.js +166 -0
- package/test/tool-registry.test.js +85 -0
- package/test/trim-budget.test.js +101 -0
- package/test/truncate-visible.test.js +78 -0
- package/test/verify-agent.test.js +317 -0
- package/test/verify.test.js +141 -0
- package/test/view-image.test.js +199 -0
- package/test/web-activity-ordering.test.js +203 -0
- package/test/web-activity.test.js +207 -0
- package/test/web-data-extraction-guidance.test.js +71 -0
- package/test/web-extract.test.js +185 -0
- package/test/web-fetch-agent.test.js +291 -0
- package/test/web-fetch-mode.test.js +193 -0
- package/test/web-search.test.js +380 -0
- package/lib/commands.js +0 -1438
- package/path +0 -1
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Pure renderer for a ToolOperation descriptor (Output Refactor — Phase 1).
|
|
4
|
+
//
|
|
5
|
+
// `renderOperation(descriptor, { mode, phase, maxLines })` → the string for that
|
|
6
|
+
// phase/mode. This is the SINGLE place a tool-call's chrome line is assembled
|
|
7
|
+
// for the interactive path; chat-turn.js builds a descriptor and calls here
|
|
8
|
+
// instead of assembling formatToolLine arguments inline.
|
|
9
|
+
//
|
|
10
|
+
// Phase 1 is a re-routing, not a re-styling: the renderer internally reuses the
|
|
11
|
+
// existing `formatToolLine` (glyph/category/operation/meta assembly) and
|
|
12
|
+
// `buildExecutionDiff` (diff body), so its output is byte-for-byte identical to
|
|
13
|
+
// today's. Do NOT change glyphs, spacing, colours, or wording here — equivalence
|
|
14
|
+
// with the old formatters is the phase's pass/fail.
|
|
15
|
+
//
|
|
16
|
+
// modes: 'ansi' (interactive) — assembles the chrome line via formatToolLine.
|
|
17
|
+
// 'json' (Phase 6d-i) — the pure embeddable per-operation object built
|
|
18
|
+
// from the shared `operationCore` (descriptor-native names, no
|
|
19
|
+
// ANSI/IO/framing); a superset-or-equal of serializeOperation's
|
|
20
|
+
// persistable core, minus the `v` persistence tag.
|
|
21
|
+
// 'event' (Phase 6d-i) — the json object plus the `phase` it rendered
|
|
22
|
+
// for, for a single per-lifecycle emission. Headless (6d-ii)
|
|
23
|
+
// owns any NDJSON envelope / `type` tag around it.
|
|
24
|
+
// phases: 'pending' — the live activity line (with growing timer)
|
|
25
|
+
// 'result' — the committed final line
|
|
26
|
+
// 'detail' — the collapsible body (Phase 5): a file-edit diff (expanded
|
|
27
|
+
// to maxLines) or a shell/MCP/subagent output preview
|
|
28
|
+
// (previewLines + `… N more lines`). Errors carry no detail
|
|
29
|
+
// (expanded elsewhere).
|
|
30
|
+
|
|
31
|
+
const { formatToolLine, formatOutputPreview } = require('./format');
|
|
32
|
+
const { buildExecutionDiff } = require('./diff');
|
|
33
|
+
const { operationCore } = require('./tool-operation');
|
|
34
|
+
|
|
35
|
+
// Map the descriptor's status vocabulary back to formatToolLine's.
|
|
36
|
+
function _formatStatus(descriptor, phase) {
|
|
37
|
+
if (phase === 'pending') return 'pending';
|
|
38
|
+
return descriptor.status === 'error' ? 'failure' : 'success';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Reconstruct the formatToolLine argument bag from the descriptor. Phase 1
|
|
42
|
+
// guarantees identical output by feeding the same fields the old call sites did.
|
|
43
|
+
function _toolLineArgs(descriptor, phase) {
|
|
44
|
+
return {
|
|
45
|
+
status: _formatStatus(descriptor, phase),
|
|
46
|
+
// Colour is resolved from the descriptor's category (Phase 2.5): the
|
|
47
|
+
// renderer hands formatToolLine the already-resolved category so colour is
|
|
48
|
+
// the renderer's job, keyed by the descriptor, not re-derived from the tag.
|
|
49
|
+
category: descriptor.category,
|
|
50
|
+
tag: descriptor.tag,
|
|
51
|
+
arg: descriptor.target,
|
|
52
|
+
attrs: descriptor.attrs,
|
|
53
|
+
durationMs: descriptor.durationMs == null ? 0 : descriptor.durationMs,
|
|
54
|
+
meta: phase === 'pending' ? null : descriptor.meta,
|
|
55
|
+
error: phase === 'pending' ? null : descriptor.error,
|
|
56
|
+
noDuration: descriptor.noDuration,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function _renderAnsi(descriptor, opts) {
|
|
61
|
+
const phase = opts.phase || 'result';
|
|
62
|
+
if (phase === 'detail') {
|
|
63
|
+
// The detail body, COLLAPSED per the Phase 5 policy (keyed by detail-kind):
|
|
64
|
+
// - diff → the file-edit diff, EXPANDED to maxLines (unchanged).
|
|
65
|
+
// - output → a shell_preview_lines preview + `… N more lines`. (Errors
|
|
66
|
+
// carry no detail — the error body renders expanded on the
|
|
67
|
+
// chat-history path.)
|
|
68
|
+
const detail = descriptor.detail;
|
|
69
|
+
if (!detail) return '';
|
|
70
|
+
if (detail.kind === 'diff') {
|
|
71
|
+
const diffStr = buildExecutionDiff({ diff: detail.payload, maxLines: opts.maxLines });
|
|
72
|
+
return diffStr || '';
|
|
73
|
+
}
|
|
74
|
+
if (detail.kind === 'output') {
|
|
75
|
+
const { lines, hiddenCount } = formatOutputPreview(detail.payload.body, {
|
|
76
|
+
previewLines: opts.previewLines,
|
|
77
|
+
cols: opts.cols,
|
|
78
|
+
});
|
|
79
|
+
const out = lines.slice();
|
|
80
|
+
if (hiddenCount > 0) {
|
|
81
|
+
out.push(`… ${hiddenCount} more ${hiddenCount === 1 ? 'line' : 'lines'}`);
|
|
82
|
+
}
|
|
83
|
+
return out.join('\n');
|
|
84
|
+
}
|
|
85
|
+
return '';
|
|
86
|
+
}
|
|
87
|
+
return formatToolLine(_toolLineArgs(descriptor, phase));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function renderOperation(descriptor, opts) {
|
|
91
|
+
if (!descriptor) return '';
|
|
92
|
+
const o = opts || {};
|
|
93
|
+
const mode = o.mode || 'ansi';
|
|
94
|
+
if (mode === 'ansi') return _renderAnsi(descriptor, o);
|
|
95
|
+
// json/event are PURE structured-data modes (Phase 6d-i): no ANSI, no IO, no
|
|
96
|
+
// framing. Both derive ONLY from the shared `operationCore` mapping — the same
|
|
97
|
+
// one `serializeOperation` uses — so there is exactly one descriptor→data
|
|
98
|
+
// serializer in the codebase.
|
|
99
|
+
// - json : the embeddable per-operation object (descriptor-native field
|
|
100
|
+
// names; the persistable core WITHOUT the `v` persistence tag).
|
|
101
|
+
// - event: the same data shaped for a single per-lifecycle emission — the
|
|
102
|
+
// json object plus the `phase` it was rendered for. The consumer
|
|
103
|
+
// (headless, Phase 6d-ii) owns any envelope/`type` framing.
|
|
104
|
+
if (mode === 'json') return operationCore(descriptor);
|
|
105
|
+
if (mode === 'event') {
|
|
106
|
+
const core = operationCore(descriptor);
|
|
107
|
+
return core && { ...core, phase: o.phase || 'result' };
|
|
108
|
+
}
|
|
109
|
+
// unknown mode — degrade to empty string (never throw).
|
|
110
|
+
return '';
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
module.exports = { renderOperation };
|
package/lib/ui/select.js
CHANGED
|
@@ -11,7 +11,7 @@ const writer = require('./writer');
|
|
|
11
11
|
// Two input paths:
|
|
12
12
|
// * TUI: opts.captureNavigation(handler) → release fn. Mirrors the
|
|
13
13
|
// permission picker. The host (input-field) routes prev/next/
|
|
14
|
-
// select/cancel
|
|
14
|
+
// select/cancel actions to handler; release detaches.
|
|
15
15
|
// * Non-TUI: direct raw stdin. Used by cmdModels and the permission-
|
|
16
16
|
// prompt fallback in non-chat command flows.
|
|
17
17
|
//
|
|
@@ -59,8 +59,6 @@ async function interactiveSelect(items, renderItem, options) {
|
|
|
59
59
|
writer.clearModal();
|
|
60
60
|
if (typeof releaseNav === 'function') releaseNav();
|
|
61
61
|
resolve(action === 'select' ? idx : null);
|
|
62
|
-
} else if (action === 'expand') {
|
|
63
|
-
if (typeof opts.onExpand === 'function') opts.onExpand();
|
|
64
62
|
}
|
|
65
63
|
});
|
|
66
64
|
});
|
|
@@ -93,7 +91,6 @@ async function interactiveSelect(items, renderItem, options) {
|
|
|
93
91
|
const onData = (chunk) => {
|
|
94
92
|
if (done) return;
|
|
95
93
|
const data = chunk.toString('utf8');
|
|
96
|
-
if (data[0] === '\x0f') { if (typeof opts.onExpand === 'function') opts.onExpand(); return; }
|
|
97
94
|
if (data === '\x03' || data === '\x1b' || data === 'q') { finish(null); return; }
|
|
98
95
|
if (data === '\r' || data === '\n') { finish(idx); return; }
|
|
99
96
|
if (data === '\x1b[A' || data === 'k') {
|
package/lib/ui/status-bar.js
CHANGED
|
@@ -1,8 +1,26 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const { RST, DIM,
|
|
4
|
-
const { UI_THEME } = require('./theme');
|
|
3
|
+
const { RST, DIM, SPINNER_DEFS } = require('./ansi');
|
|
4
|
+
const { UI_THEME, FG_RED, FG_DARK, colorEnabled } = require('./theme');
|
|
5
5
|
const { stripAnsi, termWidth } = require('./utils');
|
|
6
|
+
const { AnimDriver, TICKS_PER_SECOND } = require('./anim');
|
|
7
|
+
|
|
8
|
+
// States that animate the status-bar spinner (and, in the live region below,
|
|
9
|
+
// the per-tool running glyph + elapsed meter). While in one of these the
|
|
10
|
+
// single driver repaints at the base (100 ms) cadence; otherwise it only
|
|
11
|
+
// repaints for the clock (1 Hz), and when idle-paused it stops entirely.
|
|
12
|
+
const ANIM_STATES = new Set(['thinking', 'streaming', 'tool', 'waiting_download']);
|
|
13
|
+
|
|
14
|
+
// Compact token count for the estimated split (Variant B): the base/working
|
|
15
|
+
// estimates are shown abbreviated (e.g. 12k, 5.6k, 200k) to keep the row short
|
|
16
|
+
// — they are estimates, so sub-thousand precision is noise. The real measured
|
|
17
|
+
// total is rendered separately with full toLocaleString precision (no ~).
|
|
18
|
+
function abbrevTokens(n) {
|
|
19
|
+
const v = Math.max(0, Math.round(Number(n) || 0));
|
|
20
|
+
if (v < 1000) return String(v);
|
|
21
|
+
const k = v / 1000;
|
|
22
|
+
return (k < 10 ? k.toFixed(1) : String(Math.round(k))) + 'k';
|
|
23
|
+
}
|
|
6
24
|
|
|
7
25
|
// Status bar is a *content producer* only. It builds a single line string
|
|
8
26
|
// and hands it to the UI orchestrator via the onChange callback; the
|
|
@@ -27,31 +45,85 @@ class FullStatusBar {
|
|
|
27
45
|
this._reportedContext = 0;
|
|
28
46
|
this._pendingDelta = 0;
|
|
29
47
|
this._contextLimit = null;
|
|
48
|
+
// Estimated base/working split (Variant B, display-only). Both are char/4
|
|
49
|
+
// estimates of the same measured prompt; rendered ~-prefixed alongside the
|
|
50
|
+
// real total. 0 until the agent loop reports the first estimate.
|
|
51
|
+
this._baseEst = 0;
|
|
52
|
+
this._workingEst = 0;
|
|
30
53
|
this._speed = 0;
|
|
31
54
|
this._streamStart = null;
|
|
32
55
|
this._streamTokens = 0;
|
|
33
56
|
this._tokensSinceUpdate = 0;
|
|
34
57
|
this._animIdx = 0;
|
|
35
|
-
this._animTimer = null;
|
|
36
58
|
this._paused = false;
|
|
37
|
-
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
|
|
59
|
+
this._destroyed = false;
|
|
60
|
+
// Single animation driver (Phase 3). Replaces the two independent
|
|
61
|
+
// setIntervals (1 Hz clock + 100 ms spinner) with one timer. Both the
|
|
62
|
+
// clock field and the spinner glyph are now *subscribers*: each tick the
|
|
63
|
+
// driver advances one frame counter, runs both subscribers, and performs
|
|
64
|
+
// exactly one coordinated repaint (through _notify → the orchestrator →
|
|
65
|
+
// writer.setLive, which also re-renders the live-region activity rows, so
|
|
66
|
+
// the per-tool running glyph + elapsed meter ride the same repaint). The
|
|
67
|
+
// driver runs while there's something to animate OR while the clock should
|
|
68
|
+
// tick; when idle-paused with nothing animating it STOPS — preserving the
|
|
69
|
+
// 5404bd0 idle-scroll fix (no periodic redraw → the viewport scrolls).
|
|
70
|
+
this._driver = new AnimDriver();
|
|
71
|
+
this._driver.onRepaint(() => this._notify());
|
|
72
|
+
// Clock subscriber: request a repaint once per second, and only while not
|
|
73
|
+
// idle-paused (the clock tick is what fought user scrollback).
|
|
74
|
+
this._driver.subscribe((frame) => !this._paused && (frame % TICKS_PER_SECOND === 0));
|
|
75
|
+
// Spinner subscriber: while in an animating state, advance the glyph frame
|
|
76
|
+
// every tick and request the repaint (the fine 100 ms cadence).
|
|
77
|
+
this._driver.subscribe((frame) => {
|
|
78
|
+
if (!ANIM_STATES.has(this._state)) return false;
|
|
79
|
+
this._animIdx = frame;
|
|
80
|
+
return true;
|
|
81
|
+
});
|
|
82
|
+
this._syncDriver();
|
|
42
83
|
}
|
|
43
84
|
|
|
44
|
-
|
|
45
|
-
|
|
85
|
+
// Start/stop the single driver to match the current need: run while there is
|
|
86
|
+
// something to animate (ANIM_STATES) OR while the clock should tick (not
|
|
87
|
+
// idle-paused); stop when neither holds. Idempotent (start()/stop() guard
|
|
88
|
+
// against stacking), so repeated pause/resume cycles never leak a timer.
|
|
89
|
+
_syncDriver() {
|
|
90
|
+
if (this._destroyed) return;
|
|
91
|
+
const animating = ANIM_STATES.has(this._state);
|
|
92
|
+
if (!this._paused || animating) this._driver.start();
|
|
93
|
+
else this._driver.stop();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// pause() halts the periodic 1 Hz clock repaint so a forced redraw no longer
|
|
97
|
+
// snaps the viewport to the bottom while the user scrolls up. If a tool is
|
|
98
|
+
// mid-flight (an ANIM_STATE) the driver keeps running for the spinner — but
|
|
99
|
+
// the clock subscriber goes quiet, so there's no 1 Hz scroll-fighting tick.
|
|
100
|
+
// When nothing is animating, _syncDriver stops the driver outright (idle =
|
|
101
|
+
// no periodic repaint). Event-driven redraws (update/updateMetrics/setCost)
|
|
102
|
+
// are unaffected — they call _notify directly. resume() restarts the driver
|
|
103
|
+
// and forces one repaint so the viewport returns to the input prompt.
|
|
104
|
+
pause() { this._paused = true; this._syncDriver(); }
|
|
105
|
+
resume() { this._paused = false; this._syncDriver(); this._notify(); }
|
|
46
106
|
|
|
47
107
|
setModel(name) {
|
|
48
108
|
this._model = name || '';
|
|
49
109
|
this._notify();
|
|
50
110
|
}
|
|
51
111
|
|
|
112
|
+
// Session cost string (e.g. "$0.0123" or "unknown"), shown when show_cost is
|
|
113
|
+
// on (Task 2.6). null/'' hides the field. The caller computes the value from
|
|
114
|
+
// the price table × usage; the bar just renders it.
|
|
115
|
+
setCost(str) {
|
|
116
|
+
this._cost = str || null;
|
|
117
|
+
this._notify();
|
|
118
|
+
}
|
|
119
|
+
|
|
52
120
|
update(state, label) {
|
|
53
121
|
this._state = state || 'idle';
|
|
54
122
|
if (label !== undefined) this._label = label;
|
|
123
|
+
// A state change means work is happening — keep the not-paused ⇒
|
|
124
|
+
// driver-running invariant. (update('idle') unconditionally restarts the
|
|
125
|
+
// driver; the startup re-sync in chat.js re-pauses it if the field is
|
|
126
|
+
// already idle — see status-bar-resync.test.js.)
|
|
55
127
|
this._paused = false;
|
|
56
128
|
|
|
57
129
|
if (state === 'streaming') {
|
|
@@ -60,12 +132,13 @@ class FullStatusBar {
|
|
|
60
132
|
this._streamStart = null; this._streamTokens = 0; this._speed = 0;
|
|
61
133
|
}
|
|
62
134
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
135
|
+
// Leaving an animating state resets the spinner frame so the next run
|
|
136
|
+
// starts at frame 0 rather than wherever the counter happened to be.
|
|
137
|
+
if (!ANIM_STATES.has(this._state)) this._animIdx = 0;
|
|
138
|
+
// One driver, started/stopped as a unit (no per-state timer). The spinner
|
|
139
|
+
// subscriber animates while in an ANIM_STATE; the clock subscriber ticks
|
|
140
|
+
// at 1 Hz while not paused.
|
|
141
|
+
this._syncDriver();
|
|
69
142
|
this._notify();
|
|
70
143
|
}
|
|
71
144
|
|
|
@@ -92,6 +165,10 @@ class FullStatusBar {
|
|
|
92
165
|
this._reportedContext = data.contextTokens;
|
|
93
166
|
this._pendingDelta = 0;
|
|
94
167
|
}
|
|
168
|
+
// Estimated split (Variant B) — recomputed per request by the api client and
|
|
169
|
+
// threaded through here, so it tracks MCP-connect / plan-mode / native-vs-XML.
|
|
170
|
+
if (typeof data.baseEst === 'number') this._baseEst = data.baseEst;
|
|
171
|
+
if (typeof data.workingEst === 'number') this._workingEst = data.workingEst;
|
|
95
172
|
if (data.tokenLimit && typeof data.tokenLimit.limit === 'number') {
|
|
96
173
|
this._contextLimit = data.tokenLimit.limit;
|
|
97
174
|
}
|
|
@@ -122,9 +199,9 @@ class FullStatusBar {
|
|
|
122
199
|
// to build the live region. ALWAYS returns a string — the status row is a
|
|
123
200
|
// permanent fixture of the live region, never omitted. Missing data
|
|
124
201
|
// renders as a short placeholder ("—") so the row width is stable.
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
202
|
+
// Pausing (idle scroll) stops the periodic *tick* (the driver is stopped)
|
|
203
|
+
// but the row itself is still produced whenever the composer asks for it —
|
|
204
|
+
// an event-driven _notify still repaints normally.
|
|
128
205
|
renderLine() {
|
|
129
206
|
const layout = this._layout;
|
|
130
207
|
const cols = layout.cols;
|
|
@@ -163,18 +240,31 @@ class FullStatusBar {
|
|
|
163
240
|
// tokens, keeping the model name readable to the last. Never chop a
|
|
164
241
|
// field mid-character.
|
|
165
242
|
const tokenField = this._buildTokenField();
|
|
243
|
+
// Per-field colour (Phase 2.5): the right chrome is no longer wholesale-DIM
|
|
244
|
+
// — the persistent footer was the single most washed-out region. The model
|
|
245
|
+
// (the field that matters most) renders in accent; tokens/time/cost/speed
|
|
246
|
+
// are subtle (one step up from dim). Only the separators stay DIM. Under
|
|
247
|
+
// NO_COLOR/non-TTY every field colour resolves to ''.
|
|
248
|
+
const on = colorEnabled();
|
|
249
|
+
const cSubtle = on ? UI_THEME.subtle : '';
|
|
250
|
+
const cModel = on ? UI_THEME.accent : '';
|
|
166
251
|
const fields = [
|
|
167
|
-
{ visible: timePart, ansi: timePart, priority: 2 },
|
|
168
|
-
{ visible: this._model || '—', ansi: this._model || '—', priority: 4 },
|
|
169
|
-
{ visible: tokenField.visible, ansi: tokenField.ansi, priority: 3 },
|
|
252
|
+
{ visible: timePart, ansi: timePart, color: cSubtle, priority: 2 },
|
|
253
|
+
{ visible: this._model || '—', ansi: this._model || '—', color: cModel, priority: 4 },
|
|
254
|
+
{ visible: tokenField.visible, ansi: tokenField.ansi, color: cSubtle, priority: 3 },
|
|
170
255
|
];
|
|
256
|
+
if (this._cost) {
|
|
257
|
+
const c = `${this._cost}`;
|
|
258
|
+
fields.push({ visible: c, ansi: c, color: cSubtle, priority: 2 });
|
|
259
|
+
}
|
|
171
260
|
if (state === 'streaming' && this._speed > 0) {
|
|
172
261
|
const s = `${this._speed} t/s`;
|
|
173
|
-
fields.push({ visible: s, ansi: s, priority: 1 });
|
|
262
|
+
fields.push({ visible: s, ansi: s, color: cSubtle, priority: 1 });
|
|
174
263
|
}
|
|
175
264
|
|
|
176
265
|
const sep = ' · ';
|
|
177
|
-
|
|
266
|
+
// Separators stay DIM; fields carry their own colour (no outer DIM wrap).
|
|
267
|
+
const sepAnsi = on ? ` ${DIM}·${RST} ` : ' · ';
|
|
178
268
|
const leftWidth = termWidth(stripAnsi(left));
|
|
179
269
|
// Keep at least one space between left and right chrome when left is
|
|
180
270
|
// non-empty; when idle (left == '') the right can sit at column 0.
|
|
@@ -208,7 +298,8 @@ class FullStatusBar {
|
|
|
208
298
|
}
|
|
209
299
|
|
|
210
300
|
const rightWidth = widthOf(keep);
|
|
211
|
-
const
|
|
301
|
+
const R = on ? RST : '';
|
|
302
|
+
const rightAnsi = keep.map((f) => `${f.color || ''}${f.ansi}${R}`).join(sepAnsi);
|
|
212
303
|
const padding = Math.max(gap > 0 ? gap : 0, maxCols - leftWidth - rightWidth);
|
|
213
304
|
return left + ' '.repeat(padding) + rightAnsi;
|
|
214
305
|
}
|
|
@@ -221,21 +312,35 @@ class FullStatusBar {
|
|
|
221
312
|
_buildTokenField() {
|
|
222
313
|
const used = Math.max(0, (this._reportedContext | 0) + (this._pendingDelta | 0));
|
|
223
314
|
const usedStr = used.toLocaleString();
|
|
315
|
+
// Estimated split prefix (Variant B): "~Nk working · ~Nk base · ". Working
|
|
316
|
+
// first (it's the part that grows and matters), base second (fixed-ish
|
|
317
|
+
// reference). Both carry ~ — they're char/4 estimates. The measured
|
|
318
|
+
// total/limit/percent that follows carries NO ~ (it's the truth anchor).
|
|
319
|
+
// Shown only once an estimate has arrived, so a fresh bar has no ~ noise.
|
|
320
|
+
let estPrefix = '';
|
|
321
|
+
if (this._workingEst > 0 || this._baseEst > 0) {
|
|
322
|
+
estPrefix = `~${abbrevTokens(this._workingEst)} working · ~${abbrevTokens(this._baseEst)} base · `;
|
|
323
|
+
}
|
|
224
324
|
if (!this._contextLimit) {
|
|
225
|
-
const s = `${usedStr} tok`;
|
|
325
|
+
const s = `${estPrefix}${usedStr} tok`;
|
|
226
326
|
return { visible: s, ansi: s };
|
|
227
327
|
}
|
|
228
328
|
const limit = this._contextLimit;
|
|
229
329
|
const pct = limit > 0 ? Math.round((used / limit) * 100) : 0;
|
|
230
330
|
const limitStr = limit.toLocaleString();
|
|
231
|
-
const visible = `${usedStr} / ${limitStr} tok (${pct}%)`;
|
|
331
|
+
const visible = `${estPrefix}${usedStr} / ${limitStr} tok (${pct}%)`;
|
|
332
|
+
// The pct override resets and restores the field's base colour (subtle),
|
|
333
|
+
// not the old outer DIM. Gated on colour: under NO_COLOR/non-TTY the field
|
|
334
|
+
// is plain text so no ANSI leaks into a piped status read.
|
|
232
335
|
let pctAnsi = `${pct}%`;
|
|
233
|
-
if (
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
336
|
+
if (colorEnabled()) {
|
|
337
|
+
if (pct >= 90) {
|
|
338
|
+
pctAnsi = `${RST}${UI_THEME.error}${pct}%${RST}${UI_THEME.subtle}`;
|
|
339
|
+
} else if (pct >= 70) {
|
|
340
|
+
pctAnsi = `${RST}${UI_THEME.warning}${pct}%${RST}${UI_THEME.subtle}`;
|
|
341
|
+
}
|
|
237
342
|
}
|
|
238
|
-
const ansi = `${usedStr} / ${limitStr} tok (${pctAnsi})`;
|
|
343
|
+
const ansi = `${estPrefix}${usedStr} / ${limitStr} tok (${pctAnsi})`;
|
|
239
344
|
return { visible, ansi };
|
|
240
345
|
}
|
|
241
346
|
|
|
@@ -244,7 +349,8 @@ class FullStatusBar {
|
|
|
244
349
|
// affecting surrounding rows.
|
|
245
350
|
renderSeparator() {
|
|
246
351
|
const cols = this._layout.cols;
|
|
247
|
-
|
|
352
|
+
const rule = '─'.repeat(Math.max(0, cols - 1));
|
|
353
|
+
return colorEnabled() ? `${FG_DARK}${rule}${RST}` : rule;
|
|
248
354
|
}
|
|
249
355
|
|
|
250
356
|
// Back-compat: older call sites (command.js, create-ui.js) call
|
|
@@ -255,13 +361,17 @@ class FullStatusBar {
|
|
|
255
361
|
_renderBar() { this._notify(); }
|
|
256
362
|
|
|
257
363
|
_notify() {
|
|
258
|
-
|
|
364
|
+
// Every redraw — the driver's coordinated tick AND event-driven
|
|
365
|
+
// (update/updateMetrics/setCost) — flows through here. Pausing is done by
|
|
366
|
+
// stopping the driver (see pause/_syncDriver), NOT by gating here: a guard
|
|
367
|
+
// would also suppress the event-driven repaints that must keep working
|
|
368
|
+
// while idle scroll is paused.
|
|
259
369
|
this._onChange();
|
|
260
370
|
}
|
|
261
371
|
|
|
262
372
|
destroy() {
|
|
263
|
-
|
|
264
|
-
|
|
373
|
+
this._destroyed = true;
|
|
374
|
+
this._driver.stop();
|
|
265
375
|
}
|
|
266
376
|
}
|
|
267
377
|
|
package/lib/ui/stream.js
CHANGED
|
@@ -8,6 +8,24 @@ const writer = require('./writer');
|
|
|
8
8
|
|
|
9
9
|
const THINK_OPEN = '<think>', THINK_CLOSE = '</think>';
|
|
10
10
|
|
|
11
|
+
// Per-line syntax highlight for a code-block body line. Module-level (uses no
|
|
12
|
+
// instance state) so the TUI styler (lib/ui/md-stream.js) can compose the SAME
|
|
13
|
+
// highlighter the non-TUI StreamRenderer uses — one implementation, no drift.
|
|
14
|
+
// Resets to `${RST}${FG_CODE_BG}` after each span so the caller's code-block
|
|
15
|
+
// background survives to the end of the line (where an EL fills it to the edge).
|
|
16
|
+
function colorizeCode(line) {
|
|
17
|
+
const C_KW = '\x1b[38;5;176m', C_STR = '\x1b[38;5;114m', C_CMT = '\x1b[38;5;242m', C_NUM = '\x1b[38;5;215m', C_RST = `${RST}${FG_CODE_BG}`;
|
|
18
|
+
let result = '', i = 0;
|
|
19
|
+
while (i < line.length) {
|
|
20
|
+
if (line[i] === '#' || (line[i] === '/' && line[i+1] === '/')) { result += `${C_CMT}${line.slice(i)}${C_RST}`; break; }
|
|
21
|
+
if (line[i] === '"' || line[i] === "'") { const quote = line[i]; let j = i+1; while (j < line.length && line[j] !== quote) { if (line[j] === '\\') j++; j++; } j = Math.min(j+1, line.length); result += `${C_STR}${line.slice(i,j)}${C_RST}`; i = j; continue; }
|
|
22
|
+
if (/[a-zA-Z_]/.test(line[i])) { let j = i; while (j < line.length && /\w/.test(line[j])) j++; const word = line.slice(i,j); result += KEYWORDS.has(word) ? `${C_KW}${word}${C_RST}` : word; i = j; continue; }
|
|
23
|
+
if (/\d/.test(line[i])) { let j = i; while (j < line.length && /[\d.]/.test(line[j])) j++; result += `${C_NUM}${line.slice(i,j)}${C_RST}`; i = j; continue; }
|
|
24
|
+
result += line[i++];
|
|
25
|
+
}
|
|
26
|
+
return result;
|
|
27
|
+
}
|
|
28
|
+
|
|
11
29
|
class StreamRenderer {
|
|
12
30
|
constructor(options) {
|
|
13
31
|
const opts = options || {};
|
|
@@ -137,18 +155,7 @@ class StreamRenderer {
|
|
|
137
155
|
writer.scrollback(` ${FG_TAG}◆ ${name}${RST} ${FG_DARK}${preview}${RST}`);
|
|
138
156
|
}
|
|
139
157
|
|
|
140
|
-
_colorizeCode(line) {
|
|
141
|
-
const C_KW = '\x1b[38;5;176m', C_STR = '\x1b[38;5;114m', C_CMT = '\x1b[38;5;242m', C_NUM = '\x1b[38;5;215m', C_RST = `${RST}${FG_CODE_BG}`;
|
|
142
|
-
let result = '', i = 0;
|
|
143
|
-
while (i < line.length) {
|
|
144
|
-
if (line[i] === '#' || (line[i] === '/' && line[i+1] === '/')) { result += `${C_CMT}${line.slice(i)}${C_RST}`; break; }
|
|
145
|
-
if (line[i] === '"' || line[i] === "'") { const quote = line[i]; let j = i+1; while (j < line.length && line[j] !== quote) { if (line[j] === '\\') j++; j++; } j = Math.min(j+1, line.length); result += `${C_STR}${line.slice(i,j)}${C_RST}`; i = j; continue; }
|
|
146
|
-
if (/[a-zA-Z_]/.test(line[i])) { let j = i; while (j < line.length && /\w/.test(line[j])) j++; const word = line.slice(i,j); result += KEYWORDS.has(word) ? `${C_KW}${word}${C_RST}` : word; i = j; continue; }
|
|
147
|
-
if (/\d/.test(line[i])) { let j = i; while (j < line.length && /[\d.]/.test(line[j])) j++; result += `${C_NUM}${line.slice(i,j)}${C_RST}`; i = j; continue; }
|
|
148
|
-
result += line[i++];
|
|
149
|
-
}
|
|
150
|
-
return result;
|
|
151
|
-
}
|
|
158
|
+
_colorizeCode(line) { return colorizeCode(line); }
|
|
152
159
|
}
|
|
153
160
|
|
|
154
|
-
module.exports = { StreamRenderer };
|
|
161
|
+
module.exports = { StreamRenderer, colorizeCode };
|