pi-ast-sgrep 2.1.1 → 2.2.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.
@@ -18,7 +18,7 @@ async function probeIndexHealth(runtime, rootContext, options) {
18
18
  const status = runtime.nativeCall
19
19
  ? await runtime.nativeCall("index_status", {}, rootContext, options)
20
20
  : await runtime.run(["status", ".", "--json"], rootContext, options);
21
- return indexHealth(status, hinted === "ready");
21
+ return indexHealth(status);
22
22
  }
23
23
  catch (cause) {
24
24
  if (!incompatibleStatusFailure(cause))
@@ -26,11 +26,18 @@ async function probeIndexHealth(runtime, rootContext, options) {
26
26
  return "incompatible";
27
27
  }
28
28
  }
29
- /** Run index_repo via native sticky pool or CLI argv. force=true → reindex. */
29
+ /**
30
+ * Run index_repo via the host's native call (the extension routes it out of
31
+ * process — see host/tools.ts) or CLI argv. force=true → reindex.
32
+ *
33
+ * Implicit (freshness-driven) refreshes index lexical/AST rows only: neural
34
+ * embeddings for a cold repo took 36-60s on large trees before the first search
35
+ * could answer. Embeddings are built by the explicit index/reindex tool.
36
+ */
30
37
  async function runIndex(runtime, force, rootContext, options) {
31
38
  const response = runtime.nativeCall
32
- ? await runtime.nativeCall("index_repo", { force }, rootContext, options)
33
- : await runtime.run([force ? "reindex" : "index", ".", "--json"], rootContext, options);
39
+ ? await runtime.nativeCall("index_repo", { force, use_embed: false }, rootContext, options)
40
+ : await runtime.run([force ? "reindex" : "index", ".", "--json", "--no-embed"], rootContext, options);
34
41
  const { failed, walkErrors } = indexCompletion(response, true);
35
42
  if (failed > 0 || walkErrors) {
36
43
  throw new RuntimeError("INDEX_UPDATE_INCOMPLETE", "ast-sgrep did not complete the full index reconciliation", { failed, walkErrors, force });
@@ -41,8 +48,8 @@ async function runTargetedIndex(runtime, paths, rootContext, options) {
41
48
  for (let offset = 0; offset < paths.length; offset += MAX_TARGETED_INDEX_PATHS) {
42
49
  const chunk = paths.slice(offset, offset + MAX_TARGETED_INDEX_PATHS);
43
50
  const response = runtime.nativeCall
44
- ? await runtime.nativeCall("index_repo", { paths: chunk }, rootContext, options)
45
- : await runtime.run(["index", ".", "--json", ...chunk.flatMap((path) => ["--path", path])], rootContext, options);
51
+ ? await runtime.nativeCall("index_repo", { paths: chunk, use_embed: false }, rootContext, options)
52
+ : await runtime.run(["index", ".", "--json", "--no-embed", ...chunk.flatMap((path) => ["--path", path])], rootContext, options);
46
53
  const { failed } = indexCompletion(response, false);
47
54
  if (failed > 0) {
48
55
  throw new RuntimeError("INDEX_UPDATE_INCOMPLETE", `ast-sgrep failed to update ${failed} changed path${failed === 1 ? "" : "s"}`, { failed, pathCount: chunk.length });
@@ -133,6 +140,22 @@ function markStateFullScan(state) {
133
140
  function cancelledRefreshWait() {
134
141
  return new RuntimeError("CANCELLED", "ast-sgrep freshness wait was cancelled");
135
142
  }
143
+ /**
144
+ * A cancellation that belongs to another caller's dead refresh, not to this
145
+ * caller. The last waiter's cancel aborts shared work (resource hygiene); a
146
+ * caller holding a live signal must never inherit that teardown as its own
147
+ * failure — it settles the dead refresh and owns a fresh one instead.
148
+ */
149
+ function isForeignRefreshCancel(cause, signal) {
150
+ if (signal?.aborted === true)
151
+ return false;
152
+ if (cause instanceof RuntimeError)
153
+ return cause.code === "CANCELLED";
154
+ if (cause instanceof Error && cause.name === "AbortError")
155
+ return true;
156
+ const message = cause instanceof Error ? cause.message : String(cause);
157
+ return /aborted|was cancelled/i.test(message);
158
+ }
136
159
  /** Stop one caller waiting without transferring cancellation ownership to shared work. */
137
160
  function waitForRefresh(refresh, signal, waitMs) {
138
161
  if (!signal && (!waitMs || waitMs <= 0))
@@ -293,7 +316,17 @@ export class FreshnessCoordinator {
293
316
  this.#pending.delete(pendingRoot);
294
317
  }
295
318
  if (state.inFlight) {
296
- await attachRefreshWaiter(state, state.inFlight, options.signal, this.#waitBudget(options));
319
+ const shared = state.inFlight;
320
+ try {
321
+ await attachRefreshWaiter(state, shared, options.signal, this.#waitBudget(options));
322
+ }
323
+ catch (cause) {
324
+ if (!isForeignRefreshCancel(cause, options.signal))
325
+ throw cause;
326
+ // Another caller's cancel tore down the shared refresh. This caller is
327
+ // still alive: settle the dead promise, then decide for itself below.
328
+ await shared.catch(() => undefined);
329
+ }
297
330
  return this.ensureFresh(runtime, rootContext, options);
298
331
  }
299
332
  if (options.signal?.aborted)
@@ -2,7 +2,7 @@ import { type MachineEnvelope } from "./types.js";
2
2
  export type IndexHealth = "ready" | "missing" | "incompatible";
3
3
  export declare function pathContained(parent: string, child: string): boolean;
4
4
  export declare function record(value: unknown): Record<string, unknown> | undefined;
5
- export declare function indexHealth(status: MachineEnvelope, knownExisting?: boolean): IndexHealth;
5
+ export declare function indexHealth(status: MachineEnvelope): IndexHealth;
6
6
  export declare function incompatibleStatusFailure(cause: unknown): boolean;
7
7
  export declare function indexCompletion(response: MachineEnvelope, requireWalkErrors: boolean): {
8
8
  failed: number;
@@ -14,7 +14,7 @@ export function pathContained(parent, child) {
14
14
  export function record(value) {
15
15
  return value !== null && typeof value === "object" && !Array.isArray(value) ? value : undefined;
16
16
  }
17
- export function indexHealth(status, knownExisting = false) {
17
+ export function indexHealth(status) {
18
18
  const index = record(status.index);
19
19
  const state = typeof index?.status === "string" ? index.status :
20
20
  typeof status.index_status === "string" ? status.index_status : undefined;
@@ -25,7 +25,9 @@ export function indexHealth(status, knownExisting = false) {
25
25
  if (state === "ready" || state === "current" || index?.exists === true || status.indexed === true)
26
26
  return "ready";
27
27
  if (typeof status.index_path === "string" && typeof status.file_count === "number") {
28
- return knownExisting || status.file_count > 0 ? "ready" : "missing";
28
+ // A present-but-empty index answers nothing: report it as unindexed so the
29
+ // caller indexes instead of reading zero rows as a legitimate no-match.
30
+ return status.file_count > 0 ? "ready" : "missing";
29
31
  }
30
32
  throw new RuntimeError("INDEX_STATUS_UNKNOWN", "ast-sgrep status did not report index freshness", { index: status.index, index_status: status.index_status });
31
33
  }
@@ -2,7 +2,7 @@
2
2
  * Shared leaf module: version constants, wire types, RuntimeError.
3
3
  * Imports nothing from sibling modules — any file may depend on it.
4
4
  */
5
- export declare const RUNTIME_VERSION = "2.0.0";
5
+ export declare const RUNTIME_VERSION = "2.1.0";
6
6
  export declare const MACHINE_SCHEMA_VERSION = "1.0.0";
7
7
  export declare const CONFIG_SCHEMA_VERSION: 1;
8
8
  /** Index format this release ships. Must equal INDEX_SCHEMA_VERSION in crates/ast-sgrep-core (check-contract gates it). */
@@ -2,7 +2,7 @@
2
2
  * Shared leaf module: version constants, wire types, RuntimeError.
3
3
  * Imports nothing from sibling modules — any file may depend on it.
4
4
  */
5
- export const RUNTIME_VERSION = "2.0.0";
5
+ export const RUNTIME_VERSION = "2.1.0";
6
6
  export const MACHINE_SCHEMA_VERSION = "1.0.0";
7
7
  export const CONFIG_SCHEMA_VERSION = 1;
8
8
  /** Index format this release ships. Must equal INDEX_SCHEMA_VERSION in crates/ast-sgrep-core (check-contract gates it). */
package/dist/ui/card.d.ts CHANGED
@@ -26,6 +26,8 @@ export type CardModel = {
26
26
  ms: number;
27
27
  }>;
28
28
  resultLines?: string[];
29
+ /** Warnings that qualify the answer (stale index, unindexed repo). */
30
+ notes?: string[];
29
31
  error?: string;
30
32
  running?: boolean;
31
33
  expanded?: boolean;
@@ -55,9 +57,10 @@ type RenderOptions = {
55
57
  };
56
58
  type RenderContext = {
57
59
  lastComponent?: unknown;
60
+ args?: object;
58
61
  };
59
62
  /** Build the card model from the tool result's details payload. */
60
- export declare function cardModel(result: ResultLike, options: RenderOptions): CardModel;
63
+ export declare function cardModel(result: ResultLike, options: RenderOptions, callArgs?: Record<string, unknown>): CardModel;
61
64
  /** renderResult entrypoint: bind one card per result slot, feed it details. */
62
65
  export declare function renderAsgrepResult(result: ResultLike, options: RenderOptions, theme: PresentTheme, context?: RenderContext): AsgrepCard;
63
66
  export {};
package/dist/ui/card.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /** Pi TUI result card — supernova-style: the call slot is empty; one card
2
2
  * owns the whole lifecycle (running → ops ledger → result → error). Rows are
3
3
  * fixed-column and theme-painted; nothing here writes to the model channel. */
4
- import { hitLabel, hitLocation, paint, truncateToWidth, visibleWidth, } from "./present.js";
4
+ import { displayWidth, hitLabel, hitLocation, paint, sanitizeContent, summarizeValue, truncateToWidth, visibleWidth, } from "./present.js";
5
5
  /** Header text that rides the top border: "asgrep search · 4 hits · 12ms · napi". */
6
6
  function frameLabel(model) {
7
7
  const bits = ["asgrep", model.command, ...model.title.filter((b) => Boolean(b))];
@@ -22,7 +22,6 @@ export const EMPTY_CALL = {
22
22
  };
23
23
  const TOOL_COL = 12;
24
24
  const DUR_COL = 7;
25
- const MAX_CARD_WIDTH = 100;
26
25
  /** Rounded frame glyphs — pi themes may provide theme.boxRound; default ASCII-art set. */
27
26
  const BOX = { tl: "\u256d", tr: "\u256e", bl: "\u2570", br: "\u256f", h: "\u2500", v: "\u2502" };
28
27
  function boxOf(theme) {
@@ -39,45 +38,54 @@ function borderKey(model) {
39
38
  return "accent";
40
39
  return "dim";
41
40
  }
41
+ /** Tool box background per state — the same keys pi themes tint native tool rows with. */
42
+ function backgroundKey(model) {
43
+ if (model.error)
44
+ return "toolErrorBg";
45
+ if (model.running)
46
+ return "toolPendingBg";
47
+ return "toolSuccessBg";
48
+ }
49
+ /**
50
+ * Paint the tool box background across the full row, when the theme has one.
51
+ * Without this the host's message background shows through the card, which
52
+ * reads as a dark band beside the border.
53
+ */
54
+ function backgroundPaint(theme, model) {
55
+ const bg = theme?.bg;
56
+ if (typeof bg !== "function")
57
+ return undefined;
58
+ try {
59
+ const key = backgroundKey(model);
60
+ if (typeof bg.call(theme, key, "x") !== "string")
61
+ return undefined;
62
+ return (text) => {
63
+ const painted = bg.call(theme, key, text);
64
+ return typeof painted === "string" ? painted : text;
65
+ };
66
+ }
67
+ catch {
68
+ return undefined;
69
+ }
70
+ }
42
71
  /** Top/bottom bar with an optional label embedded in the rule. Geometry is in
43
- * display cells (frameW): \u256d + 3 rules on the left, corner on the right. */
72
+ * display cells (displayWidth): \u256d + 3 rules on the left, corner on the right. */
44
73
  function frameBar(theme, box, border, left, right, label, width) {
45
74
  const leftRaw = left + box.h.repeat(3);
46
- const shown = label ? clamp(" " + label + " ", Math.max(0, width - frameW(leftRaw) - 1)) : "";
47
- const fill = Math.max(0, width - frameW(leftRaw) - frameW(shown) - 1);
75
+ const shown = label ? clamp(" " + label + " ", Math.max(0, width - displayWidth(leftRaw) - 1)) : "";
76
+ const fill = Math.max(0, width - displayWidth(leftRaw) - displayWidth(shown) - 1);
48
77
  return border(leftRaw) + shown + border(box.h.repeat(fill)) + border(right);
49
78
  }
50
- /** ANSI-aware cut to `width` display columns on the same scale as frameW
51
- * (stripped code-point count). Unlike truncateToWidth this never leaves an
52
- * unclosed SGR color behind: a cut inside a painted span appends \x1b[0m
53
- * before the ellipsis so the row cannot bleed color into the right border. */
79
+ /**
80
+ * Cut a row to `width` display columns (pi's cell scale: tab = 3, wide = 2).
81
+ *
82
+ * Delegates to the shared cell-aware truncator so every surface measures with
83
+ * one rule. Counting code units here instead (the previous shape) under-counted
84
+ * tabs threefold and made tab-indented code rows overflow the card — the crash
85
+ * pi reports as "Rendered line N exceeds terminal width".
86
+ */
54
87
  function clamp(text, width) {
55
- const limit = Math.max(1, width);
56
- if (frameW(text) <= limit)
57
- return text;
58
- const budget = Math.max(1, limit - 1); // room for the ellipsis
59
- const ansi = /\u001b\[[0-9;]*m/gu;
60
- const stops = [];
61
- for (let match = ansi.exec(text); match !== null; match = ansi.exec(text)) {
62
- stops.push([match.index, match.index + match[0].length]);
63
- }
64
- let kept = "";
65
- let visible = 0;
66
- let index = 0;
67
- let stopIndex = 0;
68
- while (index < text.length && visible < budget) {
69
- if (stopIndex < stops.length && index === stops[stopIndex][0]) {
70
- const [, end] = stops[stopIndex];
71
- kept += text.slice(index, end);
72
- index = end;
73
- stopIndex += 1;
74
- continue;
75
- }
76
- kept += text[index];
77
- visible += 1;
78
- index += 1;
79
- }
80
- return kept + (stops.length > 0 ? "\u001b[0m" : "") + "\u2026";
88
+ return truncateToWidth(text, Math.max(1, width), "\u2026");
81
89
  }
82
90
  function fitPath(text, budget) {
83
91
  if (visibleWidth(text) <= budget)
@@ -86,12 +94,6 @@ function fitPath(text, budget) {
86
94
  return "\u2026";
87
95
  return "\u2026" + text.slice(Math.max(0, text.length - budget + 1));
88
96
  }
89
- /** Display columns for frame geometry: ANSI-stripped code-point count.
90
- * Box glyphs/·/✓ render width-1 in real terminals; the conservative
91
- * visibleWidth() over-counts them (width 2) which would ragged the box. */
92
- function frameW(text) {
93
- return text.replace(/\u001b\[[0-9;]*m/g, "").length;
94
- }
95
97
  function fmtMs(ms) {
96
98
  if (!Number.isFinite(ms) || ms < 0)
97
99
  return "";
@@ -134,9 +136,11 @@ export class AsgrepCard {
134
136
  return [];
135
137
  if (this.cache?.width === width)
136
138
  return this.cache.lines;
137
- // Pi hands us the full terminal width a hollow frame at 200+ cols is a
138
- // wall of empty border. Cap at a readable card width; pi pads the rest.
139
- const lines = framedLines(theme, model, Math.min(Math.max(8, width), MAX_CARD_WIDTH));
139
+ // Pi hands us the full terminal width and renders this card with
140
+ // renderShell "self", so the frame must span every column the host gave
141
+ // us. Capping it left the host's message background showing as a dark
142
+ // band to the right of the border.
143
+ const lines = framedLines(theme, model, Math.max(8, width));
140
144
  this.cache = { width, lines };
141
145
  return lines;
142
146
  }
@@ -149,13 +153,15 @@ function framedLines(theme, model, width) {
149
153
  const inner = Math.max(1, width - 4); // "\u2502 " + content + " \u2502"
150
154
  const label = frameLabel(model);
151
155
  const rows = bodyLines(theme, model, inner);
152
- const out = [frameBar(theme, box, border, box.tl, box.tr, label, width)];
156
+ const background = backgroundPaint(theme, model);
157
+ const fill = (line) => (background ? background(line) : line);
158
+ const out = [fill(frameBar(theme, box, border, box.tl, box.tr, label, width))];
153
159
  for (const row of rows) {
154
160
  const body = clamp(row, inner);
155
- const pad = Math.max(0, inner - frameW(body));
156
- out.push(border(box.v) + " " + body + " ".repeat(pad) + " " + border(box.v));
161
+ const pad = Math.max(0, inner - displayWidth(body));
162
+ out.push(fill(border(box.v) + " " + body + " ".repeat(pad) + " " + border(box.v)));
157
163
  }
158
- out.push(frameBar(theme, box, border, box.bl, box.br, null, width));
164
+ out.push(fill(frameBar(theme, box, border, box.bl, box.br, null, width)));
159
165
  return out;
160
166
  }
161
167
  function bodyLines(theme, model, width) {
@@ -174,13 +180,13 @@ function bodyLines(theme, model, width) {
174
180
  lines.push(paint(theme, "dim", " \u2026 " + ((model.hits?.length ?? 0) - hits.length) + " more"));
175
181
  }
176
182
  for (const edit of (model.edits ?? []).slice(0, model.expanded ? 12 : 4)) {
177
- const head = " " + paint(theme, "accent", edit.path ?? "?") + (edit.line ? paint(theme, "dim", ":" + edit.line) : "");
183
+ const head = " " + paint(theme, "accent", sanitizeContent(edit.path ?? "?")) + (edit.line ? paint(theme, "dim", ":" + edit.line) : "");
178
184
  lines.push(clamp(head, width));
179
185
  for (const line of (edit.removed ?? []).slice(0, model.expanded ? 24 : 8)) {
180
- lines.push(" " + paint(theme, "error", "- " + clamp(line, width - 4)));
186
+ lines.push(" " + paint(theme, "error", "- " + clamp(line, width - 5)));
181
187
  }
182
188
  for (const line of (edit.added ?? []).slice(0, model.expanded ? 24 : 8)) {
183
- lines.push(" " + paint(theme, "success", "+ " + clamp(line, width - 4)));
189
+ lines.push(" " + paint(theme, "success", "+ " + clamp(line, width - 5)));
184
190
  }
185
191
  if (edit.truncated)
186
192
  lines.push(paint(theme, "dim", " \u2026"));
@@ -198,11 +204,49 @@ function bodyLines(theme, model, width) {
198
204
  lines.push(paint(theme, "dim", " \u2026 " + (model.resultLines.length - shown.length) + " more result lines"));
199
205
  }
200
206
  }
207
+ for (const note of model.notes ?? []) {
208
+ lines.push(" " + paint(theme, "warning", "! " + clamp(note, width - 3)));
209
+ }
201
210
  return lines;
202
211
  }
212
+ /** Target bits taken from the call arguments, so a running card can name what
213
+ * the call is about before any result exists. Never duplicated on completion:
214
+ * once details land they own the label. */
215
+ function callTargetBits(args) {
216
+ if (!args)
217
+ return [];
218
+ const bits = [];
219
+ if (typeof args.query === "string" && args.query)
220
+ bits.push(JSON.stringify(sanitizeContent(args.query)));
221
+ else if (typeof args.symbol === "string" && args.symbol)
222
+ bits.push(sanitizeContent(args.symbol));
223
+ else if (typeof args.code === "string" && args.code)
224
+ bits.push(sanitizeContent(args.code.trim().replace(/\s+/gu, " ")).slice(0, 60));
225
+ if (typeof args.path === "string" && args.path)
226
+ bits.push(sanitizeContent(args.path));
227
+ else if (typeof args.ref === "string" && args.ref)
228
+ bits.push(sanitizeContent(args.ref));
229
+ return bits;
230
+ }
203
231
  function editsOf(value) {
204
232
  if (value && typeof value === "object" && Array.isArray(value.edits)) {
205
- return value.edits.filter((e) => !!e && typeof e === "object" && (Array.isArray(e.removed) || Array.isArray(e.added)));
233
+ return value.edits
234
+ .filter((e) => !!e && typeof e === "object")
235
+ .filter((e) => Array.isArray(e.removed) || Array.isArray(e.added))
236
+ .map((e) => {
237
+ const entry = {
238
+ truncated: e.truncated === true,
239
+ };
240
+ if (typeof e.path === "string")
241
+ entry.path = sanitizeContent(e.path);
242
+ if (typeof e.line === "number")
243
+ entry.line = e.line;
244
+ if (Array.isArray(e.removed))
245
+ entry.removed = e.removed.map((line) => sanitizeContent(String(line)));
246
+ if (Array.isArray(e.added))
247
+ entry.added = e.added.map((line) => sanitizeContent(String(line)));
248
+ return entry;
249
+ });
206
250
  }
207
251
  return undefined;
208
252
  }
@@ -216,37 +260,44 @@ function resultPreviewLines(value) {
216
260
  if (value === undefined || value === null)
217
261
  return undefined;
218
262
  if (typeof value === "string")
219
- return value.split("\n").filter((l) => l.length > 0).slice(0, 16);
263
+ return value.split("\n").filter((line) => line.length > 0).slice(0, 16).map(sanitizeContent);
220
264
  if (Array.isArray(value))
221
265
  return [value.length + " value" + (value.length === 1 ? "" : "s")];
222
266
  if (typeof value === "object") {
223
- return Object.entries(value).slice(0, 12).map(([k, v]) => {
224
- const text = typeof v === "string" ? v : JSON.stringify(v);
225
- return k + ": " + (text.length > 80 ? text.slice(0, 79) + "\u2026" : text);
226
- });
267
+ // Shaped summary only: transport fields (tool/command/schema_version/ok)
268
+ // describe the wire envelope, not the answer, and a raw JSON dump of them
269
+ // is noise in the transcript.
270
+ const rows = summarizeValue(value);
271
+ return rows.length > 0 ? rows : undefined;
227
272
  }
228
- return [String(value)];
273
+ return [sanitizeContent(String(value))];
229
274
  }
230
275
  /** Build the card model from the tool result's details payload. */
231
- export function cardModel(result, options) {
276
+ export function cardModel(result, options, callArgs) {
232
277
  const details = (result.details && typeof result.details === "object" ? result.details : {});
233
278
  const command = typeof details.command === "string" ? details.command : "asgrep";
234
279
  const expanded = options.expanded === true;
235
280
  // In-flight partial updates carry only {command, phase}.
236
281
  if (options.isPartial && !("ok" in details)) {
237
- return { command, title: [typeof details.phase === "string" ? details.phase : undefined], running: true, expanded };
282
+ const phase = typeof details.phase === "string" && details.phase !== "started" ? details.phase : undefined;
283
+ return {
284
+ command,
285
+ title: [...callTargetBits(callArgs), phase],
286
+ running: true,
287
+ expanded,
288
+ };
238
289
  }
239
290
  const title = [];
240
291
  if (typeof details.query === "string" && details.query)
241
- title.push(JSON.stringify(details.query));
292
+ title.push(JSON.stringify(sanitizeContent(details.query)));
242
293
  if (typeof details.mode === "string")
243
- title.push(details.mode);
294
+ title.push(sanitizeContent(details.mode));
244
295
  const error = details.error;
245
296
  if (result.isError || details.ok === false || error) {
246
297
  return {
247
298
  command,
248
299
  title,
249
- error: typeof error?.message === "string" ? error.message : "tool failed",
300
+ error: typeof error?.message === "string" ? sanitizeContent(error.message) : "tool failed",
250
301
  expanded,
251
302
  };
252
303
  }
@@ -289,8 +340,8 @@ export function cardModel(result, options) {
289
340
  ? response.windows
290
341
  : undefined;
291
342
  const readLines = windows?.flatMap((w) => [
292
- (w.path ?? "?") + ":" + (w.start ?? 1) + "-" + (w.end ?? ""),
293
- ...(typeof w.text === "string" ? w.text.split("\n").slice(0, expanded ? 20 : 6).map((l) => " " + l) : []),
343
+ sanitizeContent((w.path ?? "?") + ":" + (w.start ?? 1) + "-" + (w.end ?? "")),
344
+ ...(typeof w.text === "string" ? sanitizeContent(w.text).split("\n").slice(0, expanded ? 20 : 6).map((l) => " " + l) : []),
294
345
  ]);
295
346
  // When edits carry diffs they are the interesting part of the result.
296
347
  const resultLines = hits || resultEdits ? undefined : (readLines ?? resultPreviewLines(details.result));
@@ -303,13 +354,21 @@ export function cardModel(result, options) {
303
354
  model.edits = resultEdits;
304
355
  if (resultLines)
305
356
  model.resultLines = resultLines;
357
+ // Warnings travel with the model-visible text; keep them visible in the TUI
358
+ // too, or the transcript looks clean while the answer is qualified.
359
+ if (Array.isArray(details.notes)) {
360
+ const notes = details.notes.filter((note) => typeof note === "string" && note.length > 0);
361
+ if (notes.length > 0)
362
+ model.notes = notes.map((note) => sanitizeContent(note));
363
+ }
306
364
  return model;
307
365
  }
308
366
  /** renderResult entrypoint: bind one card per result slot, feed it details. */
309
367
  export function renderAsgrepResult(result, options, theme, context) {
310
368
  const prev = context?.lastComponent;
311
369
  const card = prev instanceof AsgrepCard ? prev : new AsgrepCard();
312
- card.set(theme, cardModel(result, options));
370
+ const args = context?.args;
371
+ card.set(theme, cardModel(result, options, args && !Array.isArray(args) ? args : undefined));
313
372
  if (context)
314
373
  context.lastComponent = card;
315
374
  return card;
@@ -2,6 +2,8 @@
2
2
  export type PresentTheme = {
3
3
  bold(text: string): string;
4
4
  fg(role: string, text: string): string;
5
+ /** Optional: pi themes expose tool box backgrounds (toolSuccessBg/toolErrorBg/toolPendingBg). */
6
+ bg?(role: string, text: string): string;
5
7
  };
6
8
  export type HitLike = {
7
9
  file?: unknown;
@@ -25,47 +27,55 @@ export type EnvelopeLike = {
25
27
  backend?: unknown;
26
28
  [key: string]: unknown;
27
29
  };
28
- export declare const ASGREP_PROMPT_SNIPPET = "Search this repo by intent, symbol, callers, defs, or pattern (asgrep; use without being asked)";
29
- export declare const ASGREP_PROMPT_GUIDELINES: readonly ["For any code lookup (find a function, callers, defs, intent, or structural pattern), call asgrep immediately. Do not wait for the user to mention ast-sgrep.", "Prefer the asgrep Code Mode tool. Write JavaScript: asgrep.search(\"query\"), asgrep.defs(\"Symbol\"), asgrep.callers(\"Symbol\"), asgrep.read({ refs }). Independent lookups: Promise.all. Return a small shaped value.", "Use grep only for exact log strings, filenames, or config keys. asgrep.edit does unique string replace plus targeted reindex; oldText must match exactly once.", "If a search returns 0 hits, use suggested_next or retry with asgrep.find, asgrep.defs, or asgrep.search(query, { in: \"src\" })."];
30
+ export declare const ASGREP_PROMPT_SNIPPET = "Code search by intent, symbol, defs, callers, pattern (asgrep; use without being asked)";
31
+ export declare const ASGREP_PROMPT_GUIDELINES: readonly ["Any code lookup (function, def, caller, intent, pattern): call asgrep first.", "Compose in Code Mode (search/defs/read with Promise.all, return a small shaped value); grep only for exact strings or filenames. Bound with in: \"path\"; on 0 hits use suggested_next."];
30
32
  export declare function paint(theme: PresentTheme | undefined, role: string, text: string, bold?: boolean): string;
31
33
  export declare function hitLocation(hit: HitLike): string;
32
34
  export declare function hitLabel(hit: HitLike): string;
33
- export declare function header(theme: PresentTheme | undefined, verb: string, bits: Array<string | null | undefined>): string;
34
- export declare function formatSearchCall(params: {
35
- query?: string;
36
- mode?: string;
37
- limit?: number;
38
- excerptLines?: number;
39
- }, theme?: PresentTheme): string;
40
- export declare function formatIndexCall(force: boolean, theme?: PresentTheme): string;
41
- export declare function formatStatusCall(theme?: PresentTheme): string;
42
- export declare function formatEditCall(params: {
43
- path?: string;
44
- edits?: unknown[];
45
- }, theme?: PresentTheme): string;
46
- export declare function formatReadCall(params: {
47
- path?: string;
48
- ref?: string;
49
- start?: number;
50
- end?: number;
51
- }, theme?: PresentTheme): string;
52
- /** Model-visible text for an edit envelope: what changed, per file. */
53
35
  export declare function formatEditResult(response: EnvelopeLike, theme?: PresentTheme): string;
54
36
  /** Model-visible text for a read envelope: the window contents themselves. */
55
37
  export declare function formatReadResult(response: EnvelopeLike, theme?: PresentTheme): string;
56
- export declare function formatCodemodeCall(code: string, theme?: PresentTheme): string;
38
+ /**
39
+ * Model-facing result text is deliberately lean: the tool call already carries
40
+ * the query/mode, the TUI card renders timing and backend for the human, and
41
+ * every token here is re-sent with the whole transcript. The first line is the
42
+ * only chrome: "<command>: <payload summary>".
43
+ */
57
44
  export declare function formatSearchResult(response: EnvelopeLike, meta: {
58
45
  command: string;
59
- query?: string;
60
- mode?: string;
61
- activationMs?: number;
62
- backend?: string;
46
+ excerptLines?: number;
63
47
  }, theme?: PresentTheme): string;
64
48
  export declare function formatStatusResult(response: EnvelopeLike, theme?: PresentTheme): string;
65
49
  export declare function formatIndexResult(command: string, response: EnvelopeLike, theme?: PresentTheme): string;
50
+ /**
51
+ * Strip terminal control sequences and C0/C1 controls from untrusted content
52
+ * (tool output, code windows, paths) before this extension paints it. Without
53
+ * this a stray ESC in a file would sit inside our own SGR span, leaving an
54
+ * unterminated color and mis-measuring the row's width.
55
+ */
56
+ export declare function sanitizeContent(text: string): string;
57
+ /**
58
+ * Conservative display width on pi's scale: never under-counts what pi measures.
59
+ *
60
+ * Pi expands tabs to three spaces and measures grapheme clusters with East Asian
61
+ * Width (CJK/fullwidth/emoji = 2); lone combining marks, variation selectors and
62
+ * joiners are zero there. Re-deriving that table here would be a second source of
63
+ * truth that can drift, so this counts only what the card itself draws exactly
64
+ * (one cell) and rounds every other non-ASCII code point UP to two. A line that
65
+ * measures `width` here is therefore at most `width` in the terminal: over-counting
66
+ * can only leave slack before the right border, while under-counting is what pi
67
+ * kills the process for ("Rendered line N exceeds terminal width").
68
+ */
69
+ export declare function displayWidth(text: string): number;
66
70
  /** Local stand-in so we do not take a pi-tui dependency. Over-counts wide glyphs rather than under-count. */
67
71
  export declare function visibleWidth(text: string): number;
68
72
  export declare function truncateToWidth(text: string, maxWidth: number, ellipsis?: string): string;
73
+ /**
74
+ * One summary line per interesting entry of a shaped value: known shapes get a
75
+ * sentence ("3 windows · path:1-340"), everything else `key: value` with the
76
+ * value compacted. Transport fields are dropped rather than rendered.
77
+ */
78
+ export declare function summarizeValue(value: unknown, limit?: number): string[];
69
79
  export declare function formatCodemodeResult(value: unknown, meta?: {
70
80
  stats?: {
71
81
  calls: number;
@@ -77,12 +87,3 @@ export declare function formatCodemodeResult(value: unknown, meta?: {
77
87
  wallMs?: number;
78
88
  backend?: string;
79
89
  }, theme?: PresentTheme): string;
80
- /** Minimal pi-tui Text stand-in so we do not take a TUI package dependency. */
81
- export declare class AsgrepText {
82
- #private;
83
- constructor(text?: string);
84
- setText(text: string): void;
85
- invalidate(): void;
86
- render(width: number): string[];
87
- }
88
- export declare function presentText(formatted: string, last: unknown): AsgrepText;