micro-models-agent 0.51.1 → 0.52.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/dist/cli/commands.js +162 -38
- package/dist/cli/completer.js +5 -5
- package/dist/cli/main.js +42 -54
- package/dist/cli/repl-commands.js +138 -38
- package/dist/cli/repl.js +175 -89
- package/dist/cli/run-result.js +11 -0
- package/dist/cli/security-commands.js +6 -6
- package/dist/cli/setup.js +21 -15
- package/dist/config/config.js +54 -27
- package/dist/config/defaults.js +17 -0
- package/dist/config/domains.js +179 -0
- package/dist/config/index.js +2 -1
- package/dist/config/security.js +28 -8
- package/dist/core/agent.js +162 -30
- package/dist/core/bootstrap.js +94 -17
- package/dist/core/crash-handler.js +51 -0
- package/dist/core/environment.js +199 -0
- package/dist/core/session-logger.js +60 -6
- package/dist/core/version.js +2 -0
- package/dist/i18n/en.json +120 -39
- package/dist/i18n/ru.json +91 -10
- package/dist/llm/openai-compat.js +191 -53
- package/dist/llm/orchestrator.js +5 -3
- package/dist/logger/app-logger.js +50 -4
- package/dist/main.js +1288 -635
- package/dist/modules/browser/session.js +4 -0
- package/dist/modules/certification/cli.js +58 -19
- package/dist/modules/certification/loader.js +2 -1
- package/dist/modules/certification/manifest.js +22 -14
- package/dist/modules/certification/runner.js +91 -5
- package/dist/modules/certification/scenarios.js +290 -7
- package/dist/modules/context/fact-extractor.js +6 -0
- package/dist/modules/context/manager.js +19 -2
- package/dist/modules/execution/audit-runners.js +61 -7
- package/dist/modules/execution/execution-plugin.js +219 -60
- package/dist/modules/execution/module.js +207 -18
- package/dist/modules/execution/moe-executor.js +33 -20
- package/dist/modules/execution/plan-store.js +39 -0
- package/dist/modules/execution/plan-tool.js +188 -19
- package/dist/modules/execution/planner.js +27 -23
- package/dist/modules/execution/stuck-detector.js +244 -8
- package/dist/modules/execution/tracker.js +8 -6
- package/dist/modules/execution/verifier.js +15 -2
- package/dist/modules/hallucination/detector.js +4 -0
- package/dist/modules/hallucination/factual.js +45 -5
- package/dist/modules/indexer/module.js +1 -0
- package/dist/modules/lsp/client.js +123 -12
- package/dist/modules/lsp/index.js +1 -1
- package/dist/modules/lsp/module.js +30 -2
- package/dist/modules/lsp/probe.js +11 -1
- package/dist/modules/lsp/startup-check.js +5 -2
- package/dist/modules/plugins/builtin/lint-on-write.js +144 -41
- package/dist/modules/plugins/manager.js +57 -13
- package/dist/modules/pricing/index.js +61 -0
- package/dist/modules/pricing/prices.js +129 -0
- package/dist/modules/providers/create.js +22 -0
- package/dist/modules/providers/fallback.js +79 -0
- package/dist/modules/providers/health.js +46 -0
- package/dist/modules/providers/index.js +5 -0
- package/dist/modules/providers/manager.js +161 -0
- package/dist/modules/providers/presets.js +128 -0
- package/dist/modules/providers/registry.js +22 -0
- package/dist/modules/providers/types.js +1 -0
- package/dist/modules/registry.js +1 -0
- package/dist/modules/security/command-validator.js +14 -0
- package/dist/modules/security/encryption.js +6 -6
- package/dist/modules/security/network-validator.js +17 -0
- package/dist/modules/security/path-validator.js +22 -26
- package/dist/modules/session/store.js +10 -10
- package/dist/tools/approve.js +1 -0
- package/dist/tools/attach-image.js +12 -0
- package/dist/tools/bash.js +27 -4
- package/dist/tools/browser.js +1 -0
- package/dist/tools/chunk-query.js +1 -0
- package/dist/tools/create-dir.js +1 -0
- package/dist/tools/delete-file.js +1 -0
- package/dist/tools/download-file.js +1 -0
- package/dist/tools/edit-file.js +2 -1
- package/dist/tools/enable-tools.js +1 -0
- package/dist/tools/executor.js +17 -7
- package/dist/tools/file-info.js +1 -0
- package/dist/tools/glob-tool.js +1 -0
- package/dist/tools/grep-tool.js +54 -13
- package/dist/tools/list-dir.js +1 -0
- package/dist/tools/load-skill.js +1 -0
- package/dist/tools/mcp-call.js +1 -0
- package/dist/tools/move-file.js +1 -0
- package/dist/tools/path-utils.js +51 -1
- package/dist/tools/pipeline-run.js +1 -0
- package/dist/tools/process-kill.js +11 -0
- package/dist/tools/process-list.js +1 -0
- package/dist/tools/process-log.js +9 -0
- package/dist/tools/question.js +1 -0
- package/dist/tools/read-file.js +94 -6
- package/dist/tools/recall.js +1 -0
- package/dist/tools/remember.js +1 -0
- package/dist/tools/scope-check.js +7 -5
- package/dist/tools/search-history.js +1 -0
- package/dist/tools/subagent.js +4 -4
- package/dist/tools/web-browse.js +1 -0
- package/dist/tools/web-fetch.js +27 -6
- package/dist/tools/web-search.js +70 -43
- package/dist/tools/write-file.js +1 -0
- package/dist/ui/line-editor.js +142 -23
- package/dist/ui/line-math.js +8 -4
- package/dist/ui/renderer.js +57 -7
- package/package.json +50 -48
package/dist/tools/web-search.js
CHANGED
|
@@ -2,8 +2,53 @@ import { t } from "../i18n/index";
|
|
|
2
2
|
import { isUrlAllowed, sanitizeUrl } from "../modules/security/network-validator";
|
|
3
3
|
import { logNetworkRequest, logSecurityBlock } from "../modules/security/audit-log";
|
|
4
4
|
import { getSessionSecurityConfig } from "../modules/security/session-isolation";
|
|
5
|
+
/**
|
|
6
|
+
* Shared web search (DuckDuckGo HTML) used by BOTH the web_search tool and
|
|
7
|
+
* the automatic error search (execution module). `networkConfig` is the
|
|
8
|
+
* resolved security network config (may be undefined when security is off);
|
|
9
|
+
* `requestTimeoutMs` overrides the timeout when the caller has its own
|
|
10
|
+
* setting (the error search uses errorWebSearch.requestTimeoutMs).
|
|
11
|
+
*/
|
|
12
|
+
export async function performWebSearch(query, numResults, networkConfig, requestTimeoutMs) {
|
|
13
|
+
const url = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`;
|
|
14
|
+
const validation = isUrlAllowed(url, networkConfig);
|
|
15
|
+
if (!validation.allowed) {
|
|
16
|
+
return {
|
|
17
|
+
success: false,
|
|
18
|
+
blocked: true,
|
|
19
|
+
results: [],
|
|
20
|
+
error: validation.reason || "URL blocked by security policy",
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
try {
|
|
24
|
+
const response = await fetch(url, {
|
|
25
|
+
signal: AbortSignal.timeout(requestTimeoutMs ?? networkConfig?.requestTimeout ?? 10000),
|
|
26
|
+
});
|
|
27
|
+
const html = await response.text();
|
|
28
|
+
const results = [];
|
|
29
|
+
const snippetRegex = /<a[^>]+class="result__a"[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>[\s\S]*?<a[^>]+class="result__snippet"[^>]*>([\s\S]*?)<\/a>/gi;
|
|
30
|
+
let match;
|
|
31
|
+
while ((match = snippetRegex.exec(html)) !== null && results.length < numResults) {
|
|
32
|
+
results.push({
|
|
33
|
+
url: match[1].trim(),
|
|
34
|
+
title: match[2].replace(/<[^>]+>/g, "").trim(),
|
|
35
|
+
snippet: match[3].replace(/<[^>]+>/g, "").trim(),
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
return { success: true, results };
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
return { success: false, results: [], error: err.message };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function formatResults(results) {
|
|
45
|
+
return results
|
|
46
|
+
.map((r, i) => `${i + 1}. ${r.title}\n URL: ${r.url}\n ${r.snippet}`)
|
|
47
|
+
.join("\n");
|
|
48
|
+
}
|
|
5
49
|
export const webSearchTool = {
|
|
6
50
|
name: "web_search",
|
|
51
|
+
icon: "🌍",
|
|
7
52
|
description: "Search the web for information. Returns search results with titles and snippets. Use this for search queries instead of opening search engines (Google/Yandex/Bing) in the browser — they block headless browsers.",
|
|
8
53
|
tags: ["research"],
|
|
9
54
|
parameters: {
|
|
@@ -18,61 +63,43 @@ export const webSearchTool = {
|
|
|
18
63
|
required: ["query"],
|
|
19
64
|
},
|
|
20
65
|
handler: async (ctx, args) => {
|
|
66
|
+
if (ctx.config?.webSearch?.enabled === false) {
|
|
67
|
+
return { success: false, output: t("tool.web_search_disabled") };
|
|
68
|
+
}
|
|
21
69
|
const query = String(args.query || "");
|
|
22
70
|
const numResults = Number(args.numResults) || 5;
|
|
23
|
-
// Build search URL
|
|
24
|
-
const url = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`;
|
|
25
|
-
// Get session-specific security config
|
|
26
71
|
const securityConfig = ctx.sessionContext
|
|
27
72
|
? getSessionSecurityConfig(ctx.config, ctx.sessionContext).network
|
|
28
73
|
: ctx.config.security?.network;
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
74
|
+
const outcome = await performWebSearch(query, numResults, securityConfig);
|
|
75
|
+
const url = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`;
|
|
76
|
+
if (outcome.blocked) {
|
|
77
|
+
logSecurityBlock(ctx.sessionId, "network_request", outcome.error || "URL blocked by security policy", sanitizeUrl(url));
|
|
32
78
|
return {
|
|
33
79
|
success: false,
|
|
34
|
-
output:
|
|
80
|
+
output: t("error.search_failed", { message: String(outcome.error) }),
|
|
35
81
|
};
|
|
36
82
|
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
signal: AbortSignal.timeout(securityConfig?.requestTimeout || 10000),
|
|
40
|
-
});
|
|
41
|
-
const html = await response.text();
|
|
42
|
-
const results = [];
|
|
43
|
-
const snippetRegex = /<a[^>]+class="result__a"[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>[\s\S]*?<a[^>]+class="result__snippet"[^>]*>([\s\S]*?)<\/a>/gi;
|
|
44
|
-
let match;
|
|
45
|
-
let count = 0;
|
|
46
|
-
while ((match = snippetRegex.exec(html)) !== null && count < numResults) {
|
|
47
|
-
const href = match[1].trim();
|
|
48
|
-
const title = match[2].replace(/<[^>]+>/g, "").trim();
|
|
49
|
-
const snippet = match[3].replace(/<[^>]+>/g, "").trim();
|
|
50
|
-
results.push(`${count + 1}. ${title}\n URL: ${href}\n ${snippet}`);
|
|
51
|
-
count++;
|
|
52
|
-
}
|
|
53
|
-
// Log successful network request
|
|
54
|
-
logNetworkRequest(ctx.sessionId, sanitizeUrl(url), true, `Results: ${results.length}`);
|
|
55
|
-
if (results.length === 0) {
|
|
56
|
-
return { success: true, output: t("tool.no_results", { query }) };
|
|
57
|
-
}
|
|
58
|
-
return {
|
|
59
|
-
success: true,
|
|
60
|
-
output: t("tool.search_results", {
|
|
61
|
-
query,
|
|
62
|
-
results: results.join("\n"),
|
|
63
|
-
}),
|
|
64
|
-
display: t("tool.web_search_result", {
|
|
65
|
-
query,
|
|
66
|
-
count: String(results.length),
|
|
67
|
-
}),
|
|
68
|
-
};
|
|
69
|
-
}
|
|
70
|
-
catch (err) {
|
|
71
|
-
logNetworkRequest(ctx.sessionId, sanitizeUrl(url), false, `Error: ${err.message}`);
|
|
83
|
+
logNetworkRequest(ctx.sessionId, sanitizeUrl(url), outcome.success, `Results: ${outcome.results.length}`);
|
|
84
|
+
if (!outcome.success) {
|
|
72
85
|
return {
|
|
73
86
|
success: false,
|
|
74
|
-
output: t("error.search_failed", { message:
|
|
87
|
+
output: t("error.search_failed", { message: String(outcome.error) }),
|
|
75
88
|
};
|
|
76
89
|
}
|
|
90
|
+
if (outcome.results.length === 0) {
|
|
91
|
+
return { success: true, output: t("tool.no_results", { query }) };
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
success: true,
|
|
95
|
+
output: t("tool.search_results", {
|
|
96
|
+
query,
|
|
97
|
+
results: formatResults(outcome.results),
|
|
98
|
+
}),
|
|
99
|
+
display: t("tool.web_search_result", {
|
|
100
|
+
query,
|
|
101
|
+
count: String(outcome.results.length),
|
|
102
|
+
}),
|
|
103
|
+
};
|
|
77
104
|
},
|
|
78
105
|
};
|
package/dist/tools/write-file.js
CHANGED
|
@@ -9,6 +9,7 @@ import { generateDiff, generateNewFileDiff } from "../ui/diff";
|
|
|
9
9
|
import { safeResolvePath } from "./path-utils";
|
|
10
10
|
export const writeFileTool = {
|
|
11
11
|
name: "write_file",
|
|
12
|
+
icon: "📝",
|
|
12
13
|
description: "Create or overwrite a file with given content. Creates intermediate directories if needed.",
|
|
13
14
|
tags: ["file", "code"],
|
|
14
15
|
parameters: {
|
package/dist/ui/line-editor.js
CHANGED
|
@@ -6,6 +6,7 @@ export class LineEditor {
|
|
|
6
6
|
output;
|
|
7
7
|
/** Host hook: return true to swallow the key before the editor handles it. */
|
|
8
8
|
onKeyInput;
|
|
9
|
+
onInterrupt;
|
|
9
10
|
promptStr;
|
|
10
11
|
completer;
|
|
11
12
|
history;
|
|
@@ -29,15 +30,28 @@ export class LineEditor {
|
|
|
29
30
|
rawMode = false;
|
|
30
31
|
listeners = {};
|
|
31
32
|
/** Visual row (within the drawn frame) where the cursor was placed by the
|
|
32
|
-
* last render —
|
|
33
|
-
* redrawing
|
|
34
|
-
*
|
|
33
|
+
* last render — fallback for moving back to the frame's first row before
|
|
34
|
+
* redrawing when no absolute anchor is known yet. Relative moves break
|
|
35
|
+
* once the terminal scrolls (the frame shifts up on screen while the
|
|
36
|
+
* relative count does not), which duplicated multi-line input. */
|
|
35
37
|
prevCursorRow = 0;
|
|
38
|
+
/** Absolute 1-based screen row of the frame's first visual row, learned
|
|
39
|
+
* from a single DSR cursor-position report (`ESC[6n`) sent once per
|
|
40
|
+
* prompt presentation. After the initial query the anchor is updated
|
|
41
|
+
* deterministically via scroll math — no further queries are sent,
|
|
42
|
+
* avoiding ConPTY desync. Falls back to relative moves when the reply
|
|
43
|
+
* never arrives (timeout / broken terminal). */
|
|
44
|
+
frameTopAbs = null;
|
|
45
|
+
/** True while waiting for a DSR reply (exactly one outstanding query). */
|
|
46
|
+
dsrPending = false;
|
|
47
|
+
/** Buffer for partial DSR escape sequences split across data chunks. */
|
|
48
|
+
dsrTail = "";
|
|
36
49
|
constructor(opts) {
|
|
37
50
|
this.input = opts.input;
|
|
38
51
|
this.output = opts.output;
|
|
39
52
|
this.promptStr = opts.prompt ?? "";
|
|
40
53
|
this.completer = opts.completer;
|
|
54
|
+
this.onInterrupt = opts.onInterrupt;
|
|
41
55
|
this.history = opts.history ?? [];
|
|
42
56
|
this.historySize = opts.historySize ?? 50;
|
|
43
57
|
if (this.history.length > this.historySize) {
|
|
@@ -48,6 +62,10 @@ export class LineEditor {
|
|
|
48
62
|
if (this.rawMode)
|
|
49
63
|
this.input.setRawMode(true);
|
|
50
64
|
this.enableTerminalProtocols();
|
|
65
|
+
// Raw data listener for DSR cursor-position replies. Runs alongside
|
|
66
|
+
// readline's keypress emitter (both receive every chunk); replies may
|
|
67
|
+
// arrive merged with regular keystrokes, hence the global regex.
|
|
68
|
+
this.input.on("data", (buf) => this.consumeDsrReplies(buf.toString("utf-8")));
|
|
51
69
|
readline.emitKeypressEvents(this.input);
|
|
52
70
|
this.input.on("keypress", (str, key) => this.onKey(str, key));
|
|
53
71
|
}
|
|
@@ -91,7 +109,7 @@ export class LineEditor {
|
|
|
91
109
|
this.col = 0;
|
|
92
110
|
this.historyIndex = -1;
|
|
93
111
|
this.stash = null;
|
|
94
|
-
this.
|
|
112
|
+
this.invalidateAnchor();
|
|
95
113
|
if (this.input.isTTY)
|
|
96
114
|
this.render();
|
|
97
115
|
}
|
|
@@ -101,7 +119,7 @@ export class LineEditor {
|
|
|
101
119
|
this.lines = [""];
|
|
102
120
|
this.row = 0;
|
|
103
121
|
this.col = 0;
|
|
104
|
-
this.
|
|
122
|
+
this.invalidateAnchor();
|
|
105
123
|
if (this.input.isTTY)
|
|
106
124
|
this.render();
|
|
107
125
|
}
|
|
@@ -213,6 +231,12 @@ export class LineEditor {
|
|
|
213
231
|
this.lastWasCR = false;
|
|
214
232
|
return;
|
|
215
233
|
}
|
|
234
|
+
// In question mode, LF should also submit (script/pipes send
|
|
235
|
+
// \n without preceding \r, unlike real terminals).
|
|
236
|
+
if (this.questionCb) {
|
|
237
|
+
this.submit();
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
216
240
|
this.insertNewline();
|
|
217
241
|
return;
|
|
218
242
|
case "backspace":
|
|
@@ -319,10 +343,10 @@ export class LineEditor {
|
|
|
319
343
|
const answer = this.lines.join("\n").trim();
|
|
320
344
|
this.commitFrame();
|
|
321
345
|
this.output.write("\r\n");
|
|
346
|
+
this.invalidateAnchor();
|
|
322
347
|
this.lines = [""];
|
|
323
348
|
this.row = 0;
|
|
324
349
|
this.col = 0;
|
|
325
|
-
this.prevCursorRow = 0;
|
|
326
350
|
cb(answer);
|
|
327
351
|
return;
|
|
328
352
|
}
|
|
@@ -337,19 +361,25 @@ export class LineEditor {
|
|
|
337
361
|
this.stash = null;
|
|
338
362
|
this.commitFrame();
|
|
339
363
|
this.output.write("\r\n");
|
|
364
|
+
this.invalidateAnchor();
|
|
340
365
|
this.lines = [""];
|
|
341
366
|
this.row = 0;
|
|
342
367
|
this.col = 0;
|
|
343
|
-
this.prevCursorRow = 0;
|
|
344
368
|
this.emitLine(text);
|
|
345
369
|
}
|
|
346
370
|
/** Move the cursor to the end of the buffer's last visual row so the
|
|
347
|
-
* submitted text stays on screen (commit), without erasing the frame.
|
|
371
|
+
* submitted text stays on screen (commit), without erasing the frame.
|
|
372
|
+
* Uses the absolute anchor when known — relative moves land short once
|
|
373
|
+
* a tall frame has scrolled the terminal. */
|
|
348
374
|
commitFrame() {
|
|
349
|
-
const layout = this.
|
|
375
|
+
const layout = this.visibleLayout();
|
|
376
|
+
const lastLen = charLen(stripAnsi(layout[layout.length - 1].text));
|
|
377
|
+
if (this.frameTopAbs !== null) {
|
|
378
|
+
this.output.write(`\x1b[${this.frameTopAbs + layout.length - 1};${lastLen + 1}H`);
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
350
381
|
const idx = layoutIndexFor(layout, this.row, this.col);
|
|
351
382
|
const rowsDown = layout.length - 1 - idx;
|
|
352
|
-
const lastLen = charLen(stripAnsi(layout[layout.length - 1].text));
|
|
353
383
|
const out = [];
|
|
354
384
|
if (rowsDown > 0)
|
|
355
385
|
out.push(`\x1b[${rowsDown}B`);
|
|
@@ -362,9 +392,18 @@ export class LineEditor {
|
|
|
362
392
|
this.lines = [""];
|
|
363
393
|
this.row = 0;
|
|
364
394
|
this.col = 0;
|
|
365
|
-
this.
|
|
395
|
+
this.invalidateAnchor();
|
|
366
396
|
this.render();
|
|
367
|
-
|
|
397
|
+
if (this.onInterrupt) {
|
|
398
|
+
this.onInterrupt();
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
// Default: emit only when someone listens. With zero handlers
|
|
402
|
+
// process.emit("SIGINT") takes the DEFAULT action (process termination);
|
|
403
|
+
// the REPL always installs a listener, so production is unaffected.
|
|
404
|
+
if (process.listenerCount("SIGINT") > 0) {
|
|
405
|
+
process.emit("SIGINT");
|
|
406
|
+
}
|
|
368
407
|
}
|
|
369
408
|
ctrlD() {
|
|
370
409
|
if (this.lines.length === 1 && this.lines[0] === "") {
|
|
@@ -641,7 +680,7 @@ export class LineEditor {
|
|
|
641
680
|
}
|
|
642
681
|
clearScreen() {
|
|
643
682
|
this.output.write("\x1b[2J\x1b[H");
|
|
644
|
-
this.
|
|
683
|
+
this.invalidateAnchor();
|
|
645
684
|
this.render();
|
|
646
685
|
}
|
|
647
686
|
// ---- rendering ----
|
|
@@ -649,26 +688,83 @@ export class LineEditor {
|
|
|
649
688
|
return this.output.columns || 80;
|
|
650
689
|
}
|
|
651
690
|
layout() {
|
|
652
|
-
|
|
691
|
+
const promptWidth = stringWidth(stripAnsi(this.promptStr));
|
|
692
|
+
return buildLayout(this.lines, this.columns(), Math.max(1, this.columns() - promptWidth));
|
|
693
|
+
}
|
|
694
|
+
/** 1-based terminal height, or null when unknown (tests / non-TTY). */
|
|
695
|
+
screenRows() {
|
|
696
|
+
const r = this.output.rows;
|
|
697
|
+
return typeof r === "number" && r > 3 ? r : null;
|
|
698
|
+
}
|
|
699
|
+
/** Cap the rendered frame to the visible screen. A frame taller than the
|
|
700
|
+
* terminal cannot be redrawn in place at all (its top scrolls off), so
|
|
701
|
+
* only the tail is drawn; the absolute anchor keeps the next render
|
|
702
|
+
* consistent regardless. */
|
|
703
|
+
visibleLayout() {
|
|
704
|
+
const layout = this.layout();
|
|
705
|
+
const H = this.screenRows();
|
|
706
|
+
const maxRows = H ? H - 2 : 0;
|
|
707
|
+
if (maxRows > 0 && layout.length > maxRows) {
|
|
708
|
+
return layout.slice(layout.length - maxRows);
|
|
709
|
+
}
|
|
710
|
+
return layout;
|
|
711
|
+
}
|
|
712
|
+
/** Forget the learned absolute frame position — the cursor has jumped to
|
|
713
|
+
* an unknown location (external output, clear screen, fresh prompt).
|
|
714
|
+
* The next render will re-query via DSR. */
|
|
715
|
+
invalidateAnchor() {
|
|
716
|
+
this.frameTopAbs = null;
|
|
717
|
+
this.dsrPending = false;
|
|
718
|
+
this.dsrTail = "";
|
|
719
|
+
this.prevCursorRow = 0;
|
|
720
|
+
}
|
|
721
|
+
/** Parse `ESC[row;colR` cursor-position replies. Buffers partial escape
|
|
722
|
+
* sequences across chunks. Accepts at most one reply (the outstanding
|
|
723
|
+
* query); extras are silently dropped. Validates the reported row against
|
|
724
|
+
* `screenRows()` to reject implausible ConPTY values. */
|
|
725
|
+
consumeDsrReplies(chunk) {
|
|
726
|
+
this.dsrTail = (this.dsrTail + chunk).slice(-80);
|
|
727
|
+
const m = /\x1b\[(\d+);(\d+)R/.exec(this.dsrTail);
|
|
728
|
+
if (!m)
|
|
729
|
+
return;
|
|
730
|
+
this.dsrTail = "";
|
|
731
|
+
if (!this.dsrPending)
|
|
732
|
+
return;
|
|
733
|
+
this.dsrPending = false;
|
|
734
|
+
const row = Number(m[1]);
|
|
735
|
+
const idx = Number(m[2]);
|
|
736
|
+
// idx is not the layout index — it's the column from the reply; ignore it.
|
|
737
|
+
// We compute frameTop from the reply row minus the cursor's visual row
|
|
738
|
+
// at query time. The visual row was stored in `prevCursorRow` when the
|
|
739
|
+
// query was sent (right after render).
|
|
740
|
+
const top = row - this.prevCursorRow;
|
|
741
|
+
if (top >= 1)
|
|
742
|
+
this.frameTopAbs = top;
|
|
653
743
|
}
|
|
654
744
|
render() {
|
|
655
745
|
if (!this.input.isTTY || this.isDone)
|
|
656
746
|
return;
|
|
657
|
-
const layout = this.
|
|
747
|
+
const layout = this.visibleLayout();
|
|
658
748
|
const promptWidth = stringWidth(stripAnsi(this.promptStr));
|
|
659
749
|
const idx = layoutIndexFor(layout, this.row, this.col);
|
|
660
750
|
const vcol = visualColAt(layout, idx, this.col);
|
|
661
751
|
const rowsAfter = layout.length - idx - 1;
|
|
662
752
|
const ccol = (idx === 0 ? promptWidth : 0) + vcol;
|
|
663
753
|
const out = [];
|
|
664
|
-
//
|
|
665
|
-
//
|
|
666
|
-
//
|
|
667
|
-
//
|
|
668
|
-
|
|
669
|
-
|
|
754
|
+
// Return to the frame's first row before redrawing. Absolute
|
|
755
|
+
// positioning (learned via a single DSR query per prompt) is
|
|
756
|
+
// scroll-proof; relative up-moves are the fallback until the
|
|
757
|
+
// initial reply arrives.
|
|
758
|
+
if (this.frameTopAbs !== null) {
|
|
759
|
+
out.push(`\x1b[${this.frameTopAbs};1H`);
|
|
760
|
+
}
|
|
761
|
+
else if (this.prevCursorRow > 0) {
|
|
670
762
|
out.push(`\x1b[${this.prevCursorRow}A`);
|
|
671
|
-
|
|
763
|
+
out.push("\r");
|
|
764
|
+
}
|
|
765
|
+
else {
|
|
766
|
+
out.push("\r");
|
|
767
|
+
}
|
|
672
768
|
for (let i = 0; i < layout.length; i++) {
|
|
673
769
|
out.push("\x1b[2K");
|
|
674
770
|
if (i === 0)
|
|
@@ -683,8 +779,31 @@ export class LineEditor {
|
|
|
683
779
|
out.push("\r");
|
|
684
780
|
if (ccol > 0)
|
|
685
781
|
out.push(`\x1b[${ccol}C`);
|
|
686
|
-
this.prevCursorRow = idx;
|
|
687
782
|
this.output.write(out.join(""));
|
|
783
|
+
// Deterministic scroll math: update the anchor for the NEXT render.
|
|
784
|
+
// After writing L rows starting from frameTopAbs, if the frame extends
|
|
785
|
+
// past the screen bottom the terminal scrolls; the new frame top is
|
|
786
|
+
// computed locally — no further DSR queries needed.
|
|
787
|
+
if (this.frameTopAbs !== null) {
|
|
788
|
+
const H = this.screenRows();
|
|
789
|
+
if (H) {
|
|
790
|
+
const T = this.frameTopAbs;
|
|
791
|
+
const L = layout.length;
|
|
792
|
+
if (T + L - 1 > H)
|
|
793
|
+
this.frameTopAbs = H - L + 1;
|
|
794
|
+
// else: no scroll, anchor unchanged
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
else if (!this.dsrPending) {
|
|
798
|
+
// First render after prompt — send exactly ONE DSR query.
|
|
799
|
+
// Disabled for now: ConPTY may buffer stdout while processing
|
|
800
|
+
// the DSR reply, hiding agent responses. Will revisit once
|
|
801
|
+
// scroll-proof rendering is solved via deterministic tracking.
|
|
802
|
+
//
|
|
803
|
+
// this.dsrPending = true;
|
|
804
|
+
// this.output.write("\x1b[6n");
|
|
805
|
+
}
|
|
806
|
+
this.prevCursorRow = idx;
|
|
688
807
|
}
|
|
689
808
|
enableTerminalProtocols() {
|
|
690
809
|
this.output.write("\x1b[?2004h");
|
package/dist/ui/line-math.js
CHANGED
|
@@ -6,8 +6,11 @@ export function stripAnsi(s) {
|
|
|
6
6
|
export function charLen(s) {
|
|
7
7
|
return Array.from(s).length;
|
|
8
8
|
}
|
|
9
|
-
export function wrapLine(line, width) {
|
|
9
|
+
export function wrapLine(line, width, firstRowWidth) {
|
|
10
10
|
const w = width > 0 ? width : 80;
|
|
11
|
+
// Only the FIRST visual row shares its screen line with something else
|
|
12
|
+
// (e.g. the prompt); every continuation row uses the full width.
|
|
13
|
+
let limit = firstRowWidth !== undefined && firstRowWidth > 0 ? firstRowWidth : w;
|
|
11
14
|
const chars = Array.from(line);
|
|
12
15
|
const rows = [];
|
|
13
16
|
let start = 0;
|
|
@@ -21,10 +24,11 @@ export function wrapLine(line, width) {
|
|
|
21
24
|
continue;
|
|
22
25
|
}
|
|
23
26
|
const cw = stringWidth(ch);
|
|
24
|
-
if (cells + cw >
|
|
27
|
+
if (cells + cw > limit && cells > 0) {
|
|
25
28
|
rows.push({ text: chars.slice(start, i).join(""), charStart: start });
|
|
26
29
|
start = i;
|
|
27
30
|
cells = cw;
|
|
31
|
+
limit = w;
|
|
28
32
|
}
|
|
29
33
|
else {
|
|
30
34
|
cells += cw;
|
|
@@ -33,10 +37,10 @@ export function wrapLine(line, width) {
|
|
|
33
37
|
rows.push({ text: chars.slice(start).join(""), charStart: start });
|
|
34
38
|
return rows;
|
|
35
39
|
}
|
|
36
|
-
export function buildLayout(lines, width) {
|
|
40
|
+
export function buildLayout(lines, width, firstRowWidth) {
|
|
37
41
|
const rows = [];
|
|
38
42
|
for (let b = 0; b < lines.length; b++) {
|
|
39
|
-
for (const r of wrapLine(lines[b], width)) {
|
|
43
|
+
for (const r of wrapLine(lines[b], width, b === 0 ? firstRowWidth : undefined)) {
|
|
40
44
|
rows.push({ bufRow: b, charStart: r.charStart, text: r.text });
|
|
41
45
|
}
|
|
42
46
|
}
|
package/dist/ui/renderer.js
CHANGED
|
@@ -4,7 +4,34 @@ import { Spinner } from "./spinner";
|
|
|
4
4
|
import { box, divider } from "./box";
|
|
5
5
|
import { getTerminalWidth } from "./table";
|
|
6
6
|
import { t } from "../i18n/index";
|
|
7
|
+
import { formatCost } from "../modules/pricing/prices";
|
|
8
|
+
import { isAbsolute, relative, sep } from "path";
|
|
9
|
+
function formatUsd(cost) {
|
|
10
|
+
return formatCost(cost);
|
|
11
|
+
}
|
|
12
|
+
/** Tools whose `path` argument is shown in the header. */
|
|
13
|
+
const PATH_TOOLS = new Set([
|
|
14
|
+
"read_file",
|
|
15
|
+
"write_file",
|
|
16
|
+
"edit_file",
|
|
17
|
+
"delete_file",
|
|
18
|
+
"create_dir",
|
|
19
|
+
"move_file",
|
|
20
|
+
"list_dir",
|
|
21
|
+
"file_info",
|
|
22
|
+
]);
|
|
23
|
+
function toDisplayPath(baseDir, p) {
|
|
24
|
+
if (!baseDir || !isAbsolute(p))
|
|
25
|
+
return p;
|
|
26
|
+
const rel = relative(baseDir, p);
|
|
27
|
+
// Outside baseDir (../ prefix) or another drive (absolute result) → keep as-is.
|
|
28
|
+
if (!rel || rel.startsWith("..") || isAbsolute(rel))
|
|
29
|
+
return p;
|
|
30
|
+
return rel.split(sep).join("/");
|
|
31
|
+
}
|
|
7
32
|
const GUTTER = " ";
|
|
33
|
+
/** Tools that show a busy spinner while running (inline mode). */
|
|
34
|
+
const BUSY_TOOLS = new Set(["lsp_check"]);
|
|
8
35
|
/** opencode-like leading marker per tool category. */
|
|
9
36
|
export function toolMarker(tool) {
|
|
10
37
|
switch (tool) {
|
|
@@ -62,6 +89,7 @@ export class Renderer {
|
|
|
62
89
|
err;
|
|
63
90
|
width;
|
|
64
91
|
toolStyle;
|
|
92
|
+
baseDir;
|
|
65
93
|
card = null;
|
|
66
94
|
constructor(opts = {}) {
|
|
67
95
|
this.rich = opts.rich ?? isRichTerminal();
|
|
@@ -69,6 +97,7 @@ export class Renderer {
|
|
|
69
97
|
this.err = opts.err ?? process.stderr;
|
|
70
98
|
this.width = opts.width ?? getTerminalWidth();
|
|
71
99
|
this.toolStyle = opts.toolStyle ?? "inline";
|
|
100
|
+
this.baseDir = opts.baseDir;
|
|
72
101
|
this.spinner = new Spinner({
|
|
73
102
|
enabled: this.rich && (opts.spinner ?? true),
|
|
74
103
|
stream: this.err,
|
|
@@ -118,22 +147,28 @@ export class Renderer {
|
|
|
118
147
|
thinkingEnd() {
|
|
119
148
|
this.spinner.stop();
|
|
120
149
|
}
|
|
121
|
-
toolStart(tool, args, stepContext) {
|
|
150
|
+
toolStart(tool, args, stepContext, icon) {
|
|
122
151
|
this.endCard();
|
|
123
152
|
this.spinner.stop();
|
|
124
|
-
const
|
|
153
|
+
const displayArgs = PATH_TOOLS.has(tool) && typeof args.path === "string"
|
|
154
|
+
? { ...args, path: toDisplayPath(this.baseDir, args.path) }
|
|
155
|
+
: args;
|
|
156
|
+
const summary = summarizeArgs(displayArgs);
|
|
125
157
|
const step = stepContext ? ` ${pc.cyan(`← ${stepContext}`)}` : "";
|
|
158
|
+
const marker = icon || toolMarker(tool);
|
|
126
159
|
if (!this.rich) {
|
|
127
|
-
this.out.write(`\n${pc.dim(
|
|
160
|
+
this.out.write(`\n${pc.dim(marker)} ${friendlyTool(tool)}${summary ? ` ${pc.dim(summary)}` : ""}${step}\n`);
|
|
128
161
|
return;
|
|
129
162
|
}
|
|
130
163
|
this.card = { tool, args, body: [], start: Date.now() };
|
|
131
164
|
if (this.toolStyle === "inline") {
|
|
132
|
-
const marker = toolMarker(tool);
|
|
133
165
|
this.out.write(`\n${pc.dim(marker)} ${friendlyTool(tool)}${summary ? ` ${pc.dim(summary)}` : ""}${step}\n`);
|
|
166
|
+
if (BUSY_TOOLS.has(tool)) {
|
|
167
|
+
this.spinner.start(t("ui.tool_running", { tool: friendlyTool(tool) }));
|
|
168
|
+
}
|
|
134
169
|
return;
|
|
135
170
|
}
|
|
136
|
-
this.spinner.start(`${pc.dim(
|
|
171
|
+
this.spinner.start(`${pc.dim(marker)} ${friendlyTool(tool)}${summary ? ` ${pc.dim(summary)}` : ""}${step}`);
|
|
137
172
|
}
|
|
138
173
|
/** Live plan checklist — prints the plan block as-is (already colored). */
|
|
139
174
|
planBlock(lines) {
|
|
@@ -143,13 +178,22 @@ export class Renderer {
|
|
|
143
178
|
this.out.write(`${line}\n`);
|
|
144
179
|
}
|
|
145
180
|
}
|
|
146
|
-
toolEnd(_tool, duration, error, ctxDelta) {
|
|
181
|
+
toolEnd(_tool, duration, error, ctxDelta, costUsd, provider, model) {
|
|
147
182
|
this.spinner.stop();
|
|
183
|
+
const prov = provider && model ? `${pc.dim(`${provider}·${model}`)}` : undefined;
|
|
148
184
|
if (!this.rich) {
|
|
185
|
+
const parts = [];
|
|
149
186
|
if (ctxDelta !== undefined && ctxDelta !== 0) {
|
|
150
187
|
const deltaStr = ctxDelta > 0 ? pc.green(`+${ctxDelta}`) : pc.yellow(`${ctxDelta} ↓`);
|
|
151
|
-
|
|
188
|
+
parts.push(`${pc.dim("ctx")} ${deltaStr}`);
|
|
189
|
+
}
|
|
190
|
+
if (costUsd !== undefined && costUsd > 0) {
|
|
191
|
+
parts.push(`${pc.dim("cost")} ${pc.yellow(formatUsd(costUsd))}`);
|
|
152
192
|
}
|
|
193
|
+
if (prov)
|
|
194
|
+
parts.push(prov);
|
|
195
|
+
if (parts.length > 0)
|
|
196
|
+
this.out.write(`${parts.join(" ")}\n`);
|
|
153
197
|
return;
|
|
154
198
|
}
|
|
155
199
|
if (!this.card)
|
|
@@ -161,6 +205,12 @@ export class Renderer {
|
|
|
161
205
|
const deltaStr = ctxDelta > 0 ? pc.green(`+${ctxDelta}`) : pc.yellow(`${ctxDelta} ↓`);
|
|
162
206
|
footer += ` ${pc.dim("ctx")} ${deltaStr}`;
|
|
163
207
|
}
|
|
208
|
+
if (costUsd !== undefined && costUsd > 0) {
|
|
209
|
+
footer += ` ${pc.dim("cost")} ${pc.yellow(formatUsd(costUsd))}`;
|
|
210
|
+
}
|
|
211
|
+
if (prov) {
|
|
212
|
+
footer += ` ${pc.dim("via")} ${prov}`;
|
|
213
|
+
}
|
|
164
214
|
if (this.toolStyle === "inline") {
|
|
165
215
|
this.out.write(`${GUTTER}${footer}\n`);
|
|
166
216
|
this.out.write(`${divider(this.width)}\n`);
|