zames_pro 2.73.10 → 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.
- package/CHANGELOG.md +30 -0
- package/README.md +13 -7
- package/dist/banner.js +71 -0
- package/dist/browser.js +84 -27
- package/dist/code-blocks.js +48 -0
- package/dist/commands.js +1 -0
- package/dist/config-live.js +53 -0
- package/dist/config.js +38 -0
- package/dist/goal.js +81 -0
- package/dist/i18n/common.js +31 -0
- package/dist/i18n/config.js +20 -0
- package/dist/i18n/deepseek.js +14 -0
- package/dist/i18n/help.js +22 -2
- package/dist/i18n/qwen.js +4 -0
- package/dist/i18n/self-review.js +8 -0
- package/dist/i18n/spinner.js +6 -0
- package/dist/index.js +478 -65
- package/dist/input/layout.js +19 -0
- package/dist/input/suggest.js +13 -0
- package/dist/input.js +14 -3
- package/dist/net-capture.js +18 -0
- package/dist/qwen-browser.js +43 -20
- package/dist/run-task.js +97 -4
- package/dist/theme.js +7 -1
- package/dist/tool-summary.js +83 -0
- package/dist/tui/ConfigMenu.js +304 -0
- package/dist/tui/ConfirmDialog.js +42 -0
- package/dist/tui/FullScreenApp.js +85 -0
- package/dist/tui/InputLine.js +285 -0
- package/dist/tui/LogView.js +48 -0
- package/dist/tui/LoginForm.js +83 -0
- package/dist/tui/SelectList.js +50 -0
- package/dist/tui/StatusBar.js +140 -0
- package/dist/tui/TextPrompt.js +41 -0
- package/dist/tui/TuiApp.js +28 -0
- package/dist/tui/TuiEditor.js +490 -0
- package/dist/tui/edit.js +297 -0
- package/dist/tui/keys.js +162 -0
- package/dist/tui/log-window.js +93 -0
- package/dist/tui/login-pure.js +20 -0
- package/dist/tui/run-config-menu.js +19 -0
- package/dist/tui/run-confirm.js +22 -0
- package/dist/tui/run-login.js +43 -0
- package/dist/tui/run-select.js +18 -0
- package/dist/tui/store.js +104 -0
- package/dist/tui/types.js +10 -0
- package/dist/tui/useMouseWheel.js +113 -0
- package/dist/tui/widgets.js +55 -0
- package/package.json +6 -4
- package/scripts/coverage-gate.mts +10 -0
- package/scripts/render-demo.mts +1 -1
- 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
|
+
}
|