@semalt-ai/code 1.8.4 → 1.19.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 +8 -1
- package/.github/workflows/ci.yml +69 -0
- package/CLAUDE.md +1588 -27
- package/README.md +147 -3
- package/TECHNICAL_DEBT.md +66 -0
- package/examples/embed.js +74 -0
- package/index.js +259 -11
- package/lib/agent.js +935 -181
- package/lib/api.js +308 -55
- package/lib/args.js +96 -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 +306 -0
- package/lib/commands/chat-slash.js +399 -0
- package/lib/commands/chat-turn.js +446 -0
- package/lib/commands/chat.js +403 -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 +346 -11
- package/lib/constants.js +372 -3
- package/lib/debug.js +106 -0
- package/lib/deny.js +199 -0
- package/lib/doctor.js +160 -0
- package/lib/headless.js +167 -0
- package/lib/hooks.js +286 -0
- package/lib/images.js +264 -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 +100 -10
- package/lib/pricing.js +67 -0
- package/lib/proc.js +158 -0
- package/lib/prompts.js +88 -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 +2558 -0
- package/lib/tool_specs.js +236 -9
- package/lib/tools.js +370 -944
- package/lib/ui/chat-history.js +19 -1
- package/lib/ui/format.js +101 -6
- package/lib/ui/input-field.js +16 -7
- package/lib/ui/status-bar.js +79 -11
- package/lib/ui/terminal.js +10 -4
- package/lib/ui/theme.js +1 -0
- package/lib/ui/web-activity.js +218 -0
- package/lib/ui/writer.js +7 -9
- 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/background.test.js +414 -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/deny-windows.test.js +120 -0
- package/test/deny.test.js +83 -0
- package/test/download-allow-anywhere.test.js +66 -0
- package/test/download-confine.test.js +153 -0
- package/test/executors.test.js +362 -0
- package/test/extract-tool-calls.test.js +315 -0
- package/test/fetch-url-validation.test.js +219 -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/harness/README.md +57 -0
- package/test/harness/chat-harness.js +142 -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 +203 -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/max-iterations.test.js +216 -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/memory-truncation-warning.test.js +222 -0
- package/test/memory.test.js +198 -0
- package/test/native-dispatch.test.js +356 -0
- package/test/output-chokepoint.test.js +188 -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 +163 -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/result-cap.test.js +233 -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-pause.test.js +164 -0
- package/test/stream-parser.test.js +147 -0
- package/test/subagents-agent.test.js +178 -0
- package/test/subagents.test.js +222 -0
- package/test/tool-registry.test.js +85 -0
- package/test/trim-budget.test.js +101 -0
- package/test/verify-agent.test.js +317 -0
- package/test/verify.test.js +141 -0
- package/test/web-activity-ordering.test.js +194 -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 -1288
package/lib/ui/chat-history.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
const { RST, DIM, BOLD, FG_CYAN, FG_GREEN, FG_YELLOW, FG_RED, FG_DARK, FG_GRAY } = require('./ansi');
|
|
4
4
|
const { getCols, stripAnsi } = require('./utils');
|
|
5
5
|
const { UI_THEME, UI_ICONS, TOOL_CATEGORIES } = require('./theme');
|
|
6
|
+
const { summarizeToolResult } = require('./format');
|
|
6
7
|
const writer = require('./writer');
|
|
7
8
|
|
|
8
9
|
|
|
@@ -147,7 +148,24 @@ class ChatHistory {
|
|
|
147
148
|
// scrollback via endActivity — when a caller supplies an empty
|
|
148
149
|
// `content` they're signalling that the header is already present
|
|
149
150
|
// and only the expandable output body should render here.
|
|
150
|
-
|
|
151
|
+
//
|
|
152
|
+
// History-loaded tool messages arrive with the raw stored payload as
|
|
153
|
+
// `content` and no `output` — collapse them through summarizeToolResult
|
|
154
|
+
// to a single live-activity-style line. Callers that pass `output`
|
|
155
|
+
// (debug blocks, live-activity error pass-through) keep the legacy
|
|
156
|
+
// header chrome.
|
|
157
|
+
if (msg.content && !msg.output) {
|
|
158
|
+
const summary = safeContent(summarizeToolResult(msg.content));
|
|
159
|
+
if (summary) {
|
|
160
|
+
const indicator = msg.isError
|
|
161
|
+
? `${UI_THEME.error}${UI_ICONS.error}${RST}`
|
|
162
|
+
: `${UI_THEME.success}${UI_ICONS.success}${RST}`;
|
|
163
|
+
const sep = ` ${DIM}·${RST} `;
|
|
164
|
+
const styled = summary.split(' · ').map((p) => _dimPaths(p)).join(sep);
|
|
165
|
+
out = ` ${indicator} ${styled}\n`;
|
|
166
|
+
lineCount = 1;
|
|
167
|
+
}
|
|
168
|
+
} else if (content) {
|
|
151
169
|
const indicator = msg.isError
|
|
152
170
|
? `${UI_THEME.error}${UI_ICONS.error}${RST}`
|
|
153
171
|
: `${UI_THEME.success}${UI_ICONS.success}${RST}`;
|
package/lib/ui/format.js
CHANGED
|
@@ -95,11 +95,28 @@ function _categoryLabel(tag) {
|
|
|
95
95
|
return cat.length >= CATEGORY_WIDTH ? cat.slice(0, CATEGORY_WIDTH) : cat.padEnd(CATEGORY_WIDTH);
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
|
|
98
|
+
// Display-only normalizer for tool argument text. Collapses every run of
|
|
99
|
+
// whitespace (including \n from heredocs and `\<NL>` line continuations)
|
|
100
|
+
// to a single space and trims the ends, so the summary line never spills
|
|
101
|
+
// across multiple physical rows. Pure: never mutates the value used for
|
|
102
|
+
// execution or sent to the model.
|
|
103
|
+
function normalizeCmdForDisplay(text) {
|
|
99
104
|
if (text == null) return '';
|
|
100
|
-
|
|
105
|
+
return String(text).replace(/\s+/g, ' ').trim();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Truncate to fit `max` visible chars, normalizing first so a multi-line
|
|
109
|
+
// command becomes a single visual line. When the cut would land mid-word,
|
|
110
|
+
// back up to the nearest space — but only if it doesn't sacrifice more
|
|
111
|
+
// than ~30% of the available width (otherwise prefer the harder cut).
|
|
112
|
+
function _truncate(text, max) {
|
|
113
|
+
const s = normalizeCmdForDisplay(text);
|
|
101
114
|
if (s.length <= max) return s;
|
|
102
|
-
|
|
115
|
+
const cap = Math.max(0, max - 1);
|
|
116
|
+
let cut = s.slice(0, cap);
|
|
117
|
+
const lastSpace = cut.lastIndexOf(' ');
|
|
118
|
+
if (lastSpace > cap * 0.7) cut = cut.slice(0, lastSpace);
|
|
119
|
+
return cut + '…';
|
|
103
120
|
}
|
|
104
121
|
|
|
105
122
|
// Verb + target string. Never more than ~80 chars visible — longer URLs,
|
|
@@ -136,7 +153,28 @@ function _operation(tag, arg, attrs) {
|
|
|
136
153
|
case 'recall_memory': return _truncate(`recall ${a.key || arg || ''}`, max);
|
|
137
154
|
case 'list_memories': return 'list memories';
|
|
138
155
|
case 'system_info': return 'system info';
|
|
139
|
-
default:
|
|
156
|
+
default:
|
|
157
|
+
if (_normalizeTag(tag).startsWith('git_')) return _truncate(_gitOperation(_normalizeTag(tag), a), max);
|
|
158
|
+
// arg may be a structured options object for newer tools — avoid rendering
|
|
159
|
+
// "[object Object]" by only appending string/number args.
|
|
160
|
+
return _truncate((arg && typeof arg !== 'object') ? `${tag} ${arg}` : tag, max);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Human-readable one-liner for the native git tools (Task 5.1). `a` is the
|
|
165
|
+
// options object (attrs) carried by the call.
|
|
166
|
+
function _gitOperation(tag, a) {
|
|
167
|
+
const paths = Array.isArray(a.paths) ? a.paths.join(' ') : (a.paths || '');
|
|
168
|
+
switch (tag) {
|
|
169
|
+
case 'git_status': return 'git status';
|
|
170
|
+
case 'git_diff': return `git diff${a.staged ? ' --staged' : ''}${a.path ? ' ' + a.path : ''}`;
|
|
171
|
+
case 'git_log': return `git log${a.count ? ' -n ' + a.count : ''}${a.path ? ' ' + a.path : ''}`;
|
|
172
|
+
case 'git_add': return `git add ${a.all ? '-A' : paths}`.trim();
|
|
173
|
+
case 'git_commit': return `git commit${a.all ? ' -a' : ''} -m "${normalizeCmdForDisplay(a.message || '')}"`;
|
|
174
|
+
case 'git_branch': return a.name ? `git branch ${a.delete ? '-d ' : ''}${a.name}` : 'git branch';
|
|
175
|
+
case 'git_checkout': return `git checkout ${a.create ? '-b ' : ''}${a.name || ''}`.trim();
|
|
176
|
+
case 'git_worktree': return `git worktree ${a.op || 'list'}${a.path ? ' ' + a.path : ''}`;
|
|
177
|
+
default: return tag;
|
|
140
178
|
}
|
|
141
179
|
}
|
|
142
180
|
|
|
@@ -199,6 +237,7 @@ function formatToolLine(args) {
|
|
|
199
237
|
durationMs,
|
|
200
238
|
meta,
|
|
201
239
|
error,
|
|
240
|
+
noDuration,
|
|
202
241
|
} = args || {};
|
|
203
242
|
|
|
204
243
|
let glyph;
|
|
@@ -218,7 +257,6 @@ function formatToolLine(args) {
|
|
|
218
257
|
const catColor = (UI_THEME.categories && UI_THEME.categories[_normalizeTag(tag) && (TOOL_CATEGORIES[_normalizeTag(tag)] || 'tool')]) || UI_THEME.accent;
|
|
219
258
|
|
|
220
259
|
const op = _operation(tag, arg, attrs);
|
|
221
|
-
const durStr = formatDuration(durationMs || 0) + (status === 'pending' ? '…' : '');
|
|
222
260
|
const metaParts = _metaParts(tag, meta, error);
|
|
223
261
|
|
|
224
262
|
// Segment-by-segment styling. Each fragment carries its own ANSI codes
|
|
@@ -232,16 +270,73 @@ function formatToolLine(args) {
|
|
|
232
270
|
const segments = [];
|
|
233
271
|
segments.push(` ${glyphColor}${glyph}${RST} ${catColor}${cat}${RST}`);
|
|
234
272
|
segments.push(`${UI_THEME.default}${op}${RST}`);
|
|
235
|
-
|
|
273
|
+
if (!noDuration) {
|
|
274
|
+
const durStr = formatDuration(durationMs || 0) + (status === 'pending' ? '…' : '');
|
|
275
|
+
segments.push(`${durColor}${durStr}${RST}`);
|
|
276
|
+
}
|
|
236
277
|
for (const m of metaParts) {
|
|
237
278
|
if (m) segments.push(`${metaColor}${m}${RST}`);
|
|
238
279
|
}
|
|
239
280
|
return segments.join(sep);
|
|
240
281
|
}
|
|
241
282
|
|
|
283
|
+
// Collapse a stored tool-result string to a single-line summary in the same
|
|
284
|
+
// shape as the live activity bubble ("net · GET https://x · 200 · 256 KB").
|
|
285
|
+
// Pure: no ANSI, no IO, no allocation beyond the returned string. Idempotent
|
|
286
|
+
// — a value that already looks like a summary is returned untouched so the
|
|
287
|
+
// helper survives double-application once a separate display field lands in
|
|
288
|
+
// storage.
|
|
289
|
+
function summarizeToolResult(content) {
|
|
290
|
+
if (typeof content !== 'string' || !content) return '';
|
|
291
|
+
const trimmed = content.trim();
|
|
292
|
+
if (!trimmed) return '';
|
|
293
|
+
|
|
294
|
+
if (!trimmed.includes('\n') && trimmed.includes(' · ') && trimmed.length < 200) {
|
|
295
|
+
return trimmed;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// HTTP: agent.js formats as `HTTP <VERB> <url> (<status>):\n<body>`.
|
|
299
|
+
const httpMatch = content.match(/^HTTP\s+(\w+)\s+(\S+)\s+\((\d+)\)/);
|
|
300
|
+
if (httpMatch) {
|
|
301
|
+
const parts = ['net', `${httpMatch[1]} ${httpMatch[2]}`, httpMatch[3]];
|
|
302
|
+
const bytes = formatBytes(Buffer.byteLength(content, 'utf8'));
|
|
303
|
+
if (bytes) parts.push(bytes);
|
|
304
|
+
return parts.join(' · ');
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Exec: `Command \`<cmd>\`:\nExit code: <N>\n<output>`. The cmd may span
|
|
308
|
+
// multiple lines if the user passed a heredoc — non-greedy capture stops
|
|
309
|
+
// at the first `\`:` boundary, then we collapse whitespace for the preview.
|
|
310
|
+
const cmdMatch = content.match(/^Command `([\s\S]+?)`:/);
|
|
311
|
+
const exitMatch = content.match(/^Exit code: (-?\d+)$/m);
|
|
312
|
+
if (cmdMatch && exitMatch) {
|
|
313
|
+
const cmd = cmdMatch[1].replace(/\s+/g, ' ').trim();
|
|
314
|
+
const preview = cmd.length > 60 ? cmd.slice(0, 59) + '…' : cmd;
|
|
315
|
+
return `exec · ${preview} · exit ${exitMatch[1]}`;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const lines = content.split('\n');
|
|
319
|
+
if (lines.length <= 3 && /^(Wrote|Read|Created|Deleted|Moved|Renamed)\b/.test(lines[0])) {
|
|
320
|
+
return lines[0];
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (lines.length > 1 || content.length > 120) {
|
|
324
|
+
const firstLine = lines[0] || '';
|
|
325
|
+
const preview = firstLine.length > 100 ? firstLine.slice(0, 99) + '…' : firstLine;
|
|
326
|
+
const parts = ['tool', preview];
|
|
327
|
+
const bytes = formatBytes(Buffer.byteLength(content, 'utf8'));
|
|
328
|
+
if (bytes) parts.push(bytes);
|
|
329
|
+
return parts.join(' · ');
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return trimmed;
|
|
333
|
+
}
|
|
334
|
+
|
|
242
335
|
module.exports = {
|
|
243
336
|
formatDuration,
|
|
244
337
|
formatBytes,
|
|
245
338
|
formatHttpErrorTag,
|
|
246
339
|
formatToolLine,
|
|
340
|
+
summarizeToolResult,
|
|
341
|
+
normalizeCmdForDisplay,
|
|
247
342
|
};
|
package/lib/ui/input-field.js
CHANGED
|
@@ -7,10 +7,13 @@ const { RST, DIM, FG_CYAN, FG_GREEN, FG_YELLOW } = require('./ansi');
|
|
|
7
7
|
const { stripAnsi, termWidth } = require('./utils');
|
|
8
8
|
const writer = require('./writer');
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
10
|
+
// Tab-completion command list is generated from the slash-command registry —
|
|
11
|
+
// the single source of truth — so it can never drift from the dispatcher.
|
|
12
|
+
// Resolved LIVE (not snapshotted at load) so commands registered after this
|
|
13
|
+
// module loads — notably the Markdown custom commands discovered at chat
|
|
14
|
+
// startup (Task 3.1) — appear in completion. The exported `SLASH_CMDS` is kept
|
|
15
|
+
// for back-compat (since 1.3) but backed by the same live getter.
|
|
16
|
+
const { completionNames } = require('../commands/registry');
|
|
14
17
|
|
|
15
18
|
// ─── Key sequence parser ──────────────────────────────────────────────────────
|
|
16
19
|
|
|
@@ -571,7 +574,7 @@ class InputField extends EventEmitter {
|
|
|
571
574
|
sources.push({ type: 'history', text });
|
|
572
575
|
for (const item of this._searchExtraItems)
|
|
573
576
|
sources.push(item);
|
|
574
|
-
for (const cmd of
|
|
577
|
+
for (const cmd of completionNames())
|
|
575
578
|
sources.push({ type: 'command', text: cmd });
|
|
576
579
|
return sources;
|
|
577
580
|
}
|
|
@@ -635,7 +638,7 @@ class InputField extends EventEmitter {
|
|
|
635
638
|
}
|
|
636
639
|
|
|
637
640
|
if (!val.startsWith('/')) return;
|
|
638
|
-
const matches =
|
|
641
|
+
const matches = completionNames().filter(c => c.startsWith(val));
|
|
639
642
|
if (!matches.length) return;
|
|
640
643
|
if (matches.length === 1) {
|
|
641
644
|
this._setValue(matches[0] + ' ');
|
|
@@ -1266,4 +1269,10 @@ class InputField extends EventEmitter {
|
|
|
1266
1269
|
}
|
|
1267
1270
|
}
|
|
1268
1271
|
|
|
1269
|
-
module.exports = { InputField, parseKeySequence
|
|
1272
|
+
module.exports = { InputField, parseKeySequence };
|
|
1273
|
+
// SLASH_CMDS kept for back-compat (since 1.3), but now a live getter so callers
|
|
1274
|
+
// reading it after custom-command registration see the current set.
|
|
1275
|
+
Object.defineProperty(module.exports, 'SLASH_CMDS', {
|
|
1276
|
+
enumerable: true,
|
|
1277
|
+
get() { return completionNames(); },
|
|
1278
|
+
});
|
package/lib/ui/status-bar.js
CHANGED
|
@@ -4,6 +4,17 @@ const { RST, DIM, FG_RED, FG_DARK, SPINNER_DEFS } = require('./ansi');
|
|
|
4
4
|
const { UI_THEME } = require('./theme');
|
|
5
5
|
const { stripAnsi, termWidth } = require('./utils');
|
|
6
6
|
|
|
7
|
+
// Compact token count for the estimated split (Variant B): the base/working
|
|
8
|
+
// estimates are shown abbreviated (e.g. 12k, 5.6k, 200k) to keep the row short
|
|
9
|
+
// — they are estimates, so sub-thousand precision is noise. The real measured
|
|
10
|
+
// total is rendered separately with full toLocaleString precision (no ~).
|
|
11
|
+
function abbrevTokens(n) {
|
|
12
|
+
const v = Math.max(0, Math.round(Number(n) || 0));
|
|
13
|
+
if (v < 1000) return String(v);
|
|
14
|
+
const k = v / 1000;
|
|
15
|
+
return (k < 10 ? k.toFixed(1) : String(Math.round(k))) + 'k';
|
|
16
|
+
}
|
|
17
|
+
|
|
7
18
|
// Status bar is a *content producer* only. It builds a single line string
|
|
8
19
|
// and hands it to the UI orchestrator via the onChange callback; the
|
|
9
20
|
// orchestrator composes the full live region (status + input + hints) and
|
|
@@ -27,6 +38,11 @@ class FullStatusBar {
|
|
|
27
38
|
this._reportedContext = 0;
|
|
28
39
|
this._pendingDelta = 0;
|
|
29
40
|
this._contextLimit = null;
|
|
41
|
+
// Estimated base/working split (Variant B, display-only). Both are char/4
|
|
42
|
+
// estimates of the same measured prompt; rendered ~-prefixed alongside the
|
|
43
|
+
// real total. 0 until the agent loop reports the first estimate.
|
|
44
|
+
this._baseEst = 0;
|
|
45
|
+
this._workingEst = 0;
|
|
30
46
|
this._speed = 0;
|
|
31
47
|
this._streamStart = null;
|
|
32
48
|
this._streamTokens = 0;
|
|
@@ -37,22 +53,53 @@ class FullStatusBar {
|
|
|
37
53
|
// Clock tick drives the `HH:MM:SS` part of the right-hand side. Every
|
|
38
54
|
// tick just notifies the orchestrator to re-push the live region — the
|
|
39
55
|
// compound erase+redraw goes through the writer's queue so a tick
|
|
40
|
-
// falling mid-bubble can't produce a torn frame.
|
|
56
|
+
// falling mid-bubble can't produce a torn frame. The tick is what fights
|
|
57
|
+
// user scrollback while idle, so pause()/resume() START/STOP this timer
|
|
58
|
+
// (see _startClock/_stopClock) rather than gating _notify with a flag.
|
|
59
|
+
this._clockTimer = null;
|
|
60
|
+
this._startClock();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Idempotent clock control. pause() stops the once-per-second redraw so the
|
|
64
|
+
// terminal viewport can scroll freely while idle; resume() restarts it.
|
|
65
|
+
// Guarded so repeated pause()/resume() cycles never stack a second timer.
|
|
66
|
+
_startClock() {
|
|
67
|
+
if (this._clockTimer) return;
|
|
41
68
|
this._clockTimer = setInterval(() => this._notify(), 1000);
|
|
42
69
|
}
|
|
43
70
|
|
|
44
|
-
|
|
45
|
-
|
|
71
|
+
_stopClock() {
|
|
72
|
+
if (this._clockTimer) { clearInterval(this._clockTimer); this._clockTimer = null; }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// pause() genuinely halts the periodic redraw (clears the clock timer) so a
|
|
76
|
+
// forced redraw no longer snaps the viewport to the bottom while the user
|
|
77
|
+
// scrolls up. Event-driven redraws (update/updateMetrics/setCost/spinner)
|
|
78
|
+
// are unaffected — they call _notify directly. resume() restarts the clock
|
|
79
|
+
// and forces one repaint so the viewport returns to the input prompt.
|
|
80
|
+
pause() { this._paused = true; this._stopClock(); }
|
|
81
|
+
resume() { this._paused = false; this._startClock(); this._notify(); }
|
|
46
82
|
|
|
47
83
|
setModel(name) {
|
|
48
84
|
this._model = name || '';
|
|
49
85
|
this._notify();
|
|
50
86
|
}
|
|
51
87
|
|
|
88
|
+
// Session cost string (e.g. "$0.0123" or "unknown"), shown when show_cost is
|
|
89
|
+
// on (Task 2.6). null/'' hides the field. The caller computes the value from
|
|
90
|
+
// the price table × usage; the bar just renders it.
|
|
91
|
+
setCost(str) {
|
|
92
|
+
this._cost = str || null;
|
|
93
|
+
this._notify();
|
|
94
|
+
}
|
|
95
|
+
|
|
52
96
|
update(state, label) {
|
|
53
97
|
this._state = state || 'idle';
|
|
54
98
|
if (label !== undefined) this._label = label;
|
|
99
|
+
// A state change means work is happening — keep the not-paused ⇒
|
|
100
|
+
// clock-running invariant (idempotent, so no stacked timer).
|
|
55
101
|
this._paused = false;
|
|
102
|
+
this._startClock();
|
|
56
103
|
|
|
57
104
|
if (state === 'streaming') {
|
|
58
105
|
if (!this._streamStart) { this._streamStart = Date.now(); this._streamTokens = 0; }
|
|
@@ -92,6 +139,10 @@ class FullStatusBar {
|
|
|
92
139
|
this._reportedContext = data.contextTokens;
|
|
93
140
|
this._pendingDelta = 0;
|
|
94
141
|
}
|
|
142
|
+
// Estimated split (Variant B) — recomputed per request by the api client and
|
|
143
|
+
// threaded through here, so it tracks MCP-connect / plan-mode / native-vs-XML.
|
|
144
|
+
if (typeof data.baseEst === 'number') this._baseEst = data.baseEst;
|
|
145
|
+
if (typeof data.workingEst === 'number') this._workingEst = data.workingEst;
|
|
95
146
|
if (data.tokenLimit && typeof data.tokenLimit.limit === 'number') {
|
|
96
147
|
this._contextLimit = data.tokenLimit.limit;
|
|
97
148
|
}
|
|
@@ -122,9 +173,9 @@ class FullStatusBar {
|
|
|
122
173
|
// to build the live region. ALWAYS returns a string — the status row is a
|
|
123
174
|
// permanent fixture of the live region, never omitted. Missing data
|
|
124
175
|
// renders as a short placeholder ("—") so the row width is stable.
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
176
|
+
// Pausing (idle scroll) stops the periodic *tick* (the clock timer is
|
|
177
|
+
// cleared) but the row itself is still produced whenever the composer asks
|
|
178
|
+
// for it — an event-driven _notify still repaints normally.
|
|
128
179
|
renderLine() {
|
|
129
180
|
const layout = this._layout;
|
|
130
181
|
const cols = layout.cols;
|
|
@@ -168,6 +219,10 @@ class FullStatusBar {
|
|
|
168
219
|
{ visible: this._model || '—', ansi: this._model || '—', priority: 4 },
|
|
169
220
|
{ visible: tokenField.visible, ansi: tokenField.ansi, priority: 3 },
|
|
170
221
|
];
|
|
222
|
+
if (this._cost) {
|
|
223
|
+
const c = `${this._cost}`;
|
|
224
|
+
fields.push({ visible: c, ansi: c, priority: 2 });
|
|
225
|
+
}
|
|
171
226
|
if (state === 'streaming' && this._speed > 0) {
|
|
172
227
|
const s = `${this._speed} t/s`;
|
|
173
228
|
fields.push({ visible: s, ansi: s, priority: 1 });
|
|
@@ -221,21 +276,30 @@ class FullStatusBar {
|
|
|
221
276
|
_buildTokenField() {
|
|
222
277
|
const used = Math.max(0, (this._reportedContext | 0) + (this._pendingDelta | 0));
|
|
223
278
|
const usedStr = used.toLocaleString();
|
|
279
|
+
// Estimated split prefix (Variant B): "~Nk working · ~Nk base · ". Working
|
|
280
|
+
// first (it's the part that grows and matters), base second (fixed-ish
|
|
281
|
+
// reference). Both carry ~ — they're char/4 estimates. The measured
|
|
282
|
+
// total/limit/percent that follows carries NO ~ (it's the truth anchor).
|
|
283
|
+
// Shown only once an estimate has arrived, so a fresh bar has no ~ noise.
|
|
284
|
+
let estPrefix = '';
|
|
285
|
+
if (this._workingEst > 0 || this._baseEst > 0) {
|
|
286
|
+
estPrefix = `~${abbrevTokens(this._workingEst)} working · ~${abbrevTokens(this._baseEst)} base · `;
|
|
287
|
+
}
|
|
224
288
|
if (!this._contextLimit) {
|
|
225
|
-
const s = `${usedStr} tok`;
|
|
289
|
+
const s = `${estPrefix}${usedStr} tok`;
|
|
226
290
|
return { visible: s, ansi: s };
|
|
227
291
|
}
|
|
228
292
|
const limit = this._contextLimit;
|
|
229
293
|
const pct = limit > 0 ? Math.round((used / limit) * 100) : 0;
|
|
230
294
|
const limitStr = limit.toLocaleString();
|
|
231
|
-
const visible = `${usedStr} / ${limitStr} tok (${pct}%)`;
|
|
295
|
+
const visible = `${estPrefix}${usedStr} / ${limitStr} tok (${pct}%)`;
|
|
232
296
|
let pctAnsi = `${pct}%`;
|
|
233
297
|
if (pct >= 90) {
|
|
234
298
|
pctAnsi = `${RST}${UI_THEME.error}${pct}%${RST}${DIM}`;
|
|
235
299
|
} else if (pct >= 70) {
|
|
236
300
|
pctAnsi = `${RST}${UI_THEME.warning}${pct}%${RST}${DIM}`;
|
|
237
301
|
}
|
|
238
|
-
const ansi = `${usedStr} / ${limitStr} tok (${pctAnsi})`;
|
|
302
|
+
const ansi = `${estPrefix}${usedStr} / ${limitStr} tok (${pctAnsi})`;
|
|
239
303
|
return { visible, ansi };
|
|
240
304
|
}
|
|
241
305
|
|
|
@@ -255,13 +319,17 @@ class FullStatusBar {
|
|
|
255
319
|
_renderBar() { this._notify(); }
|
|
256
320
|
|
|
257
321
|
_notify() {
|
|
258
|
-
|
|
322
|
+
// Every redraw — periodic tick AND event-driven (update/updateMetrics/
|
|
323
|
+
// setCost/spinner) — flows through here. Pausing is done by stopping the
|
|
324
|
+
// clock timer (see pause/_stopClock), NOT by gating here: a guard would
|
|
325
|
+
// also suppress the event-driven repaints that must keep working while
|
|
326
|
+
// idle scroll is paused.
|
|
259
327
|
this._onChange();
|
|
260
328
|
}
|
|
261
329
|
|
|
262
330
|
destroy() {
|
|
263
331
|
if (this._animTimer) { clearInterval(this._animTimer); this._animTimer = null; }
|
|
264
|
-
|
|
332
|
+
this._stopClock();
|
|
265
333
|
}
|
|
266
334
|
}
|
|
267
335
|
|
package/lib/ui/terminal.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// the process dies.
|
|
8
8
|
|
|
9
9
|
const writer = require('./writer');
|
|
10
|
+
const dbg = require('../debug');
|
|
10
11
|
|
|
11
12
|
let _registered = false;
|
|
12
13
|
|
|
@@ -30,20 +31,24 @@ function registerTerminalCleanup() {
|
|
|
30
31
|
|
|
31
32
|
// Normal exit + process.exit(): fires synchronously, last thing Node does.
|
|
32
33
|
// Catches every path that doesn't already manually call teardown.
|
|
33
|
-
process.on('exit', () => {
|
|
34
|
+
process.on('exit', () => {
|
|
35
|
+
try { writer.teardown(); } catch {}
|
|
36
|
+
try { dbg.close(); } catch {}
|
|
37
|
+
});
|
|
34
38
|
|
|
35
39
|
// Signals that should terminate the app. Cleanup first, then exit with
|
|
36
40
|
// the conventional 128+signum code. In TUI raw mode, Ctrl+C is consumed
|
|
37
41
|
// at the byte level and SIGINT is not delivered, so this handler only
|
|
38
42
|
// trips in non-raw contexts (one-shot commands, readline prompts, etc.).
|
|
39
|
-
process.on('SIGINT', () => { try { writer.teardown(); } catch {} process.exit(130); });
|
|
40
|
-
process.on('SIGTERM', () => { try { writer.teardown(); } catch {} process.exit(143); });
|
|
41
|
-
process.on('SIGHUP', () => { try { writer.teardown(); } catch {} process.exit(129); });
|
|
43
|
+
process.on('SIGINT', () => { try { writer.teardown(); } catch {} try { dbg.close(); } catch {} process.exit(130); });
|
|
44
|
+
process.on('SIGTERM', () => { try { writer.teardown(); } catch {} try { dbg.close(); } catch {} process.exit(143); });
|
|
45
|
+
process.on('SIGHUP', () => { try { writer.teardown(); } catch {} try { dbg.close(); } catch {} process.exit(129); });
|
|
42
46
|
|
|
43
47
|
// Last-chance net: if something throws outside a try/catch, still
|
|
44
48
|
// restore terminal state before the stack trace prints.
|
|
45
49
|
process.on('uncaughtException', (err) => {
|
|
46
50
|
try { writer.teardown(); } catch {}
|
|
51
|
+
try { dbg.close(); } catch {}
|
|
47
52
|
// audit: allowed — crash handler stderr after writer teardown.
|
|
48
53
|
try { console.error(err && err.stack ? err.stack : err); } catch {}
|
|
49
54
|
process.exit(1);
|
|
@@ -51,6 +56,7 @@ function registerTerminalCleanup() {
|
|
|
51
56
|
|
|
52
57
|
process.on('unhandledRejection', (reason) => {
|
|
53
58
|
try { writer.teardown(); } catch {}
|
|
59
|
+
try { dbg.close(); } catch {}
|
|
54
60
|
// audit: allowed — crash handler stderr after writer teardown.
|
|
55
61
|
try { console.error(reason && reason.stack ? reason.stack : reason); } catch {}
|
|
56
62
|
process.exit(1);
|
package/lib/ui/theme.js
CHANGED
|
@@ -21,6 +21,7 @@ const UI_THEME = {
|
|
|
21
21
|
// values produced by TOOL_CATEGORIES so a lookup is categories[cat].
|
|
22
22
|
categories: {
|
|
23
23
|
net: fg256(110), // dusty blue — http, download, upload
|
|
24
|
+
web: fg256(80), // aqua — collapsed web-activity summary (W.3)
|
|
24
25
|
file: fg256(151), // soft sage — file ops
|
|
25
26
|
cmd: fg256(180), // warm tan — shell / exec
|
|
26
27
|
user: fg256(217), // pale rose — ask_user
|