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.
Files changed (107) hide show
  1. package/dist/cli/commands.js +162 -38
  2. package/dist/cli/completer.js +5 -5
  3. package/dist/cli/main.js +42 -54
  4. package/dist/cli/repl-commands.js +138 -38
  5. package/dist/cli/repl.js +175 -89
  6. package/dist/cli/run-result.js +11 -0
  7. package/dist/cli/security-commands.js +6 -6
  8. package/dist/cli/setup.js +21 -15
  9. package/dist/config/config.js +54 -27
  10. package/dist/config/defaults.js +17 -0
  11. package/dist/config/domains.js +179 -0
  12. package/dist/config/index.js +2 -1
  13. package/dist/config/security.js +28 -8
  14. package/dist/core/agent.js +162 -30
  15. package/dist/core/bootstrap.js +94 -17
  16. package/dist/core/crash-handler.js +51 -0
  17. package/dist/core/environment.js +199 -0
  18. package/dist/core/session-logger.js +60 -6
  19. package/dist/core/version.js +2 -0
  20. package/dist/i18n/en.json +120 -39
  21. package/dist/i18n/ru.json +91 -10
  22. package/dist/llm/openai-compat.js +191 -53
  23. package/dist/llm/orchestrator.js +5 -3
  24. package/dist/logger/app-logger.js +50 -4
  25. package/dist/main.js +1288 -635
  26. package/dist/modules/browser/session.js +4 -0
  27. package/dist/modules/certification/cli.js +58 -19
  28. package/dist/modules/certification/loader.js +2 -1
  29. package/dist/modules/certification/manifest.js +22 -14
  30. package/dist/modules/certification/runner.js +91 -5
  31. package/dist/modules/certification/scenarios.js +290 -7
  32. package/dist/modules/context/fact-extractor.js +6 -0
  33. package/dist/modules/context/manager.js +19 -2
  34. package/dist/modules/execution/audit-runners.js +61 -7
  35. package/dist/modules/execution/execution-plugin.js +219 -60
  36. package/dist/modules/execution/module.js +207 -18
  37. package/dist/modules/execution/moe-executor.js +33 -20
  38. package/dist/modules/execution/plan-store.js +39 -0
  39. package/dist/modules/execution/plan-tool.js +188 -19
  40. package/dist/modules/execution/planner.js +27 -23
  41. package/dist/modules/execution/stuck-detector.js +244 -8
  42. package/dist/modules/execution/tracker.js +8 -6
  43. package/dist/modules/execution/verifier.js +15 -2
  44. package/dist/modules/hallucination/detector.js +4 -0
  45. package/dist/modules/hallucination/factual.js +45 -5
  46. package/dist/modules/indexer/module.js +1 -0
  47. package/dist/modules/lsp/client.js +123 -12
  48. package/dist/modules/lsp/index.js +1 -1
  49. package/dist/modules/lsp/module.js +30 -2
  50. package/dist/modules/lsp/probe.js +11 -1
  51. package/dist/modules/lsp/startup-check.js +5 -2
  52. package/dist/modules/plugins/builtin/lint-on-write.js +144 -41
  53. package/dist/modules/plugins/manager.js +57 -13
  54. package/dist/modules/pricing/index.js +61 -0
  55. package/dist/modules/pricing/prices.js +129 -0
  56. package/dist/modules/providers/create.js +22 -0
  57. package/dist/modules/providers/fallback.js +79 -0
  58. package/dist/modules/providers/health.js +46 -0
  59. package/dist/modules/providers/index.js +5 -0
  60. package/dist/modules/providers/manager.js +161 -0
  61. package/dist/modules/providers/presets.js +128 -0
  62. package/dist/modules/providers/registry.js +22 -0
  63. package/dist/modules/providers/types.js +1 -0
  64. package/dist/modules/registry.js +1 -0
  65. package/dist/modules/security/command-validator.js +14 -0
  66. package/dist/modules/security/encryption.js +6 -6
  67. package/dist/modules/security/network-validator.js +17 -0
  68. package/dist/modules/security/path-validator.js +22 -26
  69. package/dist/modules/session/store.js +10 -10
  70. package/dist/tools/approve.js +1 -0
  71. package/dist/tools/attach-image.js +12 -0
  72. package/dist/tools/bash.js +27 -4
  73. package/dist/tools/browser.js +1 -0
  74. package/dist/tools/chunk-query.js +1 -0
  75. package/dist/tools/create-dir.js +1 -0
  76. package/dist/tools/delete-file.js +1 -0
  77. package/dist/tools/download-file.js +1 -0
  78. package/dist/tools/edit-file.js +2 -1
  79. package/dist/tools/enable-tools.js +1 -0
  80. package/dist/tools/executor.js +17 -7
  81. package/dist/tools/file-info.js +1 -0
  82. package/dist/tools/glob-tool.js +1 -0
  83. package/dist/tools/grep-tool.js +54 -13
  84. package/dist/tools/list-dir.js +1 -0
  85. package/dist/tools/load-skill.js +1 -0
  86. package/dist/tools/mcp-call.js +1 -0
  87. package/dist/tools/move-file.js +1 -0
  88. package/dist/tools/path-utils.js +51 -1
  89. package/dist/tools/pipeline-run.js +1 -0
  90. package/dist/tools/process-kill.js +11 -0
  91. package/dist/tools/process-list.js +1 -0
  92. package/dist/tools/process-log.js +9 -0
  93. package/dist/tools/question.js +1 -0
  94. package/dist/tools/read-file.js +94 -6
  95. package/dist/tools/recall.js +1 -0
  96. package/dist/tools/remember.js +1 -0
  97. package/dist/tools/scope-check.js +7 -5
  98. package/dist/tools/search-history.js +1 -0
  99. package/dist/tools/subagent.js +4 -4
  100. package/dist/tools/web-browse.js +1 -0
  101. package/dist/tools/web-fetch.js +27 -6
  102. package/dist/tools/web-search.js +70 -43
  103. package/dist/tools/write-file.js +1 -0
  104. package/dist/ui/line-editor.js +142 -23
  105. package/dist/ui/line-math.js +8 -4
  106. package/dist/ui/renderer.js +57 -7
  107. package/package.json +50 -48
@@ -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 validation = isUrlAllowed(url, securityConfig);
30
- if (!validation.allowed) {
31
- logSecurityBlock(ctx.sessionId, "network_request", validation.reason || "URL blocked by security policy", sanitizeUrl(url));
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: `[SECURITY BLOCKED] Search URL is not allowed: ${validation.reason}`,
80
+ output: t("error.search_failed", { message: String(outcome.error) }),
35
81
  };
36
82
  }
37
- try {
38
- const response = await fetch(url, {
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: err.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
  };
@@ -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: {
@@ -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 — used to move back to the frame's first row before
33
- * redrawing, so a redraw starting on a middle/bottom row never leaves
34
- * stale lines above (duplicated input text on newline). */
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.prevCursorRow = 0;
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.prevCursorRow = 0;
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.layout();
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.prevCursorRow = 0;
395
+ this.invalidateAnchor();
366
396
  this.render();
367
- process.emit("SIGINT");
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.prevCursorRow = 0;
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
- return buildLayout(this.lines, this.columns());
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.layout();
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
- // Move back up to the frame's first row before redrawing. Without this,
665
- // a redraw that starts on a middle/bottom row (e.g. typing on line 2 of
666
- // a multi-line buffer) writes the whole frame one row too low and leaves
667
- // the previous frame's top line stale on screen — the user sees every
668
- // line duplicated.
669
- if (this.prevCursorRow > 0)
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
- out.push("\r");
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");
@@ -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 > w && cells > 0) {
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
  }
@@ -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 summary = summarizeArgs(args);
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("⚙")} ${friendlyTool(tool)}${summary ? ` ${pc.dim(summary)}` : ""}${step}\n`);
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("⚙")} ${friendlyTool(tool)}${summary ? ` ${pc.dim(summary)}` : ""}${step}`);
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
- this.out.write(`${pc.dim("ctx")} ${deltaStr}\n`);
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`);