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.
@@ -1,80 +1,59 @@
1
1
  /** Neat asgrep tool chrome for the Pi TUI and the model-visible content. */
2
- export const ASGREP_PROMPT_SNIPPET = "Search this repo by intent, symbol, callers, defs, or pattern (asgrep; use without being asked)";
2
+ export const ASGREP_PROMPT_SNIPPET = "Code search by intent, symbol, defs, callers, pattern (asgrep; use without being asked)";
3
3
  export const ASGREP_PROMPT_GUIDELINES = [
4
- "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.",
5
- "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.",
6
- "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.",
7
- "If a search returns 0 hits, use suggested_next or retry with asgrep.find, asgrep.defs, or asgrep.search(query, { in: \"src\" }).",
4
+ "Any code lookup (function, def, caller, intent, pattern): call asgrep first.",
5
+ "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.",
8
6
  ];
9
7
  export function paint(theme, role, text, bold = false) {
10
8
  const body = bold && theme ? theme.bold(text) : text;
11
9
  return theme ? theme.fg(role, body) : body;
12
10
  }
13
11
  export function hitLocation(hit) {
14
- const file = String(hit.file ?? hit.path ?? "");
12
+ const file = sanitizeContent(String(hit.file ?? hit.path ?? ""));
15
13
  const line = hit.start_line ?? hit.line ?? hit.lines;
16
14
  if (typeof line === "number")
17
15
  return `${file}:${line}`;
18
16
  if (typeof line === "string" && line.length > 0)
19
- return `${file}:${line}`;
17
+ return `${file}:${sanitizeContent(line)}`;
20
18
  if (typeof hit.ref === "string" && hit.ref.length > 0)
21
- return hit.ref;
19
+ return sanitizeContent(hit.ref);
22
20
  return file || "?";
23
21
  }
22
+ /** Hard caps for one result: rows shown, excerpt lines per hit, longest preview. */
23
+ const MAX_HIT_ROWS = 24;
24
+ const MAX_EXCERPT_LINES_OUT = 12;
25
+ const MAX_PREVIEW_CHARS = 96;
24
26
  export function hitLabel(hit) {
25
- const symbol = typeof hit.symbol === "string" ? hit.symbol : "";
26
- const kind = typeof hit.kind === "string" ? hit.kind : "";
27
- const preview = typeof hit.preview === "string" ? hit.preview.replace(/\s+/g, " ").trim() : "";
28
- return [symbol, kind, preview && preview.length < 80 ? preview : ""].filter(Boolean).join(" ");
29
- }
30
- export function header(theme, verb, bits) {
31
- return [paint(theme, "toolTitle", "asgrep", true), paint(theme, "accent", verb), ...bits.filter((bit) => Boolean(bit))].join(" · ");
32
- }
33
- export function formatSearchCall(params, theme) {
34
- return header(theme, "search", [
35
- params.query ? JSON.stringify(params.query) : undefined,
36
- params.mode ?? "natural",
37
- params.limit !== undefined ? `limit ${params.limit}` : undefined,
38
- params.excerptLines ? `excerpt ${params.excerptLines}` : undefined,
39
- ]);
40
- }
41
- export function formatIndexCall(force, theme) {
42
- return header(theme, force ? "reindex" : "index", []);
43
- }
44
- export function formatStatusCall(theme) {
45
- return header(theme, "status", []);
27
+ const symbol = typeof hit.symbol === "string" ? sanitizeContent(hit.symbol) : "";
28
+ const kind = typeof hit.kind === "string" ? sanitizeContent(hit.kind) : "";
29
+ const raw = typeof hit.preview === "string" ? sanitizeContent(hit.preview).replace(/\s+/g, " ").trim() : "";
30
+ // Truncate instead of dropping: a long preview used to vanish entirely, so a
31
+ // hit could carry no hint at all about what it contained.
32
+ const preview = raw.length > MAX_PREVIEW_CHARS ? raw.slice(0, MAX_PREVIEW_CHARS - 1) + "\u2026" : raw;
33
+ return [symbol, kind, preview].filter(Boolean).join(" ");
46
34
  }
47
- export function formatEditCall(params, theme) {
48
- const n = Array.isArray(params.edits) ? params.edits.length : 0;
49
- return header(theme, "edit", [params.path, n > 1 ? n + " edits" : undefined]);
50
- }
51
- export function formatReadCall(params, theme) {
52
- const target = params.path ?? params.ref;
53
- const range = params.start !== undefined ? "L" + params.start + "-L" + (params.end ?? "") : undefined;
54
- return header(theme, "read", [target, range]);
55
- }
56
- /** Model-visible text for an edit envelope: what changed, per file. */
57
35
  export function formatEditResult(response, theme) {
58
36
  const edits = Array.isArray(response.edits) ? response.edits : [];
59
37
  const changed = edits.filter((e) => e.changed === true).length;
60
- const title = header(theme, "edit", [changed + "/" + edits.length + " changed"]);
61
- const rows = edits.slice(0, 12).map((e) => {
62
- const path = typeof e.path === "string" ? e.path : "?";
63
- const line = typeof e.line === "number" ? ":" + e.line : "";
64
- return paint(theme, "toolOutput", " " + path + line);
65
- });
66
- return [title, ...rows].join("\n");
38
+ const rows = ["edit: " + changed + "/" + edits.length + " changed"];
39
+ for (const entry of edits.slice(0, 12)) {
40
+ const path = sanitizeContent(typeof entry.path === "string" ? entry.path : "?");
41
+ const line = typeof entry.line === "number" ? ":" + entry.line : "";
42
+ rows.push(" " + path + line);
43
+ }
44
+ return rows.join("\n");
67
45
  }
68
46
  /** Model-visible text for a read envelope: the window contents themselves. */
69
47
  export function formatReadResult(response, theme) {
70
48
  const windows = Array.isArray(response.windows) ? response.windows : [];
71
49
  if (windows.length === 0)
72
- return header(theme, "read", ["0 windows"]);
50
+ return "read: 0 windows";
73
51
  const out = [];
74
52
  for (const w of windows.slice(0, 8)) {
75
- const path = typeof w.path === "string" ? w.path : "?";
76
- out.push(header(theme, "read", [path + "#L" + (w.start ?? 1) + "-L" + (w.end ?? "")]));
77
- const text = typeof w.text === "string" ? w.text : "";
53
+ const path = sanitizeContent(typeof w.path === "string" ? w.path : "?");
54
+ // The window's own path+range is the line the model needs to cite back.
55
+ out.push(path + "#L" + (w.start ?? 1) + "-L" + (w.end ?? ""));
56
+ const text = sanitizeContent(typeof w.text === "string" ? w.text : "");
78
57
  for (const line of text.split("\n").slice(0, 80))
79
58
  out.push(line);
80
59
  }
@@ -82,111 +61,166 @@ export function formatReadResult(response, theme) {
82
61
  out.push("… " + (windows.length - 8) + " more windows");
83
62
  return out.join("\n");
84
63
  }
85
- export function formatCodemodeCall(code, theme) {
86
- const preview = code.trim().replace(/\s+/g, " ").slice(0, 80);
87
- return header(theme, "codemode", [`${preview}${code.trim().length > 80 ? "…" : ""}`]);
88
- }
64
+ /**
65
+ * Model-facing result text is deliberately lean: the tool call already carries
66
+ * the query/mode, the TUI card renders timing and backend for the human, and
67
+ * every token here is re-sent with the whole transcript. The first line is the
68
+ * only chrome: "<command>: <payload summary>".
69
+ */
89
70
  export function formatSearchResult(response, meta, theme) {
90
71
  const hits = Array.isArray(response.hits) ? response.hits : [];
91
- const title = header(theme, meta.command, [
92
- meta.query ? JSON.stringify(meta.query) : undefined,
93
- meta.mode,
94
- `${hits.length} hit${hits.length === 1 ? "" : "s"}`,
95
- meta.activationMs !== undefined ? `${meta.activationMs < 10 ? meta.activationMs.toFixed(2) : meta.activationMs.toFixed(1)}ms` : undefined,
96
- meta.backend,
97
- ]);
98
- const rows = hits.slice(0, 24).map((hit) => {
72
+ // Capsules carry an excerpt whether or not the caller asked; render it only
73
+ // when they did, or every defs/imports answer pays for body text nobody
74
+ // requested (measured: 2.5k tokens of unasked-for excerpts before the guard).
75
+ const excerptBudget = Math.max(0, Math.min(meta.excerptLines ?? 0, MAX_EXCERPT_LINES_OUT));
76
+ let excerptLinesLeft = excerptBudget * Math.min(hits.length, MAX_HIT_ROWS);
77
+ const rows = [`${meta.command}: ${hits.length} hit${hits.length === 1 ? "" : "s"}`];
78
+ for (const hit of hits.slice(0, MAX_HIT_ROWS)) {
99
79
  const loc = hitLocation(hit);
100
80
  const label = hitLabel(hit);
101
- return paint(theme, "toolOutput", label ? ` ${loc} ${label}` : ` ${loc}`);
102
- });
81
+ // Single-space fields: same payload, fewer tokens per row, and every row
82
+ // is re-sent with the transcript on each turn.
83
+ rows.push(label ? ` ${loc} ${label}` : ` ${loc}`);
84
+ // Body excerpts only exist when the caller asked for them (excerptLines);
85
+ // they used to be dropped here, so asking cost nothing and returned less.
86
+ const excerpt = hit.excerpt;
87
+ if (excerptLinesLeft > 0 && typeof excerpt === "string" && excerpt.trim() !== "") {
88
+ for (const line of sanitizeContent(excerpt).split("\n")) {
89
+ if (excerptLinesLeft === 0)
90
+ break;
91
+ if (line.trim() === "")
92
+ continue;
93
+ rows.push(" " + line.replace(/\s+$/u, ""));
94
+ excerptLinesLeft -= 1;
95
+ }
96
+ }
97
+ }
103
98
  if (hits.length === 0) {
104
- rows.push(paint(theme, "muted", " 0 hits"));
105
- const next = Array.isArray(response.suggested_next)
106
- ? response.suggested_next.filter((item) => typeof item === "string")
99
+ // A chain answer is nodes+edges, not hits: depth, site, symbol per row.
100
+ const nodes = Array.isArray(response.nodes)
101
+ ? (response.nodes)
107
102
  : [];
108
- if (next.length > 0) {
109
- for (const query of next.slice(0, 4)) {
110
- rows.push(paint(theme, "toolOutput", ` try ${query}`));
103
+ if (nodes.length > 0) {
104
+ rows[0] = `${meta.command}: ${nodes.length} nodes`;
105
+ for (const node of nodes.slice(0, 24)) {
106
+ const file = sanitizeContent(typeof node.file === "string" ? node.file : "?");
107
+ const line = typeof node.line_start === "number" ? ":" + node.line_start : "";
108
+ const depth = typeof node.depth === "number" ? "d" + node.depth + " " : "";
109
+ const symbol = typeof node.symbol === "string" ? " " + sanitizeContent(node.symbol) : "";
110
+ rows.push(` ${depth}${file}${line}${symbol}`);
111
111
  }
112
+ return rows.join("\n");
112
113
  }
113
- else {
114
- rows.push(paint(theme, "toolOutput", " try asgrep.find(query) or asgrep.defs(symbol) or asgrep.search(query, { in: \"src\" })"));
115
- }
116
- }
117
- if (hits.length > 24) {
118
- rows.push(paint(theme, "muted", ` … ${hits.length - 24} more`));
114
+ const next = Array.isArray(response.suggested_next)
115
+ ? response.suggested_next.filter((item) => typeof item === "string")
116
+ : [];
117
+ for (const query of next.slice(0, 3))
118
+ rows.push(` try: ${sanitizeContent(query)}`);
119
119
  }
120
- return [title, ...rows].join("\n");
120
+ if (hits.length > 24)
121
+ rows.push(` … ${hits.length - 24} more`);
122
+ return rows.join("\n");
121
123
  }
122
124
  export function formatStatusResult(response, theme) {
123
125
  const state = typeof response.status === "string" ? response.status
124
126
  : typeof response.index_status === "string" ? response.index_status
125
127
  : response.ok ? "ok" : "failed";
126
128
  const counts = response.counts && typeof response.counts === "object"
127
- ? Object.entries(response.counts).map(([key, value]) => `${key}=${String(value)}`).join(" ")
129
+ ? Object.entries(response.counts).map(([key, value]) => `${key}=${String(value)}`).join(" ")
128
130
  : "";
129
131
  const backend = typeof response.backend === "string" ? response.backend : "";
130
- const title = header(theme, "status", [state, counts, backend]);
131
- return title;
132
+ return ["status: " + state, counts, backend].filter(Boolean).join(" ");
132
133
  }
133
134
  export function formatIndexResult(command, response, theme) {
134
135
  const count = typeof response.count === "number" ? response.count
135
136
  : typeof response.total === "number" ? response.total
136
- : undefined;
137
- const tail = count === undefined ? "done" : `${count} file${count === 1 ? "" : "s"}`;
138
- return header(theme, command, [tail]);
137
+ : typeof response.files_indexed === "number" ? response.files_indexed
138
+ : undefined;
139
+ return count === undefined ? `${command}: done` : `${command}: ${count} file${count === 1 ? "" : "s"}`;
139
140
  }
141
+ /** Longest escape sequence we will skip as a unit; longer runs are treated as
142
+ * text so an unterminated sequence cannot swallow the rest of a line. */
143
+ const MAX_ESCAPE_LENGTH = 512;
144
+ /**
145
+ * Length of the terminal escape starting at `index`, or 0 when there is none.
146
+ *
147
+ * A lone or unterminated ESC measures zero and everything after it is text,
148
+ * which is what pi does (measured: "\u001b[31" is three cells, ESC included as
149
+ * zero). Skipping unterminated sequences whole would under-count, and pi kills
150
+ * the process for any rendered line wider than the terminal.
151
+ */
140
152
  function ansiLengthAt(text, index) {
141
153
  if (text.charCodeAt(index) !== 0x1b)
142
154
  return 0;
155
+ const limit = Math.min(text.length, index + MAX_ESCAPE_LENGTH);
143
156
  const next = text[index + 1];
144
157
  if (next === "[") {
145
- let cursor = index + 2;
146
- while (cursor < text.length) {
158
+ for (let cursor = index + 2; cursor < limit; cursor += 1) {
147
159
  const code = text.charCodeAt(cursor);
148
160
  if (code >= 0x40 && code <= 0x7e)
149
161
  return cursor - index + 1;
150
- cursor += 1;
151
162
  }
152
- return text.length - index;
163
+ return 1;
153
164
  }
154
165
  if (next === "]") {
155
- let cursor = index + 2;
156
- while (cursor < text.length) {
166
+ for (let cursor = index + 2; cursor < limit; cursor += 1) {
157
167
  if (text.charCodeAt(cursor) === 0x07)
158
168
  return cursor - index + 1;
159
169
  if (text.charCodeAt(cursor) === 0x1b && text[cursor + 1] === "\\")
160
170
  return cursor - index + 2;
161
- cursor += 1;
162
171
  }
163
- return text.length - index;
172
+ return 1;
164
173
  }
165
174
  if (next === "P" || next === "X" || next === "^" || next === "_") {
166
- let cursor = index + 2;
167
- while (cursor < text.length) {
175
+ for (let cursor = index + 2; cursor < limit; cursor += 1) {
168
176
  if (text.charCodeAt(cursor) === 0x1b && text[cursor + 1] === "\\")
169
177
  return cursor - index + 2;
170
- cursor += 1;
171
178
  }
172
- return text.length - index;
179
+ return 1;
173
180
  }
174
- return Math.min(2, text.length - index);
181
+ return 1;
175
182
  }
176
- function cellWidthAt(text, index) {
177
- const code = text.charCodeAt(index);
178
- if (code === 0x09)
179
- return { width: 3, length: 1 };
180
- if (code >= 0xd800 && code <= 0xdbff)
181
- return { width: 2, length: 2 };
182
- if (code <= 0x1f || (code >= 0x7f && code <= 0x9f))
183
- return { width: 0, length: 1 };
184
- if (code <= 0x7e)
185
- return { width: 1, length: 1 };
186
- return { width: 2, length: 1 };
183
+ /**
184
+ * Strip terminal control sequences and C0/C1 controls from untrusted content
185
+ * (tool output, code windows, paths) before this extension paints it. Without
186
+ * this a stray ESC in a file would sit inside our own SGR span, leaving an
187
+ * unterminated color and mis-measuring the row's width.
188
+ */
189
+ export function sanitizeContent(text) {
190
+ let out = "";
191
+ for (let index = 0; index < text.length;) {
192
+ const ansi = ansiLengthAt(text, index);
193
+ if (ansi > 0) {
194
+ index += ansi;
195
+ continue;
196
+ }
197
+ const code = text.charCodeAt(index);
198
+ if (code === 0x09 || code === 0x0a || (code >= 0x20 && code !== 0x7f && !(code >= 0x80 && code <= 0x9f))) {
199
+ out += text[index];
200
+ }
201
+ index += 1;
202
+ }
203
+ return out;
187
204
  }
188
- /** Local stand-in so we do not take a pi-tui dependency. Over-counts wide glyphs rather than under-count. */
189
- export function visibleWidth(text) {
205
+ /**
206
+ * Chrome glyphs this extension draws itself. Pi measures every one of them as a
207
+ * single cell (verified against `visibleWidth` from @earendil-works/pi-tui), so
208
+ * frame geometry can stay exact while everything else rounds UP.
209
+ */
210
+ const ONE_CELL_CHROME = /^[\u00b7\u00d7\u2022\u2026\u2192\u23f5\u23f8\u2500-\u257f\u25a0-\u25cf\u2591-\u2593\u26d3\u2713\u2714\u2717\u276f\u2588]$/u;
211
+ /**
212
+ * Conservative display width on pi's scale: never under-counts what pi measures.
213
+ *
214
+ * Pi expands tabs to three spaces and measures grapheme clusters with East Asian
215
+ * Width (CJK/fullwidth/emoji = 2); lone combining marks, variation selectors and
216
+ * joiners are zero there. Re-deriving that table here would be a second source of
217
+ * truth that can drift, so this counts only what the card itself draws exactly
218
+ * (one cell) and rounds every other non-ASCII code point UP to two. A line that
219
+ * measures `width` here is therefore at most `width` in the terminal: over-counting
220
+ * can only leave slack before the right border, while under-counting is what pi
221
+ * kills the process for ("Rendered line N exceeds terminal width").
222
+ */
223
+ export function displayWidth(text) {
190
224
  let width = 0;
191
225
  for (let index = 0; index < text.length;) {
192
226
  const ansi = ansiLengthAt(text, index);
@@ -200,6 +234,24 @@ export function visibleWidth(text) {
200
234
  }
201
235
  return width;
202
236
  }
237
+ function cellWidthAt(text, index) {
238
+ const code = text.charCodeAt(index);
239
+ if (code === 0x09)
240
+ return { width: 3, length: 1 };
241
+ if (code <= 0x1f || (code >= 0x7f && code <= 0x9f))
242
+ return { width: 0, length: 1 };
243
+ if (code <= 0x7e)
244
+ return { width: 1, length: 1 };
245
+ if (code >= 0xd800 && code <= 0xdbff)
246
+ return { width: 2, length: 2 };
247
+ if (ONE_CELL_CHROME.test(text[index] ?? ""))
248
+ return { width: 1, length: 1 };
249
+ return { width: 2, length: 1 };
250
+ }
251
+ /** Local stand-in so we do not take a pi-tui dependency. Over-counts wide glyphs rather than under-count. */
252
+ export function visibleWidth(text) {
253
+ return displayWidth(text);
254
+ }
203
255
  export function truncateToWidth(text, maxWidth, ellipsis = "...") {
204
256
  const limit = Math.max(0, maxWidth);
205
257
  if (limit <= 0)
@@ -231,11 +283,68 @@ export function truncateToWidth(text, maxWidth, ellipsis = "...") {
231
283
  // Close it explicitly; harmless when the kept spans were already balanced.
232
284
  return kept + (kept.includes("\u001b[") ? "\u001b[0m" : "") + ellipsis;
233
285
  }
286
+ /** Wire-envelope fields that describe the transport, not the answer. Showing
287
+ * them turns a one-line answer into a JSON dump of our own protocol. */
288
+ const TRANSPORT_KEYS = new Set([
289
+ "tool",
290
+ "command",
291
+ "schema_version",
292
+ "ok",
293
+ "ref",
294
+ "refs",
295
+ "index_path",
296
+ "expand_hint",
297
+ "snapshot",
298
+ "backend",
299
+ "wall_ms",
300
+ "exit_code",
301
+ "prevented_read_bytes",
302
+ "read_bytes_estimate",
303
+ "returned_excerpt_bytes",
304
+ ]);
305
+ /**
306
+ * One summary line per interesting entry of a shaped value: known shapes get a
307
+ * sentence ("3 windows · path:1-340"), everything else `key: value` with the
308
+ * value compacted. Transport fields are dropped rather than rendered.
309
+ */
310
+ export function summarizeValue(value, limit = 4) {
311
+ if (value === null || value === undefined || typeof value !== "object" || Array.isArray(value)) {
312
+ return [compactValue(value)];
313
+ }
314
+ const recordValue = value;
315
+ const rows = [];
316
+ const hits = Array.isArray(recordValue.hits) ? recordValue.hits : undefined;
317
+ const windows = Array.isArray(recordValue.windows)
318
+ ? recordValue.windows
319
+ : undefined;
320
+ if (hits)
321
+ rows.push(hits.length + " hit" + (hits.length === 1 ? "" : "s"));
322
+ if (windows && windows.length > 0) {
323
+ const first = windows[0] ?? {};
324
+ const where = typeof first.path === "string" ? sanitizeContent(first.path) : "";
325
+ const range = typeof first.start === "number"
326
+ ? ":" + first.start + (typeof first.end === "number" ? "-" + first.end : "")
327
+ : "";
328
+ rows.push(windows.length + " window" + (windows.length === 1 ? "" : "s") + (where ? " · " + where + range : ""));
329
+ }
330
+ for (const [key, entry] of Object.entries(recordValue)) {
331
+ if (rows.length >= limit)
332
+ break;
333
+ if (TRANSPORT_KEYS.has(key))
334
+ continue;
335
+ if (hits && key === "hits")
336
+ continue;
337
+ if (windows && (key === "windows" || key === "count"))
338
+ continue;
339
+ rows.push(key + ": " + compactValue(entry));
340
+ }
341
+ return rows;
342
+ }
234
343
  function compactValue(value) {
235
344
  if (value === null || value === undefined)
236
345
  return String(value);
237
346
  if (typeof value !== "object")
238
- return String(value);
347
+ return sanitizeContent(String(value));
239
348
  if (Array.isArray(value))
240
349
  return `${value.length} item${value.length === 1 ? "" : "s"}`;
241
350
  const json = JSON.stringify(value);
@@ -258,30 +367,21 @@ export function formatCodemodeResult(value, meta = {}, theme) {
258
367
  else if (typeof record.node_count === "number")
259
368
  bits.push(`${record.node_count} node${record.node_count === 1 ? "" : "s"}`);
260
369
  }
261
- if (meta.backend === "napi")
262
- bits.push("in-process");
263
- else if (meta.backend === "cli")
264
- bits.push("cli-sticky");
265
370
  if (meta.stats && meta.stats.calls > 0) {
266
- const via = (meta.stats.stickyCalls ?? 0) > 0
267
- ? `native ${meta.stats.stickyCalls}`
268
- : meta.stats.batchedCalls > 0
269
- ? `batched ${meta.stats.batchedCalls}`
270
- : meta.stats.parallelSpawnCalls > 0
271
- ? `parallel-spawn ${meta.stats.parallelSpawnCalls}`
272
- : `${meta.stats.calls} call${meta.stats.calls === 1 ? "" : "s"}`;
273
- bits.push(via);
371
+ bits.push(`${meta.stats.calls} call${meta.stats.calls === 1 ? "" : "s"}`);
274
372
  if (meta.stats.waves > 1)
275
373
  bits.push(`${meta.stats.waves} waves`);
276
374
  }
277
- if (meta.wallMs !== undefined)
278
- bits.push(`${meta.wallMs}ms`);
279
- const title = header(theme, "codemode", bits);
375
+ // Backend and wall time are display concerns: the card renders them, the
376
+ // transcript does not need them repeated on every call.
377
+ const title = "codemode" + (bits.length > 0 ? ": " + bits.join(" ") : "");
280
378
  if (value === undefined) {
281
379
  return `${title}\n${paint(theme, "muted", " (no return statement; add `return` to send a value to the model)")}`;
282
380
  }
283
381
  if (value && typeof value === "object" && !Array.isArray(value)) {
284
- const rows = Object.entries(value).slice(0, 16).map(([key, entry]) => paint(theme, "toolOutput", ` ${key}: ${compactValue(entry)}`));
382
+ // The program's own return shape is a deliberate choice: summarize it
383
+ // wider than a hit preview, or the model has to re-run to see its value.
384
+ const rows = summarizeValue(value, 12).map((row) => paint(theme, "toolOutput", ` ${row}`));
285
385
  return [title, ...rows].join("\n");
286
386
  }
287
387
  if (Array.isArray(value)) {
@@ -289,31 +389,3 @@ export function formatCodemodeResult(value, meta = {}, theme) {
289
389
  }
290
390
  return `${title}\n${paint(theme, "toolOutput", ` ${compactValue(value)}`)}`;
291
391
  }
292
- /** Minimal pi-tui Text stand-in so we do not take a TUI package dependency. */
293
- export class AsgrepText {
294
- #text;
295
- constructor(text = "") {
296
- this.#text = text;
297
- }
298
- setText(text) {
299
- this.#text = text;
300
- }
301
- invalidate() { }
302
- render(width) {
303
- const maxWidth = Math.max(1, width);
304
- if (this.#text.length === 0)
305
- return [""];
306
- return this.#text.split("\n").map((line) => truncateToWidth(line, maxWidth));
307
- }
308
- }
309
- export function presentText(formatted, last) {
310
- if (last instanceof AsgrepText) {
311
- last.setText(formatted);
312
- return last;
313
- }
314
- if (last && typeof last === "object" && last !== null && "setText" in last && typeof last.setText === "function") {
315
- last.setText(formatted);
316
- return last;
317
- }
318
- return new AsgrepText(formatted);
319
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ast-sgrep",
3
- "version": "2.1.1",
3
+ "version": "2.2.0",
4
4
  "description": "Native Code Mode, structural, graph, and semantic code search for Pi",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -53,16 +53,17 @@
53
53
  "scripts": {
54
54
  "build": "tsc -p tsconfig.json && node scripts/copy-guest-worker.mjs",
55
55
  "build:native": "cargo build -p ast-sgrep-codemode-napi --release && node ./scripts/copy-native.mjs",
56
- "test": "ASGREP_CODEMODE_BACKEND=cli node --import tsx --test ../../../tests/pi/extension/code-mode.test.ts ../../../tests/pi/extension/codemode.test.ts ../../../tests/pi/extension/commands.test.ts ../../../tests/pi/extension/present.test.ts ../../../tests/pi/extension/runtime.test.ts ../../../tests/pi/extension/security.test.ts ../../../tests/pi/extension/session-pool.test.ts ../../../tests/pi/extension/skill-workflow.test.ts ../../../tests/pi/extension/sqlite.test.ts ../../../tests/pi/extension/tools.test.ts",
56
+ "test": "ASGREP_CODEMODE_BACKEND=cli node --import tsx --test ../../../tests/pi/extension/code-mode.test.ts ../../../tests/pi/extension/codemode.test.ts ../../../tests/pi/extension/commands.test.ts ../../../tests/pi/extension/present.test.ts ../../../tests/pi/extension/runtime.test.ts ../../../tests/pi/extension/security.test.ts ../../../tests/pi/extension/session-pool.test.ts ../../../tests/pi/extension/skill-workflow.test.ts ../../../tests/pi/extension/sqlite.test.ts ../../../tests/pi/extension/../../../tests/pi/extension/token-budget.test.ts ../../../tests/pi/extension/tools.test.ts",
57
57
  "test:native": "node --import tsx --test ../../../tests/pi/extension/native-inprocess.test.ts",
58
58
  "test:all": "npm test && npm run test:native",
59
- "prepack": "npm run build"
59
+ "prepack": "npm run build",
60
+ "bench:p100": "node scripts/p100-bench.mjs"
60
61
  },
61
62
  "engines": {
62
63
  "node": ">=22.19.0"
63
64
  },
64
65
  "dependencies": {
65
- "ast-sgrep": ">=2.0.0 <3",
66
+ "ast-sgrep": ">=2.1.0 <3",
66
67
  "typebox": "^1.0.0"
67
68
  },
68
69
  "peerDependencies": {