@semalt-ai/code 1.19.0 → 1.20.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.
Files changed (83) hide show
  1. package/.claude/settings.local.json +2 -1
  2. package/ARCHITECTURE.md +6 -95
  3. package/CLAUDE.md +196 -1874
  4. package/README.md +1 -1
  5. package/docs/ARCHITECTURE.md +1321 -0
  6. package/docs/CONFIG.md +340 -0
  7. package/docs/HISTORY.md +245 -0
  8. package/index.js +1 -1
  9. package/lib/agent.js +145 -16
  10. package/lib/api.js +28 -3
  11. package/lib/commands/chat-session.js +188 -4
  12. package/lib/commands/chat-slash.js +16 -0
  13. package/lib/commands/chat-turn.js +319 -52
  14. package/lib/commands/chat.js +12 -8
  15. package/lib/config.js +27 -0
  16. package/lib/constants.js +30 -1
  17. package/lib/headless.js +36 -1
  18. package/lib/images.js +8 -2
  19. package/lib/permissions.js +23 -16
  20. package/lib/prompts.js +15 -3
  21. package/lib/tool_registry.js +357 -53
  22. package/lib/tool_specs.js +42 -8
  23. package/lib/tools.js +80 -19
  24. package/lib/ui/anim.js +86 -0
  25. package/lib/ui/ansi.js +17 -27
  26. package/lib/ui/chat-history.js +253 -71
  27. package/lib/ui/create-ui.js +67 -24
  28. package/lib/ui/diff.js +90 -25
  29. package/lib/ui/file-activity.js +229 -0
  30. package/lib/ui/format.js +173 -28
  31. package/lib/ui/input-field.js +5 -4
  32. package/lib/ui/md-stream.js +234 -0
  33. package/lib/ui/render-operation.js +113 -0
  34. package/lib/ui/select.js +1 -4
  35. package/lib/ui/status-bar.js +99 -57
  36. package/lib/ui/stream.js +20 -13
  37. package/lib/ui/theme.js +190 -45
  38. package/lib/ui/tool-operation.js +190 -0
  39. package/lib/ui/utils.js +9 -5
  40. package/lib/ui/web-activity.js +58 -6
  41. package/lib/ui/writer.js +159 -45
  42. package/lib/ui.js +1 -1
  43. package/package.json +1 -1
  44. package/test/anim-driver.test.js +153 -0
  45. package/test/ask-user-display.test.js +226 -0
  46. package/test/ask-user-gate.test.js +231 -0
  47. package/test/chat-history-nocolor.test.js +155 -0
  48. package/test/chat-relogin.test.js +207 -0
  49. package/test/defer-detail-band.test.js +403 -0
  50. package/test/detail-band-tab-flatten.test.js +242 -0
  51. package/test/exec-diff.test.js +268 -0
  52. package/test/executors.test.js +250 -13
  53. package/test/extract-tool-calls.test.js +37 -3
  54. package/test/file-activity.test.js +542 -0
  55. package/test/grep-path-target.test.js +227 -0
  56. package/test/harness/chat-harness.js +2 -1
  57. package/test/headless.test.js +146 -1
  58. package/test/input-field-ctrl-o.test.js +37 -0
  59. package/test/live-height-physical.test.js +281 -0
  60. package/test/max-iterations.test.js +9 -7
  61. package/test/md-stream.test.js +183 -0
  62. package/test/narration-ordering.test.js +309 -0
  63. package/test/native-dispatch.test.js +53 -0
  64. package/test/native-live-narration.test.js +254 -0
  65. package/test/output-heredoc-leak.test.js +195 -0
  66. package/test/output-preview.test.js +245 -0
  67. package/test/permission-flush.test.js +302 -0
  68. package/test/permissions.test.js +199 -0
  69. package/test/read-paginate.test.js +1 -1
  70. package/test/render-operation.test.js +317 -0
  71. package/test/replay-descriptor-xml.test.js +216 -0
  72. package/test/replay-descriptor.test.js +189 -0
  73. package/test/replay-web-aggregate.test.js +291 -0
  74. package/test/replay-web-persist.test.js +241 -0
  75. package/test/running-glyph-anim.test.js +111 -0
  76. package/test/status-bar-driver.test.js +93 -0
  77. package/test/status-bar-resync.test.js +188 -0
  78. package/test/stream-parser.test.js +24 -0
  79. package/test/theme-palette.test.js +166 -0
  80. package/test/truncate-visible.test.js +78 -0
  81. package/test/view-image.test.js +199 -0
  82. package/test/web-activity-ordering.test.js +12 -3
  83. 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 the contents of a single line in a file, identified by 1-based line number.',
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; trailing newline is added automatically when the file is rejoined',
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 glob limiting which files are searched, e.g. "*.js" or "src/**/*.ts"',
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: 'Regex-replace occurrences of a pattern in a file; returns the number of replacements made.',
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: 'JavaScript regular-expression pattern to search for',
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; supports the standard $1, $2, $& back-references',
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; the "g" flag is added automatically if omitted',
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
- // Cheap ReDoS guard. Rejects pathologically long patterns, common
126
- // catastrophic-backtracking anti-patterns, and pattern×data sizes large
127
- // enough to hang the regex engine.
128
- function _checkRegexSafety(pattern, data) {
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: 'Pattern rejected: length exceeds 1000 chars' };
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
- const options = [];
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
- for (const match of text.matchAll(/```(?:shell|bash|sh)\n([\s\S]*?)```/g)) {
682
- for (const line of match[1].trim().split('\n')) {
683
- const cmd = line.trim();
684
- if (cmd && !cmd.startsWith('#')) calls.push(['shell', cmd]);
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,