pi-ast-sgrep 2.0.2 → 2.1.1

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 (49) hide show
  1. package/README.md +15 -19
  2. package/dist/code-mode.d.ts +1 -1
  3. package/dist/code-mode.js +1 -1
  4. package/dist/codemode/connector.d.ts +17 -3
  5. package/dist/codemode/connector.js +59 -31
  6. package/dist/codemode/dispatch.d.ts +13 -1
  7. package/dist/codemode/dispatch.js +83 -23
  8. package/dist/codemode/guest-api.d.ts +16 -0
  9. package/dist/codemode/guest-api.js +194 -0
  10. package/dist/codemode/guest-worker.mjs +287 -0
  11. package/dist/codemode/index.d.ts +4 -3
  12. package/dist/codemode/index.js +4 -3
  13. package/dist/codemode/runner.d.ts +13 -9
  14. package/dist/codemode/runner.js +411 -213
  15. package/dist/codemode/session-pool.d.ts +6 -1
  16. package/dist/codemode/session-pool.js +125 -32
  17. package/dist/codemode/types.d.ts +33 -2
  18. package/dist/codemode/types.js +39 -13
  19. package/dist/codemode/worker.d.ts +1 -1
  20. package/dist/codemode/worker.js +25 -2
  21. package/dist/host/commands.d.ts +6 -0
  22. package/dist/host/commands.js +49 -0
  23. package/dist/host/results.d.ts +115 -0
  24. package/dist/host/results.js +80 -0
  25. package/dist/host/tools.d.ts +9 -0
  26. package/dist/host/tools.js +610 -0
  27. package/dist/index.d.ts +7 -34
  28. package/dist/index.js +5 -543
  29. package/dist/runtime/config.d.ts +36 -0
  30. package/dist/runtime/config.js +98 -0
  31. package/dist/runtime/freshness.d.ts +43 -0
  32. package/dist/runtime/freshness.js +413 -0
  33. package/dist/runtime/index-health.d.ts +16 -0
  34. package/dist/runtime/index-health.js +109 -0
  35. package/dist/runtime/runtime.d.ts +48 -0
  36. package/dist/runtime/runtime.js +265 -0
  37. package/dist/runtime/sqlite.d.ts +15 -0
  38. package/dist/runtime/sqlite.js +63 -0
  39. package/dist/runtime/types.d.ts +55 -0
  40. package/dist/runtime/types.js +25 -0
  41. package/dist/ui/card.d.ts +63 -0
  42. package/dist/ui/card.js +316 -0
  43. package/dist/{present.d.ts → ui/present.d.ts} +20 -2
  44. package/dist/{present.js → ui/present.js} +68 -9
  45. package/package.json +6 -6
  46. package/dist/codemode/sandbox-worker.d.ts +0 -1
  47. package/dist/codemode/sandbox-worker.js +0 -204
  48. package/dist/runtime.d.ts +0 -137
  49. package/dist/runtime.js +0 -799
@@ -0,0 +1,316 @@
1
+ /** Pi TUI result card — supernova-style: the call slot is empty; one card
2
+ * owns the whole lifecycle (running → ops ledger → result → error). Rows are
3
+ * fixed-column and theme-painted; nothing here writes to the model channel. */
4
+ import { hitLabel, hitLocation, paint, truncateToWidth, visibleWidth, } from "./present.js";
5
+ /** Header text that rides the top border: "asgrep search · 4 hits · 12ms · napi". */
6
+ function frameLabel(model) {
7
+ const bits = ["asgrep", model.command, ...model.title.filter((b) => Boolean(b))];
8
+ const tail = model.error ? "failed" : model.running ? "running" : "";
9
+ if (tail)
10
+ bits.push(tail);
11
+ const counts = [];
12
+ if (model.ops?.length)
13
+ counts.push(model.ops.length + (model.ops.length === 1 ? " call" : " calls"));
14
+ if (model.hits)
15
+ counts.push(model.hits.length + (model.hits.length === 1 ? " hit" : " hits"));
16
+ return [...bits.slice(0, 2), ...counts, ...bits.slice(2)].join(" \u00b7 ");
17
+ }
18
+ /** renderCall component that paints nothing — the result card owns display. */
19
+ export const EMPTY_CALL = {
20
+ render: () => [],
21
+ invalidate() { },
22
+ };
23
+ const TOOL_COL = 12;
24
+ const DUR_COL = 7;
25
+ const MAX_CARD_WIDTH = 100;
26
+ /** Rounded frame glyphs — pi themes may provide theme.boxRound; default ASCII-art set. */
27
+ const BOX = { tl: "\u256d", tr: "\u256e", bl: "\u2570", br: "\u256f", h: "\u2500", v: "\u2502" };
28
+ function boxOf(theme) {
29
+ const b = theme?.boxRound;
30
+ if (b && typeof b.topLeft === "string" && typeof b.horizontal === "string" && typeof b.vertical === "string") {
31
+ return { tl: b.topLeft, tr: b.topRight ?? BOX.tr, bl: b.bottomLeft ?? BOX.bl, br: b.bottomRight ?? BOX.br, h: b.horizontal, v: b.vertical };
32
+ }
33
+ return BOX;
34
+ }
35
+ function borderKey(model) {
36
+ if (model.error)
37
+ return "error";
38
+ if (model.running)
39
+ return "accent";
40
+ return "dim";
41
+ }
42
+ /** 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. */
44
+ function frameBar(theme, box, border, left, right, label, width) {
45
+ 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);
48
+ return border(leftRaw) + shown + border(box.h.repeat(fill)) + border(right);
49
+ }
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. */
54
+ 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";
81
+ }
82
+ function fitPath(text, budget) {
83
+ if (visibleWidth(text) <= budget)
84
+ return text;
85
+ if (budget <= 1)
86
+ return "\u2026";
87
+ return "\u2026" + text.slice(Math.max(0, text.length - budget + 1));
88
+ }
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
+ function fmtMs(ms) {
96
+ if (!Number.isFinite(ms) || ms < 0)
97
+ return "";
98
+ return ms < 1000 ? Math.round(ms) + "ms" : (ms / 1000).toFixed(1) + "s";
99
+ }
100
+ /** "✓ search 12ms \"query\"" — one aligned op row. */
101
+ function opRow(theme, op, width) {
102
+ const marker = op.ok ? paint(theme, "success", "\u2713") : paint(theme, "error", "\u00d7");
103
+ const tool = paint(theme, "syntaxFunction", op.tool.slice(0, TOOL_COL).padEnd(TOOL_COL));
104
+ const dur = paint(theme, "dim", fmtMs(op.ms).padStart(DUR_COL));
105
+ const target = op.target ? " " + paint(theme, "muted", fitPath(op.target, Math.max(1, width - TOOL_COL - DUR_COL - 9))) : "";
106
+ return " " + marker + " " + tool + " " + dur + target;
107
+ }
108
+ /** " 1. path:line symbol · kind" — one numbered hit row. */
109
+ function hitRow(theme, n, hit, width) {
110
+ const num = paint(theme, "dim", String(n).padStart(2) + ".");
111
+ const loc = hitLocation(hit);
112
+ const locBudget = Math.max(8, Math.floor(width * 0.62));
113
+ const locText = paint(theme, "accent", fitPath(loc, locBudget));
114
+ const label = hitLabel(hit);
115
+ const row = " " + num + " " + locText + (label ? " " + paint(theme, "muted", label) : "");
116
+ return clamp(row, width);
117
+ }
118
+ export class AsgrepCard {
119
+ theme;
120
+ model;
121
+ cache;
122
+ set(theme, model) {
123
+ this.theme = theme;
124
+ this.model = model;
125
+ this.cache = undefined;
126
+ }
127
+ invalidate() {
128
+ this.cache = undefined;
129
+ }
130
+ render(width = 80) {
131
+ const theme = this.theme;
132
+ const model = this.model;
133
+ if (!model || width <= 0)
134
+ return [];
135
+ if (this.cache?.width === width)
136
+ 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));
140
+ this.cache = { width, lines };
141
+ return lines;
142
+ }
143
+ }
144
+ /** Rounded card: header embedded in the top rule, body rows in \u2502 gutters. */
145
+ function framedLines(theme, model, width) {
146
+ const box = boxOf(theme);
147
+ const key = borderKey(model);
148
+ const border = (text) => paint(theme, key, text);
149
+ const inner = Math.max(1, width - 4); // "\u2502 " + content + " \u2502"
150
+ const label = frameLabel(model);
151
+ const rows = bodyLines(theme, model, inner);
152
+ const out = [frameBar(theme, box, border, box.tl, box.tr, label, width)];
153
+ for (const row of rows) {
154
+ 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));
157
+ }
158
+ out.push(frameBar(theme, box, border, box.bl, box.br, null, width));
159
+ return out;
160
+ }
161
+ function bodyLines(theme, model, width) {
162
+ const lines = [];
163
+ const maxOps = model.expanded ? 24 : 8;
164
+ const maxHits = model.expanded ? 24 : 12;
165
+ const ops = (model.ops ?? []).slice(0, maxOps);
166
+ for (const op of ops)
167
+ lines.push(clamp(opRow(theme, op, width), width));
168
+ if ((model.ops?.length ?? 0) > ops.length) {
169
+ lines.push(paint(theme, "dim", " \u2026 " + ((model.ops?.length ?? 0) - ops.length) + " more calls"));
170
+ }
171
+ const hits = (model.hits ?? []).slice(0, maxHits);
172
+ hits.forEach((hit, i) => lines.push(hitRow(theme, i + 1, hit, width)));
173
+ if ((model.hits?.length ?? 0) > hits.length) {
174
+ lines.push(paint(theme, "dim", " \u2026 " + ((model.hits?.length ?? 0) - hits.length) + " more"));
175
+ }
176
+ 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) : "");
178
+ lines.push(clamp(head, width));
179
+ for (const line of (edit.removed ?? []).slice(0, model.expanded ? 24 : 8)) {
180
+ lines.push(" " + paint(theme, "error", "- " + clamp(line, width - 4)));
181
+ }
182
+ for (const line of (edit.added ?? []).slice(0, model.expanded ? 24 : 8)) {
183
+ lines.push(" " + paint(theme, "success", "+ " + clamp(line, width - 4)));
184
+ }
185
+ if (edit.truncated)
186
+ lines.push(paint(theme, "dim", " \u2026"));
187
+ }
188
+ if (model.error) {
189
+ for (const line of model.error.split("\n").slice(0, model.expanded ? 12 : 4)) {
190
+ lines.push(" " + paint(theme, "error", clamp(line, width - 1)));
191
+ }
192
+ }
193
+ else if (model.resultLines && model.resultLines.length > 0) {
194
+ const shown = model.expanded ? model.resultLines : model.resultLines.slice(0, 8);
195
+ for (const line of shown)
196
+ lines.push(" " + paint(theme, "muted", clamp(line, width - 1)));
197
+ if (!model.expanded && model.resultLines.length > shown.length) {
198
+ lines.push(paint(theme, "dim", " \u2026 " + (model.resultLines.length - shown.length) + " more result lines"));
199
+ }
200
+ }
201
+ return lines;
202
+ }
203
+ function editsOf(value) {
204
+ 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)));
206
+ }
207
+ return undefined;
208
+ }
209
+ function hitsOf(value) {
210
+ if (value && typeof value === "object" && Array.isArray(value.hits)) {
211
+ return value.hits;
212
+ }
213
+ return undefined;
214
+ }
215
+ function resultPreviewLines(value) {
216
+ if (value === undefined || value === null)
217
+ return undefined;
218
+ if (typeof value === "string")
219
+ return value.split("\n").filter((l) => l.length > 0).slice(0, 16);
220
+ if (Array.isArray(value))
221
+ return [value.length + " value" + (value.length === 1 ? "" : "s")];
222
+ 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
+ });
227
+ }
228
+ return [String(value)];
229
+ }
230
+ /** Build the card model from the tool result's details payload. */
231
+ export function cardModel(result, options) {
232
+ const details = (result.details && typeof result.details === "object" ? result.details : {});
233
+ const command = typeof details.command === "string" ? details.command : "asgrep";
234
+ const expanded = options.expanded === true;
235
+ // In-flight partial updates carry only {command, phase}.
236
+ if (options.isPartial && !("ok" in details)) {
237
+ return { command, title: [typeof details.phase === "string" ? details.phase : undefined], running: true, expanded };
238
+ }
239
+ const title = [];
240
+ if (typeof details.query === "string" && details.query)
241
+ title.push(JSON.stringify(details.query));
242
+ if (typeof details.mode === "string")
243
+ title.push(details.mode);
244
+ const error = details.error;
245
+ if (result.isError || details.ok === false || error) {
246
+ return {
247
+ command,
248
+ title,
249
+ error: typeof error?.message === "string" ? error.message : "tool failed",
250
+ expanded,
251
+ };
252
+ }
253
+ const response = details.response;
254
+ if (command === "status" || command === "index" || command === "reindex") {
255
+ const state = typeof response?.status === "string" ? response.status
256
+ : typeof response?.index_status === "string" ? response.index_status
257
+ : response?.ok === true ? "ok" : undefined;
258
+ if (state)
259
+ title.push(state);
260
+ const counts = response?.counts;
261
+ if (counts && typeof counts === "object") {
262
+ title.push(Object.entries(counts).slice(0, 4).map(([k, v]) => k + "=" + String(v)).join(" "));
263
+ }
264
+ else if (response) {
265
+ // Flat status envelope: file_count/symbol_count/caller_count + embed info.
266
+ const flat = [];
267
+ for (const k of ["file_count", "symbol_count", "caller_count"]) {
268
+ const v = response[k];
269
+ if (typeof v === "number" || typeof v === "bigint")
270
+ flat.push(k.replace(/_count$/, "s") + "=" + String(v));
271
+ }
272
+ if (typeof response.embed_backend === "string")
273
+ flat.push(response.embed_backend);
274
+ if (flat.length > 0)
275
+ title.push(flat.join(" "));
276
+ }
277
+ }
278
+ const ms = typeof details.wallMs === "number" ? details.wallMs : typeof details.activationMs === "number" ? details.activationMs : undefined;
279
+ if (ms !== undefined)
280
+ title.push(fmtMs(ms));
281
+ if (typeof details.backend === "string")
282
+ title.push(details.backend);
283
+ const trace = Array.isArray(details.trace) ? details.trace : undefined;
284
+ const ops = trace?.map((t) => ({ tool: t.tool, target: t.target ?? "", ok: t.ok !== false, ms: typeof t.ms === "number" ? t.ms : 0 }));
285
+ const hits = hitsOf(response) ?? hitsOf(details.result);
286
+ const resultEdits = editsOf(details.result) ?? editsOf(response);
287
+ // read envelopes carry windows: preview each window's first lines.
288
+ const windows = command === "read" && response && Array.isArray(response.windows)
289
+ ? response.windows
290
+ : undefined;
291
+ 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) : []),
294
+ ]);
295
+ // When edits carry diffs they are the interesting part of the result.
296
+ const resultLines = hits || resultEdits ? undefined : (readLines ?? resultPreviewLines(details.result));
297
+ const model = { command, title, expanded };
298
+ if (ops && ops.length > 0)
299
+ model.ops = ops;
300
+ if (hits)
301
+ model.hits = hits;
302
+ if (resultEdits)
303
+ model.edits = resultEdits;
304
+ if (resultLines)
305
+ model.resultLines = resultLines;
306
+ return model;
307
+ }
308
+ /** renderResult entrypoint: bind one card per result slot, feed it details. */
309
+ export function renderAsgrepResult(result, options, theme, context) {
310
+ const prev = context?.lastComponent;
311
+ const card = prev instanceof AsgrepCard ? prev : new AsgrepCard();
312
+ card.set(theme, cardModel(result, options));
313
+ if (context)
314
+ context.lastComponent = card;
315
+ return card;
316
+ }
@@ -25,8 +25,12 @@ export type EnvelopeLike = {
25
25
  backend?: unknown;
26
26
  [key: string]: unknown;
27
27
  };
28
- export declare const ASGREP_PROMPT_SNIPPET = "Search this repo by intent, symbol, callers, defs, pattern, or chain (in-process asgrep; use without being asked)";
29
- export declare const ASGREP_PROMPT_GUIDELINES: readonly ["For any code lookup (find a function, callers, defs, intent, structural pattern, or imports), call asgrep or asgrep_search immediately. Do not wait for the user to mention ast-sgrep.", "Prefer the asgrep Code Mode tool when you need more than one lookup, filtering, or parallel work. Write JavaScript that calls asgrep.search / semantic / chain / defs / callers / imports / indexStatus / indexRepo and return a small shaped value.", "Use grep only for exact log strings, filenames, or config keys. Use Pi write/edit to change files; asgrep does not mutate source."];
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 function paint(theme: PresentTheme | undefined, role: string, text: string, bold?: boolean): string;
31
+ export declare function hitLocation(hit: HitLike): string;
32
+ export declare function hitLabel(hit: HitLike): string;
33
+ export declare function header(theme: PresentTheme | undefined, verb: string, bits: Array<string | null | undefined>): string;
30
34
  export declare function formatSearchCall(params: {
31
35
  query?: string;
32
36
  mode?: string;
@@ -35,6 +39,20 @@ export declare function formatSearchCall(params: {
35
39
  }, theme?: PresentTheme): string;
36
40
  export declare function formatIndexCall(force: boolean, theme?: PresentTheme): string;
37
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
+ export declare function formatEditResult(response: EnvelopeLike, theme?: PresentTheme): string;
54
+ /** Model-visible text for a read envelope: the window contents themselves. */
55
+ export declare function formatReadResult(response: EnvelopeLike, theme?: PresentTheme): string;
38
56
  export declare function formatCodemodeCall(code: string, theme?: PresentTheme): string;
39
57
  export declare function formatSearchResult(response: EnvelopeLike, meta: {
40
58
  command: string;
@@ -1,15 +1,16 @@
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, pattern, or chain (in-process asgrep; use without being asked)";
2
+ export const ASGREP_PROMPT_SNIPPET = "Search this repo by intent, symbol, callers, defs, or pattern (asgrep; use without being asked)";
3
3
  export const ASGREP_PROMPT_GUIDELINES = [
4
- "For any code lookup (find a function, callers, defs, intent, structural pattern, or imports), call asgrep or asgrep_search immediately. Do not wait for the user to mention ast-sgrep.",
5
- "Prefer the asgrep Code Mode tool when you need more than one lookup, filtering, or parallel work. Write JavaScript that calls asgrep.search / semantic / chain / defs / callers / imports / indexStatus / indexRepo and return a small shaped value.",
6
- "Use grep only for exact log strings, filenames, or config keys. Use Pi write/edit to change files; asgrep does not mutate source.",
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\" }).",
7
8
  ];
8
- function paint(theme, role, text, bold = false) {
9
+ export function paint(theme, role, text, bold = false) {
9
10
  const body = bold && theme ? theme.bold(text) : text;
10
11
  return theme ? theme.fg(role, body) : body;
11
12
  }
12
- function hitLocation(hit) {
13
+ export function hitLocation(hit) {
13
14
  const file = String(hit.file ?? hit.path ?? "");
14
15
  const line = hit.start_line ?? hit.line ?? hit.lines;
15
16
  if (typeof line === "number")
@@ -20,13 +21,13 @@ function hitLocation(hit) {
20
21
  return hit.ref;
21
22
  return file || "?";
22
23
  }
23
- function hitLabel(hit) {
24
+ export function hitLabel(hit) {
24
25
  const symbol = typeof hit.symbol === "string" ? hit.symbol : "";
25
26
  const kind = typeof hit.kind === "string" ? hit.kind : "";
26
27
  const preview = typeof hit.preview === "string" ? hit.preview.replace(/\s+/g, " ").trim() : "";
27
28
  return [symbol, kind, preview && preview.length < 80 ? preview : ""].filter(Boolean).join(" ");
28
29
  }
29
- function header(theme, verb, bits) {
30
+ export function header(theme, verb, bits) {
30
31
  return [paint(theme, "toolTitle", "asgrep", true), paint(theme, "accent", verb), ...bits.filter((bit) => Boolean(bit))].join(" · ");
31
32
  }
32
33
  export function formatSearchCall(params, theme) {
@@ -43,6 +44,44 @@ export function formatIndexCall(force, theme) {
43
44
  export function formatStatusCall(theme) {
44
45
  return header(theme, "status", []);
45
46
  }
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
+ export function formatEditResult(response, theme) {
58
+ const edits = Array.isArray(response.edits) ? response.edits : [];
59
+ 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");
67
+ }
68
+ /** Model-visible text for a read envelope: the window contents themselves. */
69
+ export function formatReadResult(response, theme) {
70
+ const windows = Array.isArray(response.windows) ? response.windows : [];
71
+ if (windows.length === 0)
72
+ return header(theme, "read", ["0 windows"]);
73
+ const out = [];
74
+ 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 : "";
78
+ for (const line of text.split("\n").slice(0, 80))
79
+ out.push(line);
80
+ }
81
+ if (windows.length > 8)
82
+ out.push("… " + (windows.length - 8) + " more windows");
83
+ return out.join("\n");
84
+ }
46
85
  export function formatCodemodeCall(code, theme) {
47
86
  const preview = code.trim().replace(/\s+/g, " ").slice(0, 80);
48
87
  return header(theme, "codemode", [`${preview}${code.trim().length > 80 ? "…" : ""}`]);
@@ -61,6 +100,20 @@ export function formatSearchResult(response, meta, theme) {
61
100
  const label = hitLabel(hit);
62
101
  return paint(theme, "toolOutput", label ? ` ${loc} ${label}` : ` ${loc}`);
63
102
  });
103
+ 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")
107
+ : [];
108
+ if (next.length > 0) {
109
+ for (const query of next.slice(0, 4)) {
110
+ rows.push(paint(theme, "toolOutput", ` try ${query}`));
111
+ }
112
+ }
113
+ else {
114
+ rows.push(paint(theme, "toolOutput", " try asgrep.find(query) or asgrep.defs(symbol) or asgrep.search(query, { in: \"src\" })"));
115
+ }
116
+ }
64
117
  if (hits.length > 24) {
65
118
  rows.push(paint(theme, "muted", ` … ${hits.length - 24} more`));
66
119
  }
@@ -173,7 +226,10 @@ export function truncateToWidth(text, maxWidth, ellipsis = "...") {
173
226
  width += cell.width;
174
227
  index += cell.length;
175
228
  }
176
- return `${kept}${ellipsis}`;
229
+ // The cut can land inside a painted span — its closing SGR is past the
230
+ // budget, so the ellipsis and everything after would inherit the open color.
231
+ // Close it explicitly; harmless when the kept spans were already balanced.
232
+ return kept + (kept.includes("\u001b[") ? "\u001b[0m" : "") + ellipsis;
177
233
  }
178
234
  function compactValue(value) {
179
235
  if (value === null || value === undefined)
@@ -221,6 +277,9 @@ export function formatCodemodeResult(value, meta = {}, theme) {
221
277
  if (meta.wallMs !== undefined)
222
278
  bits.push(`${meta.wallMs}ms`);
223
279
  const title = header(theme, "codemode", bits);
280
+ if (value === undefined) {
281
+ return `${title}\n${paint(theme, "muted", " (no return statement; add `return` to send a value to the model)")}`;
282
+ }
224
283
  if (value && typeof value === "object" && !Array.isArray(value)) {
225
284
  const rows = Object.entries(value).slice(0, 16).map(([key, entry]) => paint(theme, "toolOutput", ` ${key}: ${compactValue(entry)}`));
226
285
  return [title, ...rows].join("\n");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ast-sgrep",
3
- "version": "2.0.2",
3
+ "version": "2.1.1",
4
4
  "description": "Native Code Mode, structural, graph, and semantic code search for Pi",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -36,8 +36,8 @@
36
36
  "import": "./dist/index.js"
37
37
  },
38
38
  "./runtime": {
39
- "types": "./dist/runtime.d.ts",
40
- "import": "./dist/runtime.js"
39
+ "types": "./dist/runtime/runtime.d.ts",
40
+ "import": "./dist/runtime/runtime.js"
41
41
  },
42
42
  "./code-mode": {
43
43
  "types": "./dist/code-mode.d.ts",
@@ -51,9 +51,9 @@
51
51
  "image": "./assets/preview.png"
52
52
  },
53
53
  "scripts": {
54
- "build": "tsc -p tsconfig.json",
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/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/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
59
  "prepack": "npm run build"
@@ -62,7 +62,7 @@
62
62
  "node": ">=22.19.0"
63
63
  },
64
64
  "dependencies": {
65
- "ast-sgrep": "2.0.0",
65
+ "ast-sgrep": ">=2.0.0 <3",
66
66
  "typebox": "^1.0.0"
67
67
  },
68
68
  "peerDependencies": {
@@ -1 +0,0 @@
1
- export {};