zames_pro 2.73.11 → 2.74.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.
Files changed (52) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +13 -7
  3. package/dist/banner.js +71 -0
  4. package/dist/browser.js +84 -27
  5. package/dist/code-blocks.js +48 -0
  6. package/dist/commands.js +1 -0
  7. package/dist/config-live.js +53 -0
  8. package/dist/config.js +38 -0
  9. package/dist/goal.js +81 -0
  10. package/dist/i18n/common.js +31 -0
  11. package/dist/i18n/config.js +20 -0
  12. package/dist/i18n/deepseek.js +14 -0
  13. package/dist/i18n/help.js +22 -2
  14. package/dist/i18n/qwen.js +4 -0
  15. package/dist/i18n/self-review.js +8 -0
  16. package/dist/i18n/spinner.js +6 -0
  17. package/dist/index.js +478 -65
  18. package/dist/input/layout.js +19 -0
  19. package/dist/input/suggest.js +13 -0
  20. package/dist/input.js +14 -3
  21. package/dist/net-capture.js +18 -0
  22. package/dist/qwen-browser.js +43 -20
  23. package/dist/run-task.js +97 -4
  24. package/dist/theme.js +7 -1
  25. package/dist/tool-summary.js +83 -0
  26. package/dist/tui/ConfigMenu.js +304 -0
  27. package/dist/tui/ConfirmDialog.js +42 -0
  28. package/dist/tui/FullScreenApp.js +85 -0
  29. package/dist/tui/InputLine.js +285 -0
  30. package/dist/tui/LogView.js +48 -0
  31. package/dist/tui/LoginForm.js +83 -0
  32. package/dist/tui/SelectList.js +50 -0
  33. package/dist/tui/StatusBar.js +140 -0
  34. package/dist/tui/TextPrompt.js +41 -0
  35. package/dist/tui/TuiApp.js +28 -0
  36. package/dist/tui/TuiEditor.js +490 -0
  37. package/dist/tui/edit.js +297 -0
  38. package/dist/tui/keys.js +162 -0
  39. package/dist/tui/log-window.js +93 -0
  40. package/dist/tui/login-pure.js +20 -0
  41. package/dist/tui/run-config-menu.js +19 -0
  42. package/dist/tui/run-confirm.js +22 -0
  43. package/dist/tui/run-login.js +43 -0
  44. package/dist/tui/run-select.js +18 -0
  45. package/dist/tui/store.js +104 -0
  46. package/dist/tui/types.js +10 -0
  47. package/dist/tui/useMouseWheel.js +113 -0
  48. package/dist/tui/widgets.js +55 -0
  49. package/package.json +6 -3
  50. package/scripts/coverage-gate.mts +10 -0
  51. package/scripts/render-demo.mts +1 -1
  52. package/scripts/tui-probe.tsx +62 -0
@@ -0,0 +1,28 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // The Ink APP ROOT — composes the permanent log, the live status bar and the
3
+ // input line into one flex column, and SUBSCRIBES to the TuiStore itself via
4
+ // `useSyncExternalStore`.
5
+ //
6
+ // Subscribing inside the tree (not by passing `state` as a prop) means the
7
+ // adapter calls `render()` exactly once and every store mutation re-renders
8
+ // only the components that read the changed slice. The top section is <Static>
9
+ // (never repainted), the bottom section re-renders as state changes, so the
10
+ // input stays pinned at the bottom while answers scroll up into scrollback.
11
+ import React, { useSyncExternalStore } from 'react';
12
+ import { Box } from 'ink';
13
+ import { LogView } from './LogView.js';
14
+ import { StatusBar } from './StatusBar.js';
15
+ import { InputLine } from './InputLine.js';
16
+ import { suggestRowCount } from '../input/suggest.js';
17
+ export function TuiApp({ store, inputProps, initialPrintedCount = 0, }) {
18
+ const state = useSyncExternalStore(store.subscribe, store.getSnapshot);
19
+ // The watermark is fixed for the lifetime of this mount.
20
+ const [watermark] = React.useState(initialPrintedCount);
21
+ const entries = watermark > 0 ? state.log.slice(watermark) : state.log;
22
+ // Cursor rows above the input box in the INLINE live area: divider + status
23
+ // + the suggestion popup (which renders above the box and would otherwise
24
+ // shift the caret down by its row count). Without the suggestRows term the
25
+ // caret drifted whenever the «/» list opened.
26
+ const suggestRows = suggestRowCount(state.edit.buf, state.commands);
27
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(LogView, { entries: entries }), _jsx(StatusBar, { state: state }), _jsx(InputLine, { ...inputProps, store: store, prompt: state.prompt, locale: state.locale, commands: state.commands, locked: state.locked, cursorTop: 3 + suggestRows })] }));
28
+ }
@@ -0,0 +1,490 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { render } from 'ink';
3
+ import { TuiStore } from './store.js';
4
+ import { TuiApp } from './TuiApp.js';
5
+ import { FullScreenApp } from './FullScreenApp.js';
6
+ import { AttachmentStore } from '../attachments.js';
7
+ import { translate } from '../i18n.js';
8
+ import { expandPastes, formatTokenStatus, tokenStatusLevel, wrapToWidth, truncateToWidth, } from '../input/layout.js';
9
+ import { contentWidth } from '../width.js';
10
+ import { theme } from '../theme.js';
11
+ import { renderMarkdown } from '../markdown.js';
12
+ import { summarizeToolCall, looksLikeToolError } from '../tool-summary.js';
13
+ import { extractCodeBlocks } from '../code-blocks.js';
14
+ import { randomThinkingPhrase, stripEllipsis } from '../spinner.js';
15
+ export class TuiEditor {
16
+ store = new TuiStore();
17
+ attachments = new AttachmentStore();
18
+ // ---- imperative surface used by index.ts / run-task.ts ----
19
+ onSubmit = null;
20
+ onEscape = null;
21
+ onCtrlC = null;
22
+ onHistoryChange = null;
23
+ onAttach = null;
24
+ onClipboard = null;
25
+ onQueueQuery = null;
26
+ onTasksQuery = null;
27
+ onContextQuery = null;
28
+ onToggleQuery = null;
29
+ /** Copy text to the OS clipboard. Wired to writeClipboardText in index.ts;
30
+ * returns { ok, via } so the editor can report the outcome. */
31
+ onCopyText = null;
32
+ busy = false;
33
+ locked = false;
34
+ history = [];
35
+ contextLimit = 1_000_000;
36
+ /** Marker that the editor is started (kept for API compatibility). */
37
+ rendered = false;
38
+ /** Marks this editor as the Ink TUI (used to pick the Ink confirm modal). */
39
+ isTui = true;
40
+ instance = null;
41
+ prompt;
42
+ commands;
43
+ locale;
44
+ contextTokens = null;
45
+ statusText = '';
46
+ pendingText = null;
47
+ tmpDir = '';
48
+ badgeTimer = null;
49
+ alternateScreen;
50
+ /**
51
+ * How many log entries have already been painted to the terminal. Used as
52
+ * the <Static> watermark across a pause/resume remount, so the scrollback is
53
+ * not reprinted. Reset by clearScreen (the screen is wiped).
54
+ */
55
+ printedCount = 0;
56
+ /** SIGWINCH handler installed while the app is mounted. */
57
+ onResize = null;
58
+ resizeTimer = null;
59
+ constructor(opts = {}) {
60
+ this.prompt = opts.prompt ?? '> ';
61
+ this.commands = opts.commands ?? [];
62
+ this.locale = opts.locale ?? 'ru';
63
+ this.alternateScreen = !!opts.alternateScreen;
64
+ this.store.set({
65
+ prompt: this.prompt,
66
+ commands: this.commands,
67
+ locale: this.locale,
68
+ history: this.history,
69
+ });
70
+ }
71
+ // ---------- lifecycle ----------
72
+ start() {
73
+ if (!(process.stdin.isTTY && process.stdout.isTTY))
74
+ return false;
75
+ if (this.instance)
76
+ return true;
77
+ this.mount();
78
+ return true;
79
+ }
80
+ buildInputProps() {
81
+ return {
82
+ onSubmit: (text, atts) => this.handleSubmit(text, atts),
83
+ onEscape: () => this.onEscape?.(),
84
+ onCtrlC: () => this.onCtrlC?.(),
85
+ onCopyCode: () => this.copyCode(),
86
+ onAttach: (raw) => this.onAttach?.(raw) ?? null,
87
+ onClipboard: () => this.onClipboard?.() ?? Promise.resolve(null),
88
+ onHistoryChange: (h) => {
89
+ this.history = h;
90
+ this.onHistoryChange?.(h);
91
+ },
92
+ onClearScreen: () => this.clearScreen(),
93
+ attachments: this.attachments.items,
94
+ onPastesChange: (pastes) => this.store.set({ pastes }),
95
+ };
96
+ }
97
+ mount() {
98
+ // Seed the editor history from the persisted list IF the store has none
99
+ // yet (first mount). index.ts sets `this.history` from loadHistory()
100
+ // BEFORE start(); the store owns the live buffer so a later remount keeps
101
+ // both the typed line and the history.
102
+ const snap = this.store.getSnapshot();
103
+ if (snap.edit.history.length === 0 && this.history.length > 0) {
104
+ this.store.set({
105
+ edit: {
106
+ ...snap.edit,
107
+ history: this.history.slice(),
108
+ histIndex: this.history.length,
109
+ },
110
+ });
111
+ }
112
+ // growing down from the current cursor row; a fresh terminal starts at the
113
+ // top, so without this the panel would sit in the middle of an empty
114
+ // screen. Filling the viewport with newlines first makes Ink's output land
115
+ // at the bottom, and every later log row scrolls the screen up while the
116
+ // panel stays put — the same technique the legacy LineEditor used
117
+ // (`_padToBottom`).
118
+ this.padToBottom();
119
+ const tree = this.alternateScreen ? (_jsx(FullScreenApp, { store: this.store, inputProps: this.buildInputProps() })) : (_jsx(TuiApp, { store: this.store, inputProps: this.buildInputProps(), initialPrintedCount: this.printedCount }));
120
+ this.instance = render(tree, {
121
+ exitOnCtrlC: false,
122
+ // The app manages console.log/error itself (index.ts routes them to
123
+ // printAbove), so Ink must not patch them: an unmount (clearScreen)
124
+ // would otherwise restore the native console and lose that routing.
125
+ patchConsole: false,
126
+ alternateScreen: this.alternateScreen,
127
+ });
128
+ this.rendered = true;
129
+ this.installResizeHandler();
130
+ // Entries shown so far are now on screen; a later remount must not reprint
131
+ // them.
132
+ this.printedCount = this.store.getSnapshot().log.length;
133
+ // Badges (queue/tasks/toggles/context) are pulled on a light timer so they
134
+ // stay live without the caller polling — mirroring the old editor, whose
135
+ // status render read them on every repaint.
136
+ if (!this.badgeTimer) {
137
+ this.badgeTimer = setInterval(() => this.refreshBadges(), 1000);
138
+ if (this.badgeTimer.unref)
139
+ this.badgeTimer.unref();
140
+ }
141
+ }
142
+ dispose() {
143
+ if (this.instance) {
144
+ this.instance.unmount();
145
+ this.instance = null;
146
+ }
147
+ if (this.badgeTimer) {
148
+ clearInterval(this.badgeTimer);
149
+ this.badgeTimer = null;
150
+ }
151
+ this.removeResizeHandler();
152
+ this.rendered = false;
153
+ }
154
+ /**
155
+ * Repaint on terminal resize. Ink's OWN resize handling only clears on a
156
+ * WIDTH DECREASE; on other resizes the terminal reflowed the printed rows
157
+ * while Ink's relative erase still points at the old frame, so the live
158
+ * panel DUPLICATED at several indents (the artifact the operator reported).
159
+ * We fix it by rebuilding the Ink instance on resize — that resets the
160
+ * internal frame buffer. This is now SAFE because the editor buffer lives in
161
+ * the STORE (a remount no longer wipes the typed line) and we keep the log
162
+ * watermark, so the scrollback is neither lost nor reprinted.
163
+ */
164
+ installResizeHandler() {
165
+ if (this.onResize)
166
+ return;
167
+ this.onResize = () => {
168
+ if (this.resizeTimer)
169
+ clearTimeout(this.resizeTimer);
170
+ this.resizeTimer = setTimeout(() => {
171
+ this.resizeTimer = null;
172
+ if (!this.instance)
173
+ return;
174
+ // Preserve the log: the watermark stays at the current length, so the
175
+ // new instance prints NOTHING already on screen (no history loss, no
176
+ // duplicate).
177
+ this.printedCount = this.store.getSnapshot().log.length;
178
+ this.instance.unmount();
179
+ this.instance = null;
180
+ this.mount();
181
+ }, 100);
182
+ if (this.resizeTimer.unref)
183
+ this.resizeTimer.unref();
184
+ };
185
+ process.stdout.on('resize', this.onResize);
186
+ }
187
+ removeResizeHandler() {
188
+ if (this.onResize) {
189
+ process.stdout.removeListener('resize', this.onResize);
190
+ this.onResize = null;
191
+ }
192
+ if (this.resizeTimer) {
193
+ clearTimeout(this.resizeTimer);
194
+ this.resizeTimer = null;
195
+ }
196
+ }
197
+ /** Emit newlines so Ink's output starts at the BOTTOM of the viewport. */
198
+ padToBottom() {
199
+ const rows = process.stdout.rows || 24;
200
+ if (rows > 1)
201
+ process.stdout.write('\n'.repeat(rows - 1));
202
+ }
203
+ /** Suspend the tree for a foreign full-screen UI (e.g. the /config menu). */
204
+ pause() {
205
+ if (this.instance) {
206
+ this.instance.unmount();
207
+ this.instance = null;
208
+ }
209
+ }
210
+ /** Resume after pause(): remount the tree fresh. */
211
+ resume() {
212
+ if (!this.instance && process.stdin.isTTY && process.stdout.isTTY) {
213
+ this.mount();
214
+ }
215
+ }
216
+ // ---------- submission ----------
217
+ handleSubmit(display, atts) {
218
+ const text = expandPastes(this.store.getSnapshot().pastes, display);
219
+ this.attachments.reset();
220
+ // A turn separator + a BAR-marked, clearly-colored operator message, so an
221
+ // operator turn never blends with the model's answer (operator request).
222
+ this.printAbove(theme.turnRule('─'.repeat(contentWidth())));
223
+ this.printAbove(theme.userBar('▌ ') +
224
+ theme.user(wrapToWidth(display, contentWidth() - 2)));
225
+ this.store.set({ pastes: [] });
226
+ this.onSubmit?.(text, atts);
227
+ if (this.busy)
228
+ this.thinking();
229
+ }
230
+ // ---------- status / log API ----------
231
+ /** Print a block into the permanent log above the input. */
232
+ printAbove(text) {
233
+ const s = String(text);
234
+ const lines = s.split('\n');
235
+ // `raw: true` — the caller already applied its own theme colors; LogView
236
+ // must print the line verbatim instead of re-wrapping it (double ANSI
237
+ // nesting made a tool line look dim/wrong).
238
+ this.store.pushLogs(lines.map((line) => ({
239
+ kind: 'system',
240
+ text: line,
241
+ raw: true,
242
+ })));
243
+ }
244
+ setStatus(text) {
245
+ this.statusText = text || '';
246
+ this.store.set({ statusText: this.statusText });
247
+ }
248
+ setSendState(state) {
249
+ const next = state === 'generating' || state === 'paused' || state === 'settled'
250
+ ? state
251
+ : '';
252
+ this.store.set({ sendState: next });
253
+ }
254
+ setPrompt(str) {
255
+ this.prompt = str;
256
+ this.store.set({ prompt: str });
257
+ }
258
+ setProvider(text) {
259
+ this.store.set({ provider: String(text || '').trim() });
260
+ }
261
+ /** Header info for full-screen mode (title/version/workdir). */
262
+ setHeader(info) {
263
+ this.store.set({
264
+ ...(info.title !== undefined ? { title: info.title } : {}),
265
+ ...(info.version !== undefined ? { version: info.version } : {}),
266
+ ...(info.workdir !== undefined ? { workdir: info.workdir } : {}),
267
+ ...(info.mouseScroll !== undefined
268
+ ? { mouseScroll: info.mouseScroll }
269
+ : {}),
270
+ });
271
+ }
272
+ setStatusLine(text) {
273
+ this.store.set({ statusLine: String(text || '').trim() });
274
+ }
275
+ setContextStatus(tokens) {
276
+ this.contextTokens =
277
+ typeof tokens === 'number' && Number.isFinite(tokens) ? tokens : null;
278
+ this.applyContext();
279
+ }
280
+ setContextLimit(limit) {
281
+ if (typeof limit === 'number' && Number.isFinite(limit) && limit > 0) {
282
+ this.contextLimit = limit;
283
+ }
284
+ this.applyContext();
285
+ }
286
+ applyContext() {
287
+ const n = this.onContextQuery ? this.readContext() : this.contextTokens;
288
+ const text = formatTokenStatus(n, this.contextLimit) || null;
289
+ const level = tokenStatusLevel(n, this.contextLimit);
290
+ this.store.set({ contextText: text, contextLevel: level });
291
+ }
292
+ readContext() {
293
+ try {
294
+ const n = this.onContextQuery?.();
295
+ return typeof n === 'number' ? n : null;
296
+ }
297
+ catch {
298
+ return null;
299
+ }
300
+ }
301
+ setLocale(locale) {
302
+ this.locale = locale;
303
+ this.store.set({ locale });
304
+ }
305
+ setCommands(commands) {
306
+ this.commands = commands;
307
+ this.store.set({ commands });
308
+ }
309
+ setTmpDir(dir) {
310
+ this.tmpDir = dir;
311
+ }
312
+ clear() {
313
+ // The buffer lives in the input component's state; this API hook is kept
314
+ // for compatibility but there is nothing to reset here.
315
+ }
316
+ refreshStatus() {
317
+ this.refreshBadges();
318
+ }
319
+ /** Pull the live badge values (queue/tasks/toggles/context) into the store. */
320
+ refreshBadges() {
321
+ if (this.onQueueQuery) {
322
+ try {
323
+ this.store.set({ queueLength: this.onQueueQuery() || 0 });
324
+ }
325
+ catch {
326
+ /* ignore */
327
+ }
328
+ }
329
+ if (this.onTasksQuery) {
330
+ try {
331
+ this.store.set({ tasks: this.onTasksQuery() || '' });
332
+ }
333
+ catch {
334
+ /* ignore */
335
+ }
336
+ }
337
+ if (this.onToggleQuery) {
338
+ try {
339
+ const s = this.onToggleQuery();
340
+ if (s) {
341
+ this.store.set({
342
+ thinkingEnabled: !!s.deepThinking,
343
+ searchEnabled: !!s.webSearch,
344
+ });
345
+ }
346
+ }
347
+ catch {
348
+ /* ignore */
349
+ }
350
+ }
351
+ this.applyContext();
352
+ }
353
+ clearScreen() {
354
+ // Drop the permanent log and let Ink repaint a clean screen. A REMOUNT is
355
+ // required because <Static> never un-renders items it has already printed:
356
+ // clearing the store alone would leave the old rows on screen. The
357
+ // watermark goes back to 0 so the (now empty) log is the fresh baseline.
358
+ this.store.set({ log: [] });
359
+ this.printedCount = 0;
360
+ if (this.instance) {
361
+ this.instance.unmount();
362
+ this.instance = null;
363
+ this.mount();
364
+ }
365
+ }
366
+ lock(status) {
367
+ this.locked = true;
368
+ if (status)
369
+ this.setStatus(theme.dim(status));
370
+ this.store.set({ locked: true });
371
+ }
372
+ unlock() {
373
+ this.locked = false;
374
+ if (this.statusText)
375
+ this.setStatus('');
376
+ this.store.set({ locked: false });
377
+ }
378
+ // ---------- spinner-compatible interface ----------
379
+ thinking() {
380
+ if (this.pendingText) {
381
+ this.setStatus('✎ ' + this.pendingText);
382
+ return;
383
+ }
384
+ this.startAnimated(randomThinkingPhrase(this.locale));
385
+ }
386
+ startAnimated(baseText) {
387
+ this.store.set({
388
+ animBase: stripEllipsis(baseText),
389
+ animStart: Date.now(),
390
+ });
391
+ }
392
+ sendPause(seconds) {
393
+ const label = translate(this.locale)('spinner.pause', { n: seconds });
394
+ if (this.store.getSnapshot().animBase) {
395
+ this.store.set({ animBase: stripEllipsis(label) });
396
+ }
397
+ else {
398
+ this.startAnimated(label);
399
+ }
400
+ }
401
+ setPending(text) {
402
+ this.pendingText = text && String(text).length ? String(text) : null;
403
+ this.store.set({ pendingText: this.pendingText });
404
+ }
405
+ stop() {
406
+ this.statusText = '';
407
+ this.pendingText = null;
408
+ this.store.set({ statusText: '', animBase: '', pendingText: null });
409
+ }
410
+ toolCall(name, args) {
411
+ const full = safeJson(args);
412
+ void full;
413
+ const summary = summarizeToolCall(name, (typeof args === 'object' && args !== null ? args : {}));
414
+ const detail = summary.detail
415
+ ? truncateToWidth(summary.detail, contentWidth() - name.length - 6)
416
+ : truncateToWidth(full, contentWidth());
417
+ this.printAbove(theme.tool('🔧 ' + name) + (detail ? theme.dim('(' + detail + ')') : ''));
418
+ this.store.set({ animBase: '', sendState: '' });
419
+ this.startAnimated(translate(this.locale)('spinner.running_tool', { name }));
420
+ this.refreshBadges();
421
+ }
422
+ toolResult(result) {
423
+ this.stop();
424
+ const text = typeof result === 'string' ? result : safeJson(result);
425
+ const preview = truncateToWidth(text.split('\n').join(' ↵ '), contentWidth());
426
+ // A simple outcome icon: a tool result that is clearly an error gets ✖, a
427
+ // normal one ✔. The result already flows here (a mutating tool's failure
428
+ // is reported as an Error string by the loop), so this is cheap and makes
429
+ // a failed call visible at a glance.
430
+ const icon = looksLikeToolError(text) ? ' ✖ ' : ' ✔ ';
431
+ this.printAbove(theme.toolResult(icon + preview));
432
+ }
433
+ assistant(msg) {
434
+ this.stop();
435
+ const rendered = renderMarkdown(msg);
436
+ this.printAbove('\n' + theme.assistant(translate(this.locale)('editor.answer')));
437
+ this.printAbove(rendered);
438
+ this.printAbove('\n');
439
+ // Remember the answer's code blocks so the operator can copy one by hotkey
440
+ // (a terminal has no copy buttons). Extraction is on the RAW markdown —
441
+ // the rendered text is full of ANSI and must never be copied.
442
+ const blocks = extractCodeBlocks(msg);
443
+ this.store.set({ codeBlocks: blocks, codeFocus: -1 });
444
+ if (blocks.length)
445
+ this.refreshBadges();
446
+ }
447
+ /** Copy the focused code block (or the LAST when none is focused). */
448
+ copyCode(index) {
449
+ const snap = this.store.getSnapshot();
450
+ const blocks = snap.codeBlocks;
451
+ if (!blocks.length) {
452
+ this.printAbove(theme.dim(translate(this.locale)('copy.no_code')));
453
+ return false;
454
+ }
455
+ const pick = typeof index === 'number'
456
+ ? blocks.find((b) => b.index === index) ?? null
457
+ : snap.codeFocus >= 0
458
+ ? blocks[snap.codeFocus] ?? null
459
+ : blocks[blocks.length - 1];
460
+ if (!pick) {
461
+ this.printAbove(theme.warn(translate(this.locale)('copy.bad_index', { n: String(index) })));
462
+ return false;
463
+ }
464
+ const res = this.onCopyText?.(pick.text) ?? { ok: false, via: 'none' };
465
+ const label = 'code #' + pick.index + (pick.lang ? ' (' + pick.lang + ')' : '');
466
+ if (res.ok) {
467
+ this.printAbove(theme.assistant(translate(this.locale)('copy.done', { tool: res.via }) + ' — ' + label));
468
+ }
469
+ else {
470
+ this.printAbove(theme.warn(translate(this.locale)('copy.failed', { tool: res.via }) + ' — ' + label));
471
+ }
472
+ return res.ok;
473
+ }
474
+ warning(msg) {
475
+ const wrapped = wrapToWidth('⚠ ' + msg, contentWidth());
476
+ this.printAbove('\n' + theme.warn(wrapped));
477
+ }
478
+ succeed() { }
479
+ fail() { }
480
+ }
481
+ function safeJson(v) {
482
+ if (typeof v === 'string')
483
+ return v;
484
+ try {
485
+ return JSON.stringify(v);
486
+ }
487
+ catch {
488
+ return String(v);
489
+ }
490
+ }