@semalt-ai/code 1.19.0 → 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 +2 -1
- package/ARCHITECTURE.md +6 -95
- package/CLAUDE.md +196 -1874
- package/README.md +1 -1
- package/docs/ARCHITECTURE.md +1321 -0
- package/docs/CONFIG.md +340 -0
- package/docs/HISTORY.md +245 -0
- package/index.js +1 -1
- package/lib/agent.js +145 -16
- package/lib/api.js +28 -3
- package/lib/commands/chat-session.js +187 -4
- package/lib/commands/chat-slash.js +16 -0
- package/lib/commands/chat-turn.js +272 -49
- package/lib/commands/chat.js +12 -8
- package/lib/config.js +27 -0
- package/lib/constants.js +30 -1
- package/lib/headless.js +36 -1
- package/lib/images.js +8 -2
- package/lib/permissions.js +23 -16
- package/lib/prompts.js +15 -3
- package/lib/tool_registry.js +357 -53
- package/lib/tool_specs.js +42 -8
- package/lib/tools.js +80 -19
- 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 +173 -28
- package/lib/ui/input-field.js +5 -4
- 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 +99 -57
- package/lib/ui/stream.js +20 -13
- package/lib/ui/theme.js +190 -45
- package/lib/ui/tool-operation.js +190 -0
- package/lib/ui/utils.js +9 -5
- package/lib/ui/web-activity.js +58 -6
- package/lib/ui/writer.js +159 -45
- package/lib/ui.js +1 -1
- package/package.json +1 -1
- 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/chat-history-nocolor.test.js +155 -0
- package/test/chat-relogin.test.js +207 -0
- package/test/defer-detail-band.test.js +403 -0
- package/test/detail-band-tab-flatten.test.js +242 -0
- package/test/exec-diff.test.js +268 -0
- package/test/executors.test.js +250 -13
- package/test/extract-tool-calls.test.js +37 -3
- package/test/file-activity.test.js +522 -0
- package/test/grep-path-target.test.js +227 -0
- package/test/harness/chat-harness.js +2 -1
- package/test/headless.test.js +146 -1
- 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 +9 -7
- package/test/md-stream.test.js +183 -0
- package/test/native-dispatch.test.js +53 -0
- package/test/native-live-narration.test.js +254 -0
- package/test/output-heredoc-leak.test.js +195 -0
- package/test/output-preview.test.js +245 -0
- package/test/permissions.test.js +199 -0
- package/test/read-paginate.test.js +1 -1
- 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/running-glyph-anim.test.js +111 -0
- package/test/status-bar-driver.test.js +93 -0
- package/test/status-bar-resync.test.js +188 -0
- package/test/stream-parser.test.js +24 -0
- package/test/theme-palette.test.js +166 -0
- package/test/truncate-visible.test.js +78 -0
- package/test/view-image.test.js +199 -0
- package/test/web-activity-ordering.test.js +12 -3
- package/path +0 -1
package/lib/tool_specs.js
CHANGED
|
@@ -81,6 +81,25 @@ const TOOL_SPECS = {
|
|
|
81
81
|
},
|
|
82
82
|
},
|
|
83
83
|
|
|
84
|
+
view_image: {
|
|
85
|
+
description: 'Load a LOCAL image file (PNG, JPEG, GIF, or WebP) into YOUR OWN vision context so you can '
|
|
86
|
+
+ 'analyze it — read text/diagrams in it, inspect a screenshot, compare a mockup, etc. The image is made '
|
|
87
|
+
+ 'visible to YOU (the model) for the next turn; it is NOT displayed to the user, so never say "take a look" '
|
|
88
|
+
+ 'or otherwise refer to the image as something the user can see. To analyze an image that lives at a URL, '
|
|
89
|
+
+ 'first use `download <url>` to save it to the working directory, then call view_image on the saved path. '
|
|
90
|
+
+ 'Unsupported formats, oversized files (image_max_bytes), or missing paths return a clear error, not a crash.',
|
|
91
|
+
parameters: {
|
|
92
|
+
type: 'object',
|
|
93
|
+
properties: {
|
|
94
|
+
path: {
|
|
95
|
+
type: 'string',
|
|
96
|
+
description: 'Absolute or relative path to a local image file (PNG/JPEG/GIF/WebP) to load into vision context',
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
required: ['path'],
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
|
|
84
103
|
write_file: {
|
|
85
104
|
description: 'Write a file, creating it and any missing parent directories; overwrites existing content.',
|
|
86
105
|
parameters: {
|
|
@@ -246,7 +265,7 @@ const TOOL_SPECS = {
|
|
|
246
265
|
},
|
|
247
266
|
|
|
248
267
|
edit_file: {
|
|
249
|
-
description: 'Replace
|
|
268
|
+
description: 'Replace a single line, or a contiguous range of lines, in a file by 1-based line number. With end_line set, lines line..end_line are replaced wholesale by content (which may itself span multiple lines) — a regex-free way to swap a block: read the slice with read_file (start_line/end_line + show_line_numbers), then replace that exact range. For large block edits this and a literal replace_in_file are the two supported paths.',
|
|
250
269
|
parameters: {
|
|
251
270
|
type: 'object',
|
|
252
271
|
properties: {
|
|
@@ -256,12 +275,17 @@ const TOOL_SPECS = {
|
|
|
256
275
|
},
|
|
257
276
|
line: {
|
|
258
277
|
type: 'integer',
|
|
259
|
-
description: '1-based line number of the line to replace',
|
|
278
|
+
description: '1-based line number of the (first) line to replace',
|
|
279
|
+
minimum: 1,
|
|
280
|
+
},
|
|
281
|
+
end_line: {
|
|
282
|
+
type: 'integer',
|
|
283
|
+
description: 'Optional 1-based last line of the range to replace (inclusive). Omit for a single-line edit. Must be >= line and within the file.',
|
|
260
284
|
minimum: 1,
|
|
261
285
|
},
|
|
262
286
|
content: {
|
|
263
287
|
type: 'string',
|
|
264
|
-
description: 'New text for the target line;
|
|
288
|
+
description: 'New text for the target line or range; may contain newlines to expand a range into several lines. Trailing newline is added automatically when the file is rejoined.',
|
|
265
289
|
default: '',
|
|
266
290
|
},
|
|
267
291
|
},
|
|
@@ -318,7 +342,7 @@ const TOOL_SPECS = {
|
|
|
318
342
|
},
|
|
319
343
|
path: {
|
|
320
344
|
type: 'string',
|
|
321
|
-
description: 'Optional
|
|
345
|
+
description: 'Optional search target: a FILE path (search just that file — absolute or relative, like search_in_file), a DIRECTORY path (search recursively under it), or a GLOB filter limiting which files in the working tree are searched, e.g. "*.js" or "src/**/*.ts". A path that is neither an existing file nor directory is treated as a glob. If a supplied path/glob matches nothing to search, grep returns a diagnostic error rather than a silent zero-match result.',
|
|
322
346
|
},
|
|
323
347
|
ignore_case: {
|
|
324
348
|
type: 'boolean',
|
|
@@ -372,7 +396,7 @@ const TOOL_SPECS = {
|
|
|
372
396
|
},
|
|
373
397
|
|
|
374
398
|
replace_in_file: {
|
|
375
|
-
description: '
|
|
399
|
+
description: 'Exact string replacement in a file (Claude Code Edit model). The search text is matched LITERALLY by default — byte-for-byte, NO regex — so paste the exact verbatim code, including all whitespace and indentation, even if it contains ( ) { } . [ ] $ etc. The match must be UNIQUE: if the search string is not found the call ERRORS and the file is unchanged; if it appears more than once the call ERRORS (asking you to add surrounding context) unless you set replace_all:true. Returns the honest number of replacements made. Set regex:true to interpret the search as a JavaScript regular expression instead (bounded: long or backtracking-prone regexes are rejected). For block edits by line number, see line-range edit_file.',
|
|
376
400
|
parameters: {
|
|
377
401
|
type: 'object',
|
|
378
402
|
properties: {
|
|
@@ -382,16 +406,26 @@ const TOOL_SPECS = {
|
|
|
382
406
|
},
|
|
383
407
|
search: {
|
|
384
408
|
type: 'string',
|
|
385
|
-
description: '
|
|
409
|
+
description: 'Exact text to find. Matched literally (verbatim, no regex) unless regex:true is set. Include enough surrounding context that it appears EXACTLY ONCE in the file, or set replace_all:true.',
|
|
386
410
|
},
|
|
387
411
|
replace: {
|
|
388
412
|
type: 'string',
|
|
389
|
-
description: 'Replacement string
|
|
413
|
+
description: 'Replacement string. In literal mode (default) it is inserted as raw text. In regex mode it supports the standard $1, $2, $& back-references.',
|
|
390
414
|
default: '',
|
|
391
415
|
},
|
|
416
|
+
replace_all: {
|
|
417
|
+
type: 'boolean',
|
|
418
|
+
description: 'Replace ALL occurrences instead of requiring a unique match. Without this, matching more than one occurrence is an error. The result reports the actual number replaced.',
|
|
419
|
+
default: false,
|
|
420
|
+
},
|
|
421
|
+
regex: {
|
|
422
|
+
type: 'boolean',
|
|
423
|
+
description: 'Opt into regex mode: interpret search as a JavaScript regular expression (bounded by a length cap + nested-quantifier guard). Default false = literal exact match. The uniqueness guard still applies in regex mode (use the g flag or replace_all to replace multiple).',
|
|
424
|
+
default: false,
|
|
425
|
+
},
|
|
392
426
|
flags: {
|
|
393
427
|
type: 'string',
|
|
394
|
-
description: 'Regex flags, any combination of g/i/m/s/u/y
|
|
428
|
+
description: 'Regex flags (only meaningful with regex:true), any combination of g/i/m/s/u/y. Without "g" (and without replace_all) the match must be unique.',
|
|
395
429
|
default: '',
|
|
396
430
|
},
|
|
397
431
|
},
|
package/lib/tools.js
CHANGED
|
@@ -122,24 +122,78 @@ function _protectedConfigWriteError(filePath) {
|
|
|
122
122
|
return { error: `Refused: ${filePath} is a protected config path (under ~/.semalt-ai or a project .semalt dir) that drives execution and cannot be written by the agent. (This guard is not overridable with --allow-anywhere.)` };
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
|
|
125
|
+
// Active (unescaped) regex metacharacters. A search pattern that contains NONE
|
|
126
|
+
// of these — or one the caller explicitly marks `literal` — is a plain literal:
|
|
127
|
+
// matching it (via split/join or indexOf) is O(dataLen) and CANNOT backtrack, so
|
|
128
|
+
// the regex-ReDoS bounds below DO NOT apply. This is what makes the intended
|
|
129
|
+
// copy-a-block-then-replace workflow work at any length (read_file defaults line
|
|
130
|
+
// numbers OFF specifically to keep snippets copyable, lib/agent.js): a long
|
|
131
|
+
// literal block is never rejected for its length.
|
|
132
|
+
const _REGEX_META = new Set(['.', '*', '+', '?', '^', '$', '{', '}', '(', ')', '|', '[', ']']);
|
|
133
|
+
|
|
134
|
+
function _hasActiveRegexMeta(pattern) {
|
|
135
|
+
if (typeof pattern !== 'string') return false;
|
|
136
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
137
|
+
const ch = pattern[i];
|
|
138
|
+
if (ch === '\\') { i++; continue; } // the escaped next char is inert, skip it
|
|
139
|
+
if (_REGEX_META.has(ch)) return true;
|
|
140
|
+
}
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Decide literal vs regex. A pattern is matched literally when the caller forces
|
|
145
|
+
// it (`literal: true` — for copied code blocks that legitimately contain
|
|
146
|
+
// regex-special chars like `(` or `[`), or when auto-detection finds no active
|
|
147
|
+
// regex metacharacter at all (the pasted plain-text-block case).
|
|
148
|
+
function _isLiteralPattern(pattern, literal) {
|
|
149
|
+
if (literal === true) return true;
|
|
150
|
+
return !_hasActiveRegexMeta(pattern);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ReDoS guard for the REGEX path only. Literals bypass it entirely — they cannot
|
|
154
|
+
// backtrack, so their length is irrelevant. Catastrophic backtracking comes from
|
|
155
|
+
// nested quantifiers (Check B below), NOT from pattern length: the old
|
|
156
|
+
// `dataLen * pattern.length` proxy (Check C) is gone because it penalized exactly
|
|
157
|
+
// the safe dimension — it rejected long *literals* (e.g. any block over ~250
|
|
158
|
+
// chars on a 40 KB file) while a short bomb like `(a+)+$` (length 6) sailed
|
|
159
|
+
// straight past it. For a genuine regex we keep two real protections:
|
|
160
|
+
// • a sanity length cap — a multi-thousand-char metacharacter-heavy pattern is
|
|
161
|
+
// suspicious and serves no legitimate purpose (literals use `literal:true`);
|
|
162
|
+
// • the nested-quantifier detector, which is the actual backtracking guard.
|
|
163
|
+
function _checkRegexSafety(pattern, data, literal) {
|
|
129
164
|
if (typeof pattern !== 'string') return null;
|
|
165
|
+
if (_isLiteralPattern(pattern, literal)) return null; // literal: O(dataLen), unbounded by length
|
|
130
166
|
if (pattern.length > 1000) {
|
|
131
|
-
return { error: '
|
|
167
|
+
return { error: 'Regex rejected: length exceeds 1000 chars (use literal:true to match a long block verbatim)' };
|
|
132
168
|
}
|
|
133
169
|
if (/(\(.*[+*].*\).*[+*])|(\[.*\].*[+*].*[+*])/.test(pattern)) {
|
|
134
170
|
return { error: 'Pattern rejected: potentially catastrophic backtracking' };
|
|
135
171
|
}
|
|
136
|
-
const dataLen = typeof data === 'string' ? data.length : 0;
|
|
137
|
-
if (dataLen * pattern.length > 10_000_000) {
|
|
138
|
-
return { error: 'Pattern too complex for input size' };
|
|
139
|
-
}
|
|
140
172
|
return null;
|
|
141
173
|
}
|
|
142
174
|
|
|
175
|
+
// The single authority for splitting an ask_user question into its menu. A line
|
|
176
|
+
// matching `^\s*\d+[.)]\s+(.+)$` is a numbered OPTION; every other line is
|
|
177
|
+
// PROMPT prose. Returns { prompt, options } where `prompt` is the non-numbered
|
|
178
|
+
// lines joined (trimmed) and `options` is the option labels — but ONLY when
|
|
179
|
+
// there are ≥2 of them (a lone "1." is prose, not a menu), matching the prior
|
|
180
|
+
// _parseNumberedOptions contract. Display-only: the caller still hands the FULL
|
|
181
|
+
// original question to the model. Pure; safe on null/non-string (auto-answer
|
|
182
|
+
// paths pass arbitrary text).
|
|
183
|
+
function parseAskMenu(text) {
|
|
184
|
+
const options = [];
|
|
185
|
+
const promptLines = [];
|
|
186
|
+
for (const line of String(text == null ? '' : text).split('\n')) {
|
|
187
|
+
const m = line.match(/^\s*\d+[.)]\s+(.+)$/);
|
|
188
|
+
if (m) options.push(m[1].trim());
|
|
189
|
+
else promptLines.push(line);
|
|
190
|
+
}
|
|
191
|
+
return {
|
|
192
|
+
prompt: promptLines.join('\n').trim(),
|
|
193
|
+
options: options.length >= 2 ? options : [],
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
143
197
|
function createToolExecutor(permissionManager, ui, getConfig, options = {}) {
|
|
144
198
|
const { BOLD, DIM, FG_DARK, FG_GRAY, FG_GREEN, FG_RED, FG_YELLOW, RST, renderDiff } = ui;
|
|
145
199
|
// Checkpoints & rewind (Task 4.3). When a store is wired, the prior state of a
|
|
@@ -174,12 +228,7 @@ function createToolExecutor(permissionManager, ui, getConfig, options = {}) {
|
|
|
174
228
|
const DIFF_BUBBLE_INSET = 5;
|
|
175
229
|
|
|
176
230
|
function _parseNumberedOptions(text) {
|
|
177
|
-
|
|
178
|
-
for (const line of text.split('\n')) {
|
|
179
|
-
const m = line.match(/^\s*\d+[.)]\s+(.+)$/);
|
|
180
|
-
if (m) options.push(m[1].trim());
|
|
181
|
-
}
|
|
182
|
-
return options.length >= 2 ? options : [];
|
|
231
|
+
return parseAskMenu(text).options;
|
|
183
232
|
}
|
|
184
233
|
|
|
185
234
|
// Build the permission descriptor for a [action, ...args] call tuple.
|
|
@@ -446,7 +495,9 @@ function createToolExecutor(permissionManager, ui, getConfig, options = {}) {
|
|
|
446
495
|
_secretReadError,
|
|
447
496
|
_protectedConfigWriteError,
|
|
448
497
|
_checkRegexSafety,
|
|
498
|
+
_isLiteralPattern,
|
|
449
499
|
_parseNumberedOptions,
|
|
500
|
+
_parseAskMenu: parseAskMenu,
|
|
450
501
|
_dryRun,
|
|
451
502
|
_skippedOps,
|
|
452
503
|
MEMORY_PATH,
|
|
@@ -678,10 +729,19 @@ function extractToolCalls(text, options = {}) {
|
|
|
678
729
|
}
|
|
679
730
|
}
|
|
680
731
|
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
732
|
+
// Bare-code-fence TEXT HEURISTIC: a ```bash/```sh/```shell markdown block with
|
|
733
|
+
// NO tool tag, each non-comment line inferred as a shell command. This is the
|
|
734
|
+
// ONLY mechanism that fires on untagged prose, so it is the only one gated by
|
|
735
|
+
// `skipTextHeuristics` (set on the native rail — see lib/agent.js). Every other
|
|
736
|
+
// pass in this function requires an EXPLICIT tool tag (<minimax:tool_call>,
|
|
737
|
+
// <function=…>, <tool_call>, the registered <tool> tags, MCP tags) and stays
|
|
738
|
+
// active regardless. The heuristic itself is unchanged — it is only skipped.
|
|
739
|
+
if (!options.skipTextHeuristics) {
|
|
740
|
+
for (const match of text.matchAll(/```(?:shell|bash|sh)\n([\s\S]*?)```/g)) {
|
|
741
|
+
for (const line of match[1].trim().split('\n')) {
|
|
742
|
+
const cmd = line.trim();
|
|
743
|
+
if (cmd && !cmd.startsWith('#')) calls.push(['shell', cmd]);
|
|
744
|
+
}
|
|
685
745
|
}
|
|
686
746
|
}
|
|
687
747
|
|
|
@@ -735,6 +795,7 @@ module.exports = {
|
|
|
735
795
|
isProtectedConfigPath,
|
|
736
796
|
isUIActive,
|
|
737
797
|
mapInvokeToCall,
|
|
798
|
+
parseAskMenu,
|
|
738
799
|
repairMinimaxMalformedXml,
|
|
739
800
|
setUIActive,
|
|
740
801
|
};
|
package/lib/ui/anim.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Single animation driver (Output Refactor — Phase 3).
|
|
4
|
+
//
|
|
5
|
+
// Before Phase 3 the status bar owned TWO independent setIntervals: a 1 Hz
|
|
6
|
+
// clock tick and a 100 ms spinner-glyph cycle. They never coordinated, each
|
|
7
|
+
// repainted the whole live region on its own, and the per-tool running glyph
|
|
8
|
+
// never animated (its elapsed meter only advanced as an accidental side-effect
|
|
9
|
+
// of whichever of the two timers happened to be firing).
|
|
10
|
+
//
|
|
11
|
+
// This driver replaces both with ONE timer. Subscribers register a per-frame
|
|
12
|
+
// callback; the driver advances a single monotonic frame counter at the base
|
|
13
|
+
// interval and, after running every subscriber, performs AT MOST ONE
|
|
14
|
+
// coordinated repaint — so a tick yields a single writer frame, never two
|
|
15
|
+
// competing ones. A subscriber returns truthy to request the repaint this
|
|
16
|
+
// frame; the driver coalesces those requests into one.
|
|
17
|
+
//
|
|
18
|
+
// The base interval is the finer (spinner) cadence — 100 ms. Coarser
|
|
19
|
+
// consumers (the clock, which only needs ~1 s) gate on
|
|
20
|
+
// `frame % TICKS_PER_SECOND === 0` inside their callback rather than owning a
|
|
21
|
+
// second timer.
|
|
22
|
+
//
|
|
23
|
+
// start()/stop() are idempotent (the 5404bd0 lesson — never stack intervals
|
|
24
|
+
// across pause/resume cycles). The owner decides WHEN to run: while there is
|
|
25
|
+
// something to animate (a running tool / streaming / thinking) or while the
|
|
26
|
+
// clock should tick (not idle-paused). When neither holds, the owner stops the
|
|
27
|
+
// driver so idle = no periodic repaint = the viewport scrolls freely.
|
|
28
|
+
|
|
29
|
+
const BASE_INTERVAL_MS = 100;
|
|
30
|
+
const TICKS_PER_SECOND = Math.round(1000 / BASE_INTERVAL_MS); // 10
|
|
31
|
+
|
|
32
|
+
class AnimDriver {
|
|
33
|
+
constructor(intervalMs) {
|
|
34
|
+
this._interval = Number.isFinite(intervalMs) && intervalMs > 0 ? intervalMs : BASE_INTERVAL_MS;
|
|
35
|
+
this._timer = null;
|
|
36
|
+
this._frame = 0;
|
|
37
|
+
// Subscribers: (frame:number) => boolean. A truthy return requests the
|
|
38
|
+
// single coordinated repaint for this frame.
|
|
39
|
+
this._subs = new Set();
|
|
40
|
+
// The one repaint sink. Set by the owner (the status bar wires it to its
|
|
41
|
+
// _notify → _updateLive → writer.setLive path, which also re-renders the
|
|
42
|
+
// activity rows, so a single repaint covers spinner + clock + running op).
|
|
43
|
+
this._repaint = () => {};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
get frame() { return this._frame; }
|
|
47
|
+
get intervalMs() { return this._interval; }
|
|
48
|
+
isRunning() { return this._timer !== null; }
|
|
49
|
+
|
|
50
|
+
// Register the single coordinated-repaint sink. Last writer wins.
|
|
51
|
+
onRepaint(fn) { this._repaint = typeof fn === 'function' ? fn : () => {}; }
|
|
52
|
+
|
|
53
|
+
// Register a per-frame subscriber. Returns an unsubscribe thunk.
|
|
54
|
+
subscribe(fn) {
|
|
55
|
+
if (typeof fn !== 'function') return () => {};
|
|
56
|
+
this._subs.add(fn);
|
|
57
|
+
return () => this._subs.delete(fn);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Idempotent: a second start() while running is a no-op (never stacks a
|
|
61
|
+
// timer — the regression 5404bd0 guards against).
|
|
62
|
+
start() {
|
|
63
|
+
if (this._timer) return;
|
|
64
|
+
this._timer = setInterval(() => this._tick(), this._interval);
|
|
65
|
+
// An animation timer must never hold the process open — the interactive
|
|
66
|
+
// chat is kept alive by the stdin listener, not by this. unref() so a bar
|
|
67
|
+
// that's constructed-but-not-destroyed (e.g. in a unit test) can't wedge
|
|
68
|
+
// event-loop drain at exit. Guarded: mock timers may not implement unref.
|
|
69
|
+
if (this._timer && typeof this._timer.unref === 'function') this._timer.unref();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
stop() {
|
|
73
|
+
if (this._timer) { clearInterval(this._timer); this._timer = null; }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
_tick() {
|
|
77
|
+
this._frame++;
|
|
78
|
+
let dirty = false;
|
|
79
|
+
for (const fn of this._subs) {
|
|
80
|
+
try { if (fn(this._frame)) dirty = true; } catch {}
|
|
81
|
+
}
|
|
82
|
+
if (dirty) { try { this._repaint(); } catch {} }
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
module.exports = { AnimDriver, BASE_INTERVAL_MS, TICKS_PER_SECOND };
|
package/lib/ui/ansi.js
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
// ANSI primitives ONLY. Colour lives in `theme.js` (the single palette table);
|
|
4
|
+
// this file holds the SGR builders, structural box-drawing characters, the
|
|
5
|
+
// syntax-keyword set and the spinner definitions. The legacy colour palette
|
|
6
|
+
// (`THEME` / `FG_*` / code-block colours) is DEFINED in theme.js and re-exported
|
|
7
|
+
// here for back-compat — this file no longer defines any colour of its own.
|
|
8
|
+
//
|
|
9
|
+
// Dependency is one-directional: ansi.js → theme.js. theme.js does not require
|
|
10
|
+
// ansi.js (it has its own private SGR builders), so the two never form a cycle.
|
|
11
|
+
|
|
3
12
|
const RST = '\x1b[0m';
|
|
4
13
|
const BOLD = '\x1b[1m';
|
|
5
14
|
const DIM = '\x1b[2m';
|
|
@@ -15,26 +24,6 @@ function hasTruecolor() {
|
|
|
15
24
|
return v === 'truecolor' || v === '24bit';
|
|
16
25
|
}
|
|
17
26
|
|
|
18
|
-
const THEME = {
|
|
19
|
-
user: '\x1b[36m',
|
|
20
|
-
agent: '\x1b[32m',
|
|
21
|
-
sys: '\x1b[33m',
|
|
22
|
-
error: '\x1b[31;1m',
|
|
23
|
-
warn: '\x1b[33;1m',
|
|
24
|
-
tool: '\x1b[35m',
|
|
25
|
-
dim: '\x1b[2m',
|
|
26
|
-
reset: '\x1b[0m',
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
const FG_GRAY = '\x1b[38;5;245m';
|
|
30
|
-
const FG_DARK = '\x1b[38;5;240m';
|
|
31
|
-
const FG_BLUE = '\x1b[38;5;75m';
|
|
32
|
-
const FG_CYAN = '\x1b[38;5;116m';
|
|
33
|
-
const FG_GREEN = '\x1b[38;5;114m';
|
|
34
|
-
const FG_YELLOW = '\x1b[38;5;222m';
|
|
35
|
-
const FG_RED = '\x1b[38;5;203m';
|
|
36
|
-
const FG_TEAL = '\x1b[38;5;73m';
|
|
37
|
-
|
|
38
27
|
const BOX_H = '─';
|
|
39
28
|
const BOX_V = '│';
|
|
40
29
|
const BOX_TL = '╭';
|
|
@@ -42,13 +31,6 @@ const BOX_TR = '╮';
|
|
|
42
31
|
const BOX_BL = '╰';
|
|
43
32
|
const BOX_BR = '╯';
|
|
44
33
|
|
|
45
|
-
const FG_CODE_BG = '\x1b[48;5;236m';
|
|
46
|
-
const BG_SELECTED = '\x1b[48;5;237m';
|
|
47
|
-
const FG_CODE_BORDER = '\x1b[38;5;240m';
|
|
48
|
-
const FG_CODE_LANG = '\x1b[38;5;75m';
|
|
49
|
-
const FG_TAG = '\x1b[38;5;176m';
|
|
50
|
-
const FG_FILEPATH = '\x1b[38;5;222m';
|
|
51
|
-
|
|
52
34
|
const KEYWORDS = new Set([
|
|
53
35
|
'def', 'class', 'import', 'from', 'return', 'if', 'else', 'elif',
|
|
54
36
|
'for', 'while', 'try', 'except', 'finally', 'with', 'as', 'in',
|
|
@@ -67,6 +49,14 @@ const SPINNER_DEFS = {
|
|
|
67
49
|
waiting_download: { frames: ['⬇ ','⬇⠂','⬇⠆','⬇⠇','⬇⠧','⬇⠷','⬇⠿','⬇⠾','⬇⠼','⬇⠸','⬇⠰','⬇⠠'], color: '\x1b[38;5;75m' },
|
|
68
50
|
};
|
|
69
51
|
|
|
52
|
+
// Re-export the colour palette from its single home (theme.js). One-directional
|
|
53
|
+
// require — theme.js never requires this file — so there is no cycle.
|
|
54
|
+
const {
|
|
55
|
+
THEME,
|
|
56
|
+
FG_GRAY, FG_DARK, FG_BLUE, FG_CYAN, FG_GREEN, FG_YELLOW, FG_RED, FG_TEAL,
|
|
57
|
+
FG_CODE_BG, BG_SELECTED, FG_CODE_BORDER, FG_CODE_LANG, FG_TAG, FG_FILEPATH,
|
|
58
|
+
} = require('./theme');
|
|
59
|
+
|
|
70
60
|
module.exports = {
|
|
71
61
|
RST, BOLD, DIM, EL, THEME,
|
|
72
62
|
bg256, fg256, bgRGB, fgRGB, hasTruecolor,
|