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 }