/ extensions / code-actions / snippets.ts
snippets.ts
  1  export type SnippetType = "block" | "inline";
  2  
  3  export type Snippet = {
  4  	id: number;
  5  	type: SnippetType;
  6  	language?: string;
  7  	content: string;
  8  	messageId: string;
  9  	sourceLabel: string;
 10  };
 11  
 12  // ─────────────────────────────────────────────────────────────
 13  // Constants
 14  // ─────────────────────────────────────────────────────────────
 15  
 16  /** Minimum slashes required for generic path-like content (e.g. `foo/bar/baz`) */
 17  const MIN_SLASH_COUNT = 2;
 18  
 19  /** Matches file extensions like .ts, .html, .json */
 20  const HAS_FILE_EXTENSION = /\.[a-zA-Z0-9]{1,6}$/;
 21  
 22  /** Commands/keywords that appear in inline code but aren't actionable */
 23  const IGNORED_COMMANDS = new Set([
 24  	"main",
 25  	"inline",
 26  	"blocks",
 27  	"bash -lc",
 28  	"ls",
 29  	"pwd",
 30  	"cd",
 31  	"git status",
 32  	"git diff",
 33  	"git add",
 34  	"git commit",
 35  	"git push",
 36  	"git pull",
 37  	"git checkout",
 38  	"git switch",
 39  	"npm install",
 40  	"pnpm install",
 41  	"yarn install",
 42  	"bun install",
 43  	"npm test",
 44  	"pnpm test",
 45  	"yarn test",
 46  	"npm run",
 47  	"pnpm run",
 48  	"yarn run",
 49  	"make",
 50  	"make test",
 51  	"make lint",
 52  	"make build",
 53  ]);
 54  
 55  export function extractText(content: unknown): string {
 56  	if (typeof content === "string") return content;
 57  	if (!Array.isArray(content)) return "";
 58  
 59  	let text = "";
 60  	for (const part of content) {
 61  		if (!part) continue;
 62  
 63  		if (typeof part === "string") {
 64  			text += part;
 65  			continue;
 66  		}
 67  
 68  		if (typeof part !== "object") continue;
 69  
 70  		// Pi content parts are usually `{ type: "text", text: "..." }`, but tool outputs and
 71  		// other renderers may use different `type`s while still storing the actual text in a `.text` field
 72  		const anyPart = part as any;
 73  		if (typeof anyPart.text === "string" && anyPart.text.length > 0) {
 74  			text += anyPart.text;
 75  			continue;
 76  		}
 77  
 78  		// Best-effort: some tool result parts may expose `content`/`stdout`/`stderr`
 79  		if (typeof anyPart.content === "string" && anyPart.content.length > 0) {
 80  			text += anyPart.content;
 81  			continue;
 82  		}
 83  		if (typeof anyPart.stdout === "string" && anyPart.stdout.length > 0) {
 84  			text += anyPart.stdout;
 85  		}
 86  		if (typeof anyPart.stderr === "string" && anyPart.stderr.length > 0) {
 87  			text += `\nstderr:\n${anyPart.stderr}`;
 88  		}
 89  	}
 90  
 91  	return text;
 92  }
 93  
 94  // ─────────────────────────────────────────────────────────────
 95  // Filtering
 96  // ─────────────────────────────────────────────────────────────
 97  
 98  /** Check if content looks like a file path worth extracting */
 99  function looksLikePath(content: string): boolean {
100  	const slashCount = (content.match(/\//g) || []).length;
101  
102  	// ~/... or commands containing ~/ (e.g. "open ~/file.html")
103  	if (/(?:^|[\s"'])~\//.test(content)) return true;
104  
105  	// ./... (current directory)
106  	if (content.startsWith("./")) return true;
107  
108  	// Absolute paths: /foo/bar (2+ slashes) or /file.html (has extension)
109  	if (content.startsWith("/")) {
110  		return slashCount >= MIN_SLASH_COUNT || HAS_FILE_EXTENSION.test(content);
111  	}
112  
113  	// Generic: foo/bar/baz requires 2+ slashes
114  	return slashCount >= MIN_SLASH_COUNT;
115  }
116  
117  function shouldIncludeInlineSnippet(content: string): boolean {
118  	const trimmed = content.trim();
119  
120  	// Reject: empty, comments, known commands, short identifiers
121  	if (trimmed.length === 0) return false;
122  	if (trimmed.startsWith("//") || trimmed.startsWith("/*") || trimmed.startsWith("*")) return false;
123  	if (IGNORED_COMMANDS.has(trimmed)) return false;
124  	if (/^[A-Za-z0-9._-]{1,5}$/.test(trimmed)) return false;
125  
126  	return looksLikePath(trimmed);
127  }
128  
129  // ─────────────────────────────────────────────────────────────
130  // Extraction
131  // ─────────────────────────────────────────────────────────────
132  
133  export function extractSnippets(
134  	text: string,
135  	messageId: string,
136  	sourceLabel: string,
137  	startId: number,
138  	includeInline: boolean,
139  	limit: number,
140  ): Snippet[] {
141  	const snippets: Snippet[] = [];
142  	const fencedRanges: Array<{ start: number; end: number }> = [];
143  	const fencedRegex = /```([^\n`]*)\n([\s\S]*?)```/g;
144  	let match: RegExpExecArray | null;
145  
146  	while ((match = fencedRegex.exec(text))) {
147  		if (snippets.length >= limit) return snippets;
148  		const language = match[1]?.trim() || undefined;
149  		const content = match[2]?.replace(/\n$/, "") ?? "";
150  		snippets.push({
151  			id: startId + snippets.length,
152  			type: "block",
153  			language,
154  			content,
155  			messageId,
156  			sourceLabel,
157  		});
158  		fencedRanges.push({ start: match.index, end: match.index + match[0].length });
159  	}
160  
161  	if (!includeInline) return snippets;
162  
163  	const inlineRegex = /`([^`\n]+)`/g;
164  	while ((match = inlineRegex.exec(text))) {
165  		if (snippets.length >= limit) return snippets;
166  		const index = match.index;
167  		const inFence = fencedRanges.some((range) => index >= range.start && index < range.end);
168  		if (inFence) continue;
169  
170  		const content = match[1] ?? "";
171  		if (!shouldIncludeInlineSnippet(content)) continue;
172  
173  		snippets.push({
174  			id: startId + snippets.length,
175  			type: "inline",
176  			content,
177  			messageId,
178  			sourceLabel,
179  		});
180  	}
181  
182  	// Also extract shell transcript-style prompt lines from assistant messages, e.g.
183  	//   $ echo hello
184  	//   > continued args
185  	// This is common when the assistant shows a terminal-style interaction without a fenced code block.
186  	// We only extract prompt lines outside fenced code blocks
187  	const lines = text.split(/\r?\n/);
188  	let cursor = 0;
189  	let currentCommandLines: string[] = [];
190  
191  	const flushPromptSnippet = () => {
192  		if (snippets.length >= limit) return;
193  		const content = currentCommandLines.join("\n").trim();
194  		if (!content) return;
195  		snippets.push({
196  			id: startId + snippets.length,
197  			type: "block",
198  			language: "bash",
199  			content,
200  			messageId,
201  			sourceLabel,
202  		});
203  	};
204  
205  	for (const line of lines) {
206  		const lineStart = cursor;
207  		cursor += line.length + 1;
208  
209  		const inFence = fencedRanges.some((range) => lineStart >= range.start && lineStart < range.end);
210  		if (inFence) {
211  			if (currentCommandLines.length > 0) {
212  				flushPromptSnippet();
213  				currentCommandLines = [];
214  				currentStartIndex = null;
215  			}
216  			continue;
217  		}
218  
219  		const stripped = line.replace(/\x1b\[[0-9;]*m/g, "");
220  		const isPrompt = /^\s*\$\s+/.test(stripped) || /^\s*!\s*/.test(stripped);
221  		const isContinuation = /^\s*>\s+/.test(stripped);
222  
223  		if (isPrompt) {
224  			currentCommandLines.push(stripped.replace(/^\s*(?:\$\s+|!\s*)/, ""));
225  			continue;
226  		}
227  
228  		if (isContinuation && currentCommandLines.length > 0) {
229  			currentCommandLines.push(stripped.replace(/^\s*>\s+/, ""));
230  			continue;
231  		}
232  
233  		if (currentCommandLines.length > 0) {
234  			flushPromptSnippet();
235  			currentCommandLines = [];
236  			currentStartIndex = null;
237  		}
238  	}
239  
240  	if (currentCommandLines.length > 0) {
241  		flushPromptSnippet();
242  	}
243  
244  	return snippets;
245  }
246  
247  export function getSnippetPreview(snippet: Snippet): string {
248  	const content = snippet.content.trim();
249  	if (content.length === 0) return "(empty)";
250  
251  	if (snippet.type === "block") {
252  		return content.replace(/\s+/g, " ");
253  	}
254  
255  	const lines = content.split(/\r?\n/);
256  	const firstNonEmpty = lines.find((line) => line.trim().length > 0) ?? lines[0] ?? "";
257  	const preview = firstNonEmpty.trim();
258  	return preview.length > 0 ? preview : "(empty)";
259  }
260  
261  export function truncatePreview(value: string, width: number): string {
262  	if (value.length <= width) return value;
263  	if (width <= 1) return value.slice(0, width);
264  	return `${value.slice(0, width - 1)}…`;
265  }