pi-repl-py 0.1.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.
@@ -0,0 +1,518 @@
1
+ /**
2
+ * Semantic cell previews: the collapsed line of an \u0060execute\u0060 cell.
3
+ *
4
+ * A cell's first line is almost always plumbing (const out = await ...), so
5
+ * "first non-comment line" renders every collapsed call as noise. This module
6
+ * scores the whole cell for intent — the shell command inside a Bun.$ template,
7
+ * the task handed to a subagent, the file a write lands on — and returns the
8
+ * one line a reader would want, plus which kind of work it is.
9
+ *
10
+ * Pure and dependency-free so the scorer is table-testable; render-core
11
+ * consumes it for the collapsed header.
12
+ */
13
+
14
+ /** What kind of work the winning line represents; drives the header label. */
15
+ type CellPreviewKind = "shell" | "agent" | "ts";
16
+
17
+ export interface CellPreview {
18
+ kind: CellPreviewKind;
19
+ text: string;
20
+ }
21
+
22
+ const BACKTICK = "\u0060";
23
+ const DESCRIPTOR_MAX_WIDTH = 64;
24
+
25
+ // ── descriptor hygiene ───────────────────────────────────────────────────────
26
+
27
+ function collapseWhitespace(text: string): string {
28
+ return text.replace(/\s+/g, " ").trim();
29
+ }
30
+
31
+ function truncateDescriptor(text: string): string {
32
+ if (text.length <= DESCRIPTOR_MAX_WIDTH) return text;
33
+ return text.slice(0, DESCRIPTOR_MAX_WIDTH - 1).trimEnd() + "…";
34
+ }
35
+
36
+ function redactNoise(text: string): string {
37
+ return text
38
+ .replace(/[A-Za-z0-9+/]{80,}={0,2}/g, "<blob>")
39
+ .replace(/\b((?=\w*(?:token|key|secret|password))[A-Za-z_]\w*)\s*[=:]\s*(["'])[^"']*\2/gi, "$1=<redacted>")
40
+ .replace(
41
+ /\b((?=\w*(?:token|key|secret|password))[A-Za-z_]\w*)\s*[=:]\s*(?!<redacted>)(?!["'])\S+/gi,
42
+ "$1=<redacted>",
43
+ )
44
+ .replace(/(["'])sk-[^"']+\1/g, "$1<redacted>$1")
45
+ .replace(/(["']).{160,}\1/g, "$1…$1");
46
+ }
47
+
48
+ export function descriptor(text: string): string {
49
+ return truncateDescriptor(collapseWhitespace(redactNoise(text)));
50
+ }
51
+
52
+ // ── source scanning ──────────────────────────────────────────────────────────
53
+
54
+ interface Span {
55
+ start: number;
56
+ end: number;
57
+ body: string;
58
+ }
59
+
60
+ /**
61
+ * Body of the template literal opening at start (the backtick index). Tracks
62
+ * escapes and interpolation nesting (including nested templates) so a shell
63
+ * command containing interpolations is captured whole. An unclosed template is
64
+ * returned as-is: while args stream, previewing the partial command beats
65
+ * previewing nothing.
66
+ */
67
+ function scanTemplate(source: string, start: number): Span {
68
+ let depth = 0;
69
+ let inNested = false;
70
+ for (let i = start + 1; i < source.length; i++) {
71
+ const ch = source[i];
72
+ if (ch === "\\") {
73
+ i += 1;
74
+ continue;
75
+ }
76
+ if (ch === BACKTICK) {
77
+ if (depth === 0 && !inNested) return { start, end: i + 1, body: source.slice(start + 1, i) };
78
+ inNested = !inNested;
79
+ continue;
80
+ }
81
+ if (!inNested && ch === "$" && source[i + 1] === "{") {
82
+ depth += 1;
83
+ i += 1;
84
+ continue;
85
+ }
86
+ if (!inNested && depth > 0 && ch === "}") depth -= 1;
87
+ }
88
+ return { start, end: source.length, body: source.slice(start + 1) };
89
+ }
90
+
91
+ const CONST_STRING_PATTERN = new RegExp(
92
+ '(?:const|let|var)\\s+([A-Za-z_$][\\w$]*)\\s*=\\s*(?:"([^"\\n]*)"|' +
93
+ "'([^'\\n]*)'|" +
94
+ BACKTICK +
95
+ "([^" +
96
+ BACKTICK +
97
+ "$\\n]*)" +
98
+ BACKTICK +
99
+ ")",
100
+ "g",
101
+ );
102
+
103
+ /** Simple string constants, for resolving interpolations and path arguments. */
104
+ function stringConsts(source: string): Map<string, string> {
105
+ const vars = new Map<string, string>();
106
+ for (const match of source.matchAll(CONST_STRING_PATTERN)) {
107
+ const name = match[1];
108
+ const value = match[2] ?? match[3] ?? match[4];
109
+ if (name && value !== undefined) vars.set(name, value);
110
+ }
111
+ return vars;
112
+ }
113
+
114
+ function substituteVars(text: string, vars: ReadonlyMap<string, string>): string {
115
+ return text.replace(/\$\{\s*([A-Za-z_$][\w$]*)\s*\}/g, (whole, name: string) => vars.get(name) ?? whole);
116
+ }
117
+
118
+ function maskSpan(source: string, span: Span): string {
119
+ return source.slice(0, span.start) + " ".repeat(span.end - span.start) + source.slice(span.end);
120
+ }
121
+
122
+ // ── shell command simplification ─────────────────────────────────────────────
123
+
124
+ const CD_PREFIX_PATTERN = /^\s*cd\s+([^&;|]+?)\s*(?:&&|;)\s*/;
125
+ const SHELL_SETUP_PATTERN = /^(?:export\s+\w+=|set\s+[-+]|source\s+\S+|\.\s+\S+)/;
126
+ const HEREDOC_PATTERN = /<<-?\s*['"]?([A-Za-z_][A-Za-z0-9_]*)['"]?/;
127
+
128
+ function shellWords(line: string): string[] {
129
+ const words: string[] = [];
130
+ for (const match of line.matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)) {
131
+ words.push(match[1] ?? match[2] ?? match[3] ?? "");
132
+ }
133
+ return words;
134
+ }
135
+
136
+ function pathTail(path: string): string {
137
+ const cleaned = path.replace(/\/+$/, "");
138
+ const tail = cleaned.slice(cleaned.lastIndexOf("/") + 1);
139
+ return tail || cleaned;
140
+ }
141
+
142
+ function simplifyRunnerCommand(line: string): string | undefined {
143
+ const words = shellWords(line);
144
+ if (words[0] === "npm" || words[0] === "pnpm") {
145
+ const runIndex = words.indexOf("run");
146
+ if (runIndex >= 0 && words[runIndex + 1]) {
147
+ return (words[0] + " " + words.slice(runIndex + 1).join(" ")).trim();
148
+ }
149
+ }
150
+ if (line.includes("node_modules/.bin/")) {
151
+ return line.replace(/\S*node_modules\/\.bin\//g, "");
152
+ }
153
+ return undefined;
154
+ }
155
+
156
+ function simplifyMutationCommand(line: string): string | undefined {
157
+ const words = shellWords(line);
158
+ if (words.length === 0) return undefined;
159
+ if (words[0] === "cat" && words[1] === ">" && words[2]) return "write " + pathTail(words[2]);
160
+ if (words[0] === "tee" && words.at(-1)) {
161
+ return (words.includes("-a") ? "append " : "write ") + pathTail(words.at(-1) ?? "");
162
+ }
163
+ return undefined;
164
+ }
165
+
166
+ function simplifyShellLine(line: string): string {
167
+ return simplifyRunnerCommand(line) ?? simplifyMutationCommand(line) ?? line;
168
+ }
169
+
170
+ /**
171
+ * Commands that prepare the ground rather than do the work. A cell that mkdirs
172
+ * and then writes a file is a write; the shell only wins when it is the story.
173
+ */
174
+ const SHELL_SETUP_WORDS = new Set([
175
+ "mkdir",
176
+ "cd",
177
+ "export",
178
+ "touch",
179
+ "chmod",
180
+ "chown",
181
+ "ln",
182
+ "echo",
183
+ "true",
184
+ "sleep",
185
+ "which",
186
+ "sync",
187
+ ]);
188
+
189
+ const SHELL_ACTION_WORDS = new Set([
190
+ "rm",
191
+ "mv",
192
+ "cp",
193
+ "git",
194
+ "npm",
195
+ "pnpm",
196
+ "bun",
197
+ "bunx",
198
+ "npx",
199
+ "make",
200
+ "cargo",
201
+ "docker",
202
+ "curl",
203
+ "gh",
204
+ "pi",
205
+ ]);
206
+
207
+ function shellLineScore(line: string, index: number): number {
208
+ const simplified = simplifyShellLine(line);
209
+ const words = shellWords(line);
210
+ let score = 30;
211
+ if (simplified !== line) score += 40;
212
+ if (SHELL_ACTION_WORDS.has(words[0] ?? "")) score += 20;
213
+ if (/\b(?:rm|mv|cp|git\s+(?:add|commit|push)|sed\s+-i|perl\s+-pi|tee|cat\s*>)\b/.test(line)) score += 40;
214
+ return score + index;
215
+ }
216
+
217
+ function heredocBody(lines: readonly string[], startIndex: number, delimiter: string): string | undefined {
218
+ const body: string[] = [];
219
+ for (let i = startIndex + 1; i < lines.length; i++) {
220
+ if ((lines[i] ?? "").trim() === delimiter) return body.join("\n");
221
+ body.push(lines[i] ?? "");
222
+ }
223
+ return body.length > 0 ? body.join("\n") : undefined;
224
+ }
225
+
226
+ function previewHeredoc(lines: readonly string[]): string | undefined {
227
+ for (let i = 0; i < lines.length; i++) {
228
+ const line = (lines[i] ?? "").trim();
229
+ const delimiter = line.match(HEREDOC_PATTERN)?.[1];
230
+ if (!delimiter) continue;
231
+ const body = heredocBody(lines, i, delimiter);
232
+ if (!body) continue;
233
+ // The write target is the story; the body is detail for the expanded view.
234
+ const catWrite = line.match(/\b(?:cat|tee)\b.*(?:>|\s)(\S+)\s*<<-?/);
235
+ if (catWrite?.[1]) return (line.includes("tee -a") ? "append " : "write ") + pathTail(catWrite[1]);
236
+ return descriptor(body);
237
+ }
238
+ return undefined;
239
+ }
240
+
241
+ /** Best single line of a (possibly multi-line, chained) shell command. */
242
+ export function previewShellCommand(command: string): string {
243
+ return previewShellCommandScored(command).text;
244
+ }
245
+
246
+ /** As previewShellCommand, but keeps the winning line's own strength so a cell
247
+ * with several shell calls can surface the strongest one. */
248
+ function previewShellCommandScored(command: string): { text: string; strength: number } {
249
+ const lines = command.split("\n");
250
+ const heredoc = previewHeredoc(lines);
251
+ if (heredoc) return { text: descriptor(heredoc), strength: 90 };
252
+
253
+ let best: { text: string; score: number } | undefined;
254
+ let cwdSuffix: string | undefined;
255
+ let index = 0;
256
+ for (const rawLine of lines) {
257
+ for (const rawPart of rawLine.split(/\s*(?:&&|;)\s*/)) {
258
+ let part = rawPart.trim();
259
+ if (!part || part.startsWith("#") || SHELL_SETUP_PATTERN.test(part)) continue;
260
+ const cd = part.match(CD_PREFIX_PATTERN);
261
+ if (cd?.[1]) {
262
+ cwdSuffix = pathTail(cd[1].trim());
263
+ part = part.replace(CD_PREFIX_PATTERN, "").trim();
264
+ } else if (/^cd\s+\S+$/.test(part)) {
265
+ cwdSuffix = pathTail(part.slice(2).trim());
266
+ continue;
267
+ }
268
+ if (!part) continue;
269
+ const candidate = { text: simplifyShellLine(part), score: shellLineScore(part, index) };
270
+ if (!best || candidate.score > best.score) best = candidate;
271
+ index += 1;
272
+ }
273
+ }
274
+ if (!best) return { text: "", strength: 0 };
275
+ // --- trailing redirections are plumbing, not intent ---
276
+ const cleaned = best.text.replace(/(?:\s*(?:2>&1|[12]?>\s*\/dev\/null|&>\s*\/dev\/null))+\s*$/, "");
277
+ // --- a stripped cd prefix still matters ---
278
+ const text = cwdSuffix && !cleaned.includes(cwdSuffix) ? cleaned + " (" + cwdSuffix + ")" : cleaned;
279
+ return { text: descriptor(text), strength: best.score };
280
+ }
281
+
282
+ // ── special construct extraction ─────────────────────────────────────────────
283
+
284
+ interface Candidate {
285
+ kind: CellPreviewKind;
286
+ text: string;
287
+ score: number;
288
+ }
289
+
290
+ const SHELL_OPEN_PATTERN = new RegExp("Bun\\s*\\.\\s*\\$\\s*(?:\\([^)]*\\)\\s*)?" + BACKTICK, "g");
291
+
292
+ function shellCandidates(
293
+ source: string,
294
+ vars: ReadonlyMap<string, string>,
295
+ ): { candidates: Candidate[]; masked: string } {
296
+ const candidates: Candidate[] = [];
297
+ let masked = source;
298
+ SHELL_OPEN_PATTERN.lastIndex = 0;
299
+ let match = SHELL_OPEN_PATTERN.exec(masked);
300
+ while (match) {
301
+ const span = scanTemplate(masked, match.index + match[0].length - 1);
302
+ const command = previewShellCommandScored(substituteVars(span.body, vars));
303
+ // --- the command's own strength breaks ties; setup-only drops lower ---
304
+ if (command.text) {
305
+ const setupOnly = SHELL_SETUP_WORDS.has(shellWords(command.text)[0] ?? "");
306
+ const score = setupOnly ? 72 : 90 + Math.min(command.strength, 200) / 25;
307
+ candidates.push({ kind: "shell", text: command.text, score });
308
+ }
309
+ masked = maskSpan(masked, span);
310
+ SHELL_OPEN_PATTERN.lastIndex = span.end;
311
+ match = SHELL_OPEN_PATTERN.exec(masked);
312
+ }
313
+ return { candidates, masked };
314
+ }
315
+
316
+ const STRING_ARG_PATTERN = /^\s*(?:"([^"]*)"|'([^']*)')/;
317
+
318
+ function agentCandidates(
319
+ source: string,
320
+ vars: ReadonlyMap<string, string>,
321
+ ): { candidates: Candidate[]; masked: string } {
322
+ const tasks: string[] = [];
323
+ let masked = source;
324
+ const pattern = /rlm\s*\.\s*run\s*\(/g;
325
+ let match = pattern.exec(masked);
326
+ while (match) {
327
+ const argsStart = match.index + match[0].length;
328
+ let task: string | undefined;
329
+ const rest = masked.slice(argsStart);
330
+ const literal = rest.match(STRING_ARG_PATTERN);
331
+ if (literal) {
332
+ task = literal[1] ?? literal[2];
333
+ } else if (rest.trimStart().startsWith(BACKTICK)) {
334
+ const tickIndex = argsStart + rest.indexOf(BACKTICK);
335
+ const span = scanTemplate(masked, tickIndex);
336
+ task = substituteVars(span.body, vars);
337
+ masked = maskSpan(masked, span);
338
+ } else {
339
+ const identifier = rest.match(/^\s*([A-Za-z_$][\w$]*)/)?.[1];
340
+ task = identifier ? (vars.get(identifier) ?? identifier) : undefined;
341
+ }
342
+ // --- a chosen child name is identity; lead with it ---
343
+ const name = masked.slice(argsStart).match(/name\s*:\s*(?:"([^"]*)"|'([^']*)')/);
344
+ const label = name?.[1] ?? name?.[2];
345
+ tasks.push(label && task ? label + ": " + task : (label ?? task ?? "subagent"));
346
+ pattern.lastIndex = argsStart;
347
+ match = pattern.exec(masked);
348
+ }
349
+ const candidates: Candidate[] =
350
+ tasks.length === 0
351
+ ? []
352
+ : [
353
+ {
354
+ kind: "agent",
355
+ text: descriptor(tasks.length === 1 ? (tasks[0] ?? "") : tasks[0] + " (+" + (tasks.length - 1) + " more)"),
356
+ score: 100,
357
+ },
358
+ ];
359
+ return { candidates, masked };
360
+ }
361
+
362
+ const FILE_EFFECT_PATTERN =
363
+ /(?:Bun\.write|\b(?:fs|fsp|promises)\.(?:writeFileSync|writeFile|appendFileSync|appendFile|mkdirSync|mkdir|rmSync|rmdirSync|unlinkSync|unlink|renameSync|rename|copyFileSync|copyFile|cpSync|cp)|\b(?:writeFileSync|writeFile|appendFileSync|mkdirSync|rmSync|unlinkSync|renameSync|copyFileSync))\s*\(\s*([^,)\n]+)/g;
364
+
365
+ const FILE_EFFECT_VERBS: ReadonlyArray<[string, string]> = [
366
+ ["Bun.write", "write"],
367
+ ["writeFileSync", "write"],
368
+ ["writeFile", "write"],
369
+ ["appendFileSync", "append"],
370
+ ["appendFile", "append"],
371
+ ["mkdirSync", "mkdir"],
372
+ ["mkdir", "mkdir"],
373
+ ["rmdirSync", "delete"],
374
+ ["rmSync", "delete"],
375
+ ["rm", "delete"],
376
+ ["unlinkSync", "delete"],
377
+ ["unlink", "delete"],
378
+ ["renameSync", "rename"],
379
+ ["rename", "rename"],
380
+ ["copyFileSync", "copy"],
381
+ ["copyFile", "copy"],
382
+ ["cpSync", "copy"],
383
+ ["cp", "copy"],
384
+ ];
385
+
386
+ function resolveArgText(arg: string, vars: ReadonlyMap<string, string>): string | undefined {
387
+ const trimmed = arg.trim();
388
+ const literalPattern = new RegExp("^[\"'" + BACKTICK + "]([^\"'" + BACKTICK + "]*)[\"'" + BACKTICK + "]$");
389
+ const literal = trimmed.match(literalPattern);
390
+ if (literal?.[1]) return literal[1];
391
+ if (/^[A-Za-z_$][\w$]*$/.test(trimmed)) return vars.get(trimmed);
392
+ if (trimmed.startsWith(BACKTICK)) return substituteVars(trimmed.slice(1, -1), vars);
393
+ return undefined;
394
+ }
395
+
396
+ const FILE_READ_PATTERN = /Bun\.file\s*\(\s*([^,)\n]+?)\s*\)\s*\.\s*(?:text|json|arrayBuffer|bytes|stream)\s*\(/g;
397
+
398
+ function fileCandidates(source: string, vars: ReadonlyMap<string, string>): Candidate[] {
399
+ const candidates: Candidate[] = [];
400
+ for (const match of source.matchAll(FILE_EFFECT_PATTERN)) {
401
+ const call = match[0];
402
+ const verb = FILE_EFFECT_VERBS.find(([name]) => call.includes(name))?.[1];
403
+ if (!verb) continue;
404
+ const path = resolveArgText(match[1] ?? "", vars);
405
+ if (path) candidates.push({ kind: "ts", text: descriptor(verb + " " + path), score: 95 });
406
+ }
407
+ for (const match of source.matchAll(FILE_READ_PATTERN)) {
408
+ const path = resolveArgText(match[1] ?? "", vars);
409
+ if (path) candidates.push({ kind: "ts", text: descriptor("read " + path), score: 70 });
410
+ }
411
+ for (const match of source.matchAll(/\bfetch\s*\(\s*([^,)\n]+)/g)) {
412
+ const url = resolveArgText(match[1] ?? "", vars);
413
+ if (url) candidates.push({ kind: "ts", text: descriptor("fetch " + url), score: 75 });
414
+ }
415
+ return candidates;
416
+ }
417
+
418
+ // ── bridged host tools ───────────────────────────────────────────────────────
419
+
420
+ /** Per-tool: which argument names the target, the verb shown, and the band. */
421
+ const BRIDGED_TOOLS: Record<string, { arg: string; verb: string; score: number }> = {
422
+ read: { arg: "path", verb: "read", score: 70 },
423
+ bash: { arg: "command", verb: "", score: 88 },
424
+ edit: { arg: "path", verb: "edit", score: 95 },
425
+ write: { arg: "path", verb: "write", score: 95 },
426
+ grep: { arg: "pattern", verb: "grep", score: 68 },
427
+ find: { arg: "pattern", verb: "find", score: 68 },
428
+ ls: { arg: "path", verb: "ls", score: 68 },
429
+ };
430
+
431
+ function bridgedToolCandidates(source: string, vars: ReadonlyMap<string, string>): Candidate[] {
432
+ const candidates: Candidate[] = [];
433
+ for (const match of source.matchAll(/\btools\.(\w+)\s*\(\s*\{([^}]*)\}/g)) {
434
+ const spec = BRIDGED_TOOLS[match[1] ?? ""];
435
+ if (!spec) continue;
436
+ const props = match[2] ?? "";
437
+ const argMatch = props.match(new RegExp(spec.arg + "\\s*:\\s*([^,}]+)"));
438
+ const target = argMatch ? resolveArgText(argMatch[1] ?? "", vars) : undefined;
439
+ if (!target) continue;
440
+ // --- a bridged bash call is a command like any other ---
441
+ const text = spec.verb ? spec.verb + " " + target : previewShellCommand(target) || target;
442
+ candidates.push({ kind: "ts", text: descriptor(text), score: spec.score });
443
+ }
444
+ return candidates;
445
+ }
446
+
447
+ // ── generic line scoring ─────────────────────────────────────────────────────
448
+
449
+ const SKIP_LINE_PATTERN = /^(?:$|\/\/|\/\*|\*|import\s|export\s+(?:type\s|\{)|[})\];,]+$)/;
450
+ const DEFINITION_PATTERN = /^(?:export\s+)?(?:async\s+)?(?:function\s|class\s|interface\s|type\s+\w+\s*=)/;
451
+ const ARROW_DEFINITION_PATTERN = /^(?:const|let)\s+[A-Za-z_$][\w$]*\s*=\s*(?:async\s*)?\(?[^)=]*\)?\s*=>/;
452
+ const CONTROL_PATTERN = /^(?:if|for|while|switch|try|do)\b/;
453
+ const CALL_STATEMENT_PATTERN = /^(?:await\s+)?[A-Za-z_$][\w$.]*\s*\(/;
454
+ const ASSIGNMENT_CALL_PATTERN = /^(?:const|let|var)\s+[^=]{1,60}=\s*(?:await\s+)?(?:new\s+)?[A-Za-z_$][\w$.]*\s*\(/;
455
+ const LOW_SIGNAL_CALL_PATTERN =
456
+ /^(?:await\s+)?(?:console\.\w+|String|Number|Boolean|JSON\.stringify|JSON\.parse|structuredClone)\s*\(/;
457
+ const LOW_SIGNAL_ASSIGNMENT_PATTERN =
458
+ /=\s*(?:await\s+)?(?:JSON\.parse|JSON\.stringify|String|Number|Boolean|Object\.keys|Object\.entries)\s*\(/;
459
+
460
+ function consoleInnerCall(line: string): string | undefined {
461
+ const inner = line.match(/^console\.\w+\(\s*(.+)\)\s*;?\s*$/)?.[1]?.trim();
462
+ return inner && CALL_STATEMENT_PATTERN.test(inner) && !LOW_SIGNAL_CALL_PATTERN.test(inner) ? inner : undefined;
463
+ }
464
+
465
+ function genericLineScore(line: string): number {
466
+ if (SKIP_LINE_PATTERN.test(line)) return -1;
467
+ if (LOW_SIGNAL_ASSIGNMENT_PATTERN.test(line)) return 25;
468
+ if (consoleInnerCall(line)) return 55;
469
+ if (LOW_SIGNAL_CALL_PATTERN.test(line)) return 15;
470
+ if (DEFINITION_PATTERN.test(line) || ARROW_DEFINITION_PATTERN.test(line)) return 50;
471
+ if (CONTROL_PATTERN.test(line)) return 20;
472
+ if (/^(?:return|throw)\b/.test(line)) return 45;
473
+ if (ASSIGNMENT_CALL_PATTERN.test(line)) return 60;
474
+ if (CALL_STATEMENT_PATTERN.test(line)) return 65;
475
+ if (/^(?:const|let|var)\s/.test(line)) return 22;
476
+ return 30;
477
+ }
478
+
479
+ function genericCandidates(masked: string): Candidate[] {
480
+ const candidates: Candidate[] = [];
481
+ for (const [index, rawLine] of masked.split("\n").entries()) {
482
+ const line = rawLine.trim();
483
+ const score = genericLineScore(line);
484
+ if (score < 0) continue;
485
+ const text = consoleInnerCall(line) ?? line;
486
+ // --- later lines win ties: cells read as setup-then-act, and the act is the story ---
487
+
488
+ candidates.push({ kind: "ts", text: descriptor(text), score: score + Math.min(index, 90) / 100 });
489
+ }
490
+ return candidates;
491
+ }
492
+
493
+ // ── entry point ──────────────────────────────────────────────────────────────
494
+
495
+ export function previewCell(code: string): CellPreview {
496
+ const source = code.trimEnd();
497
+ if (!source) return { kind: "ts", text: "" };
498
+ const vars = stringConsts(source);
499
+
500
+ // Order matters: agent spans scrub shell-looking syntax first, then
501
+ // the shell scan masks shell bodies before the agent scan.
502
+ // generic line scan so command text is never scored as TypeScript.
503
+ const agent = agentCandidates(source, vars);
504
+ const shell = shellCandidates(agent.masked, vars);
505
+ const candidates: Candidate[] = [
506
+ ...agent.candidates,
507
+ ...shell.candidates,
508
+ ...fileCandidates(shell.masked, vars),
509
+ ...bridgedToolCandidates(shell.masked, vars),
510
+ ...genericCandidates(shell.masked),
511
+ ];
512
+
513
+ let best: Candidate | undefined;
514
+ for (const candidate of candidates) {
515
+ if (candidate.text && (!best || candidate.score > best.score)) best = candidate;
516
+ }
517
+ return best ?? { kind: "ts", text: "" };
518
+ }