pi-extensions 0.1.9

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 (135) hide show
  1. package/.ralph/import-cc-codex.md +31 -0
  2. package/.ralph/import-cc-codex.state.json +14 -0
  3. package/.ralph/mario-not-impl.md +69 -0
  4. package/.ralph/mario-not-impl.state.json +14 -0
  5. package/.ralph/mario-not-spec.md +163 -0
  6. package/.ralph/mario-not-spec.state.json +14 -0
  7. package/LICENSE +21 -0
  8. package/README.md +65 -0
  9. package/RELEASING.md +34 -0
  10. package/agent-guidance/CHANGELOG.md +4 -0
  11. package/agent-guidance/README.md +102 -0
  12. package/agent-guidance/agent-guidance.ts +147 -0
  13. package/agent-guidance/package.json +22 -0
  14. package/agent-guidance/setup.sh +75 -0
  15. package/agent-guidance/templates/CLAUDE.md +5 -0
  16. package/agent-guidance/templates/CODEX.md +92 -0
  17. package/agent-guidance/templates/GEMINI.md +5 -0
  18. package/arcade/CHANGELOG.md +4 -0
  19. package/arcade/README.md +85 -0
  20. package/arcade/assets/picman.png +0 -0
  21. package/arcade/assets/ping.png +0 -0
  22. package/arcade/assets/spice-invaders.png +0 -0
  23. package/arcade/assets/tetris.png +0 -0
  24. package/arcade/mario-not/README.md +30 -0
  25. package/arcade/mario-not/boss.js +103 -0
  26. package/arcade/mario-not/camera.js +59 -0
  27. package/arcade/mario-not/collision.js +91 -0
  28. package/arcade/mario-not/colors.js +36 -0
  29. package/arcade/mario-not/constants.js +97 -0
  30. package/arcade/mario-not/core.js +39 -0
  31. package/arcade/mario-not/death.js +77 -0
  32. package/arcade/mario-not/effects.js +84 -0
  33. package/arcade/mario-not/enemies.js +31 -0
  34. package/arcade/mario-not/engine.js +171 -0
  35. package/arcade/mario-not/fireballs.js +98 -0
  36. package/arcade/mario-not/items.js +24 -0
  37. package/arcade/mario-not/levels.js +403 -0
  38. package/arcade/mario-not/logic.js +104 -0
  39. package/arcade/mario-not/mario-not.ts +297 -0
  40. package/arcade/mario-not/player.js +244 -0
  41. package/arcade/mario-not/render.js +257 -0
  42. package/arcade/mario-not/spec.md +548 -0
  43. package/arcade/mario-not/state.js +246 -0
  44. package/arcade/mario-not/tests/e2e.test.js +855 -0
  45. package/arcade/mario-not/tests/engine.test.js +888 -0
  46. package/arcade/mario-not/tests/fixtures/story0-frame.txt +4 -0
  47. package/arcade/mario-not/tests/fixtures/story1-camera.txt +4 -0
  48. package/arcade/mario-not/tests/fixtures/story1-glyphs.txt +4 -0
  49. package/arcade/mario-not/tests/fixtures/story10-item.txt +4 -0
  50. package/arcade/mario-not/tests/fixtures/story11-hazards.txt +4 -0
  51. package/arcade/mario-not/tests/fixtures/story12-used-block.txt +4 -0
  52. package/arcade/mario-not/tests/fixtures/story13-pipes.txt +4 -0
  53. package/arcade/mario-not/tests/fixtures/story14-goal.txt +4 -0
  54. package/arcade/mario-not/tests/fixtures/story15-hud-narrow.txt +2 -0
  55. package/arcade/mario-not/tests/fixtures/story16-unknown-tile.txt +4 -0
  56. package/arcade/mario-not/tests/fixtures/story17-mix.txt +4 -0
  57. package/arcade/mario-not/tests/fixtures/story18-hud-score.txt +2 -0
  58. package/arcade/mario-not/tests/fixtures/story19-cue.txt +4 -0
  59. package/arcade/mario-not/tests/fixtures/story2-enemy.txt +4 -0
  60. package/arcade/mario-not/tests/fixtures/story20-camera-offset.txt +4 -0
  61. package/arcade/mario-not/tests/fixtures/story21-hud-zero.txt +2 -0
  62. package/arcade/mario-not/tests/fixtures/story22-big-viewport.txt +4 -0
  63. package/arcade/mario-not/tests/fixtures/story23-camera-negative.txt +4 -0
  64. package/arcade/mario-not/tests/fixtures/story24-camera-width.txt +4 -0
  65. package/arcade/mario-not/tests/fixtures/story25-camera-positive.txt +4 -0
  66. package/arcade/mario-not/tests/fixtures/story26-hud-lives.txt +2 -0
  67. package/arcade/mario-not/tests/fixtures/story27-hud-coins.txt +2 -0
  68. package/arcade/mario-not/tests/fixtures/story28-item-viewport.txt +4 -0
  69. package/arcade/mario-not/tests/fixtures/story29-enemy-viewport.txt +4 -0
  70. package/arcade/mario-not/tests/fixtures/story3-hud.txt +2 -0
  71. package/arcade/mario-not/tests/fixtures/story30-hud-score.txt +2 -0
  72. package/arcade/mario-not/tests/fixtures/story31-particles-viewport.txt +4 -0
  73. package/arcade/mario-not/tests/fixtures/story32-paused-frame.txt +4 -0
  74. package/arcade/mario-not/tests/fixtures/story4-big.txt +4 -0
  75. package/arcade/mario-not/tests/fixtures/story5-resume-hud.txt +2 -0
  76. package/arcade/mario-not/tests/fixtures/story6-particles.txt +4 -0
  77. package/arcade/mario-not/tests/fixtures/story6-paused.txt +4 -0
  78. package/arcade/mario-not/tests/fixtures/story7-powerup.txt +4 -0
  79. package/arcade/mario-not/tests/fixtures/story8-hud-time.txt +2 -0
  80. package/arcade/mario-not/tests/fixtures/story9-hud-level.txt +2 -0
  81. package/arcade/mario-not/tiles.js +79 -0
  82. package/arcade/mario-not/tsconfig.json +14 -0
  83. package/arcade/mario-not/types.js +225 -0
  84. package/arcade/package.json +26 -0
  85. package/arcade/picman.ts +328 -0
  86. package/arcade/ping.ts +594 -0
  87. package/arcade/spice-invaders.ts +1104 -0
  88. package/arcade/tetris.ts +662 -0
  89. package/code-actions/CHANGELOG.md +4 -0
  90. package/code-actions/README.md +65 -0
  91. package/code-actions/actions.ts +107 -0
  92. package/code-actions/index.ts +148 -0
  93. package/code-actions/package.json +22 -0
  94. package/code-actions/search.ts +79 -0
  95. package/code-actions/snippets.ts +179 -0
  96. package/code-actions/ui.ts +120 -0
  97. package/files-widget/CHANGELOG.md +90 -0
  98. package/files-widget/DESIGN.md +452 -0
  99. package/files-widget/README.md +122 -0
  100. package/files-widget/TODO.md +141 -0
  101. package/files-widget/browser.ts +922 -0
  102. package/files-widget/comment.ts +5 -0
  103. package/files-widget/constants.ts +18 -0
  104. package/files-widget/demo.svg +1 -0
  105. package/files-widget/file-tree.ts +224 -0
  106. package/files-widget/file-viewer.ts +93 -0
  107. package/files-widget/git.ts +107 -0
  108. package/files-widget/index.ts +140 -0
  109. package/files-widget/input-utils.ts +3 -0
  110. package/files-widget/package.json +22 -0
  111. package/files-widget/types.ts +28 -0
  112. package/files-widget/utils.ts +26 -0
  113. package/files-widget/viewer.ts +424 -0
  114. package/import-cc-codex/research/import-chats-from-other-agents.md +135 -0
  115. package/import-cc-codex/spec.md +79 -0
  116. package/package.json +29 -0
  117. package/ralph-wiggum/CHANGELOG.md +7 -0
  118. package/ralph-wiggum/README.md +96 -0
  119. package/ralph-wiggum/SKILL.md +73 -0
  120. package/ralph-wiggum/index.ts +792 -0
  121. package/ralph-wiggum/package.json +25 -0
  122. package/raw-paste/CHANGELOG.md +7 -0
  123. package/raw-paste/README.md +52 -0
  124. package/raw-paste/index.ts +112 -0
  125. package/raw-paste/package.json +22 -0
  126. package/tab-status/CHANGELOG.md +4 -0
  127. package/tab-status/README.md +61 -0
  128. package/tab-status/assets/tab-status.png +0 -0
  129. package/tab-status/package.json +22 -0
  130. package/tab-status/tab-status.ts +179 -0
  131. package/usage-extension/CHANGELOG.md +17 -0
  132. package/usage-extension/README.md +120 -0
  133. package/usage-extension/index.ts +628 -0
  134. package/usage-extension/package.json +22 -0
  135. package/usage-extension/screenshot.png +0 -0
@@ -0,0 +1,65 @@
1
+ # Code Actions extension
2
+
3
+ /code to pick code blocks (```) or `inline code` from recent assistant messages and then copy or insert them. Helpful for retrieving commands and filepaths mentioned by Pi.
4
+
5
+ /code opens a menu you can type to search. You can hit enter to copy the snippet, or `right arrow` to insert it in the command line.
6
+
7
+ <img width="751" height="416" alt="Screenshot 2026-01-09 at 17 09 17" src="https://github.com/user-attachments/assets/0dc10a64-d61f-4b56-9684-5e448c759385" />
8
+
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ pi install npm:@tmustier/pi-code-actions
14
+ ```
15
+
16
+ ```bash
17
+ pi install git:github.com/tmustier/pi-extensions
18
+ ```
19
+
20
+ Then filter to just this extension in `~/.pi/agent/settings.json`:
21
+
22
+ ```json
23
+ {
24
+ "packages": [
25
+ {
26
+ "source": "git:github.com/tmustier/pi-extensions",
27
+ "extensions": ["code-actions/index.ts"]
28
+ }
29
+ ]
30
+ }
31
+ ```
32
+
33
+ ## Usage
34
+
35
+ - Command: `/code`
36
+ - Optional args:
37
+ - `all` to scan all assistant messages in the current branch (default: all)
38
+ - `blocks` to hide inline snippets (default: inline + fenced blocks)
39
+ - `limit=50` to cap the number of snippets returned (default: 200)
40
+ - `copy`, `insert`, or `run` to choose an action up front
41
+ - a number to pick a specific snippet (1-based)
42
+
43
+ Examples:
44
+ - `/code`
45
+ - `/code blocks`
46
+ - `/code copy`
47
+ - `/code all`
48
+ - `/code limit=50`
49
+ - `/code run 2`
50
+
51
+ ## Actions
52
+
53
+ - Copy: puts the snippet on your clipboard
54
+ - Insert: inserts the snippet into the input editor
55
+ - Run: executes the snippet in your shell (asks for confirmation)
56
+
57
+ ## Notes
58
+
59
+ - Only assistant messages are scanned.
60
+ - Inline code uses single backticks. Code blocks use triple backticks.
61
+ - Inline snippets are filtered to path-like content: `~/...`, `./...`, paths with 2+ slashes, or files with extensions. Use `blocks` to show only code blocks.
62
+
63
+ ## Changelog
64
+
65
+ See `CHANGELOG.md`.
@@ -0,0 +1,107 @@
1
+ import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
2
+ import { Container, Text, matchesKey, truncateToWidth } from "@mariozechner/pi-tui";
3
+ import { DynamicBorder } from "@mariozechner/pi-coding-agent";
4
+ import * as fs from "node:fs";
5
+ import * as os from "node:os";
6
+ import * as path from "node:path";
7
+
8
+ export async function copyToClipboard(pi: ExtensionAPI, content: string): Promise<boolean> {
9
+ const tmpPath = path.join(os.tmpdir(), `pi-code-${Date.now()}.txt`);
10
+ fs.writeFileSync(tmpPath, content, "utf8");
11
+
12
+ const commands: Array<{ command: string; args: string[] }> = [];
13
+ if (process.platform === "darwin") {
14
+ commands.push({ command: "sh", args: ["-c", `cat "${tmpPath}" | pbcopy`] });
15
+ } else if (process.platform === "win32") {
16
+ commands.push({ command: "powershell", args: ["-NoProfile", "-Command", `Get-Content -Raw "${tmpPath}" | Set-Clipboard`] });
17
+ } else {
18
+ commands.push({ command: "sh", args: ["-c", `cat "${tmpPath}" | wl-copy`] });
19
+ commands.push({ command: "sh", args: ["-c", `cat "${tmpPath}" | xclip -selection clipboard`] });
20
+ commands.push({ command: "sh", args: ["-c", `cat "${tmpPath}" | xsel --clipboard --input`] });
21
+ }
22
+
23
+ let success = false;
24
+ for (const cmd of commands) {
25
+ try {
26
+ const result = await pi.exec(cmd.command, cmd.args);
27
+ if (result.code === 0) {
28
+ success = true;
29
+ break;
30
+ }
31
+ } catch {
32
+ // Try next command
33
+ }
34
+ }
35
+
36
+ try {
37
+ fs.unlinkSync(tmpPath);
38
+ } catch {
39
+ // Ignore cleanup errors
40
+ }
41
+
42
+ return success;
43
+ }
44
+
45
+ export function insertIntoEditor(ctx: ExtensionCommandContext, content: string): void {
46
+ const existing = ctx.ui.getEditorText();
47
+ const next = existing ? `${existing}\n${content}` : content;
48
+ ctx.ui.setEditorText(next);
49
+ }
50
+
51
+ function formatOutput(command: string, result: { stdout: string; stderr: string; code: number }): string {
52
+ const lines: string[] = [];
53
+ lines.push(`Command: ${command}`);
54
+ lines.push(`Exit code: ${result.code}`);
55
+
56
+ if (result.stdout.trim().length > 0) {
57
+ lines.push("");
58
+ lines.push("STDOUT:");
59
+ lines.push(result.stdout.trimEnd());
60
+ }
61
+
62
+ if (result.stderr.trim().length > 0) {
63
+ lines.push("");
64
+ lines.push("STDERR:");
65
+ lines.push(result.stderr.trimEnd());
66
+ }
67
+
68
+ return lines.join("\n");
69
+ }
70
+
71
+ function truncateLines(text: string, maxLines: number): string {
72
+ const lines = text.split(/\r?\n/);
73
+ if (lines.length <= maxLines) return text;
74
+ const truncated = lines.slice(0, maxLines).join("\n");
75
+ return `${truncated}\n\n[Output truncated to ${maxLines} lines]`;
76
+ }
77
+
78
+ export async function runSnippet(pi: ExtensionAPI, ctx: ExtensionCommandContext, snippet: string): Promise<void> {
79
+ const isWindows = process.platform === "win32";
80
+ const command = isWindows ? "powershell" : "bash";
81
+ const args = isWindows ? ["-NoProfile", "-Command", snippet] : ["-lc", snippet];
82
+
83
+ const result = await pi.exec(command, args, { cwd: ctx.cwd });
84
+ const output = truncateLines(formatOutput(`${command} ${args.join(" ")}`, result), 200);
85
+
86
+ await ctx.ui.custom<void>((tui, theme, _kb, done) => {
87
+ const container = new Container();
88
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
89
+ container.addChild(new Text(theme.fg("accent", theme.bold("Command Output")), 1, 0));
90
+
91
+ const text = new Text(output, 1, 0);
92
+ container.addChild(text);
93
+
94
+ container.addChild(new Text(theme.fg("dim", "Enter/Esc to close"), 1, 0));
95
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
96
+
97
+ return {
98
+ render: (width: number) => container.render(width).map((line) => truncateToWidth(line, width)),
99
+ invalidate: () => container.invalidate(),
100
+ handleInput: (data: string) => {
101
+ if (matchesKey(data, "escape") || matchesKey(data, "enter")) {
102
+ done();
103
+ }
104
+ },
105
+ };
106
+ });
107
+ }
@@ -0,0 +1,148 @@
1
+ import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
2
+ import { extractSnippets, extractText } from "./snippets";
3
+ import type { Snippet } from "./snippets";
4
+ import { pickAction, pickSnippet } from "./ui";
5
+ import type { PickResult } from "./ui";
6
+ import { copyToClipboard, insertIntoEditor, runSnippet } from "./actions";
7
+
8
+ type ParsedArgs = {
9
+ scope: "last" | "all";
10
+ action?: "copy" | "insert" | "run";
11
+ index?: number;
12
+ includeInline: boolean;
13
+ limit: number;
14
+ };
15
+
16
+ function parseArgs(args?: string): ParsedArgs {
17
+ const tokens = args?.trim().split(/\s+/).filter(Boolean) ?? [];
18
+ const parsed: ParsedArgs = { scope: "all", includeInline: true, limit: 200 };
19
+
20
+ for (const token of tokens) {
21
+ if (token === "all" || token === "last") {
22
+ parsed.scope = token;
23
+ continue;
24
+ }
25
+ if (token === "inline") {
26
+ parsed.includeInline = true;
27
+ continue;
28
+ }
29
+ if (token === "blocks") {
30
+ parsed.includeInline = false;
31
+ continue;
32
+ }
33
+ if (token === "copy" || token === "insert" || token === "run") {
34
+ parsed.action = token;
35
+ continue;
36
+ }
37
+ if (token.startsWith("limit=")) {
38
+ const value = Number.parseInt(token.slice("limit=".length), 10);
39
+ if (!Number.isNaN(value) && value > 0) parsed.limit = value;
40
+ continue;
41
+ }
42
+ if (/^\d+$/.test(token)) {
43
+ parsed.index = Math.max(0, Number.parseInt(token, 10) - 1);
44
+ }
45
+ }
46
+
47
+ return parsed;
48
+ }
49
+
50
+ function collectSnippets(
51
+ ctx: ExtensionCommandContext,
52
+ scope: "last" | "all",
53
+ includeInline: boolean,
54
+ limit: number,
55
+ ): Snippet[] {
56
+ const branchEntries = ctx.sessionManager.getBranch();
57
+ const assistantEntries = branchEntries.filter(
58
+ (entry) => entry.type === "message" && entry.message?.role === "assistant",
59
+ );
60
+
61
+ if (assistantEntries.length === 0) return [];
62
+
63
+ const sorted = assistantEntries.slice().sort((a, b) => {
64
+ const aTime = Date.parse(a.timestamp);
65
+ const bTime = Date.parse(b.timestamp);
66
+ return bTime - aTime;
67
+ });
68
+
69
+ const entriesToScan = scope === "all" ? sorted : [sorted[0]!];
70
+
71
+ let snippets: Snippet[] = [];
72
+ let nextId = 0;
73
+ for (const entry of entriesToScan) {
74
+ if (snippets.length >= limit) break;
75
+ const text = extractText(entry.message.content);
76
+ if (!text) continue;
77
+ const label = new Date(entry.timestamp).toLocaleTimeString();
78
+ const extracted = extractSnippets(text, entry.id, label, nextId, includeInline, limit - snippets.length);
79
+ snippets = snippets.concat(extracted);
80
+ nextId = snippets.length;
81
+ }
82
+
83
+ return snippets;
84
+ }
85
+
86
+ export default function codeActionsExtension(pi: ExtensionAPI) {
87
+ pi.registerCommand("code", {
88
+ description: "Pick code from assistant messages and copy/insert/run it",
89
+ handler: async (args, ctx) => {
90
+ if (!ctx.hasUI && (!args || args.trim().length === 0)) {
91
+ return;
92
+ }
93
+
94
+ const parsed = parseArgs(args);
95
+ const snippets = collectSnippets(ctx, parsed.scope, parsed.includeInline, parsed.limit);
96
+
97
+ if (snippets.length === 0) {
98
+ if (ctx.hasUI) ctx.ui.notify("No code snippets found. /code tracks code blocks and filepaths only.", "warning");
99
+ return;
100
+ }
101
+
102
+ let snippet: Snippet | undefined;
103
+ let pickedAction: PickResult["action"] | undefined;
104
+ if (parsed.index !== undefined) {
105
+ snippet = snippets[parsed.index];
106
+ if (!snippet) {
107
+ if (ctx.hasUI) ctx.ui.notify("Snippet index out of range.", "warning");
108
+ return;
109
+ }
110
+ } else {
111
+ if (!ctx.hasUI) return;
112
+ const result = await pickSnippet(ctx, snippets);
113
+ if (!result) return;
114
+ snippet = result.snippet;
115
+ pickedAction = result.action;
116
+ }
117
+
118
+ let action = parsed.action ?? pickedAction;
119
+ if (!action) {
120
+ if (!ctx.hasUI) return;
121
+ action = await pickAction(ctx);
122
+ }
123
+ if (!action) return;
124
+
125
+ if (action === "copy") {
126
+ const ok = await copyToClipboard(pi, snippet.content);
127
+ if (ctx.hasUI) {
128
+ ctx.ui.notify(ok ? "Copied to clipboard." : "Failed to copy to clipboard.", ok ? "info" : "error");
129
+ }
130
+ return;
131
+ }
132
+
133
+ if (action === "insert") {
134
+ if (!ctx.hasUI) return;
135
+ insertIntoEditor(ctx, snippet.content);
136
+ ctx.ui.notify("Inserted snippet into editor.", "info");
137
+ return;
138
+ }
139
+
140
+ if (action === "run") {
141
+ if (!ctx.hasUI) return;
142
+ const ok = await ctx.ui.confirm("Run snippet?", "This will execute the selected snippet in your shell.");
143
+ if (!ok) return;
144
+ await runSnippet(pi, ctx, snippet.content);
145
+ }
146
+ },
147
+ });
148
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "@tmustier/pi-code-actions",
3
+ "version": "0.1.0",
4
+ "description": "Pick code blocks or inline snippets from recent assistant messages to copy or insert.",
5
+ "license": "MIT",
6
+ "author": "Thomas Mustier",
7
+ "keywords": [
8
+ "pi-package"
9
+ ],
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/tmustier/pi-extensions.git",
13
+ "directory": "code-actions"
14
+ },
15
+ "bugs": "https://github.com/tmustier/pi-extensions/issues",
16
+ "homepage": "https://github.com/tmustier/pi-extensions/tree/main/code-actions",
17
+ "pi": {
18
+ "extensions": [
19
+ "index.ts"
20
+ ]
21
+ }
22
+ }
@@ -0,0 +1,79 @@
1
+ import type { SelectItem } from "@mariozechner/pi-tui";
2
+ import type { Snippet } from "./snippets";
3
+ import { getSnippetPreview } from "./snippets";
4
+
5
+ export type SearchIndexItem = {
6
+ item: SelectItem;
7
+ idx: number;
8
+ raw: string;
9
+ normalized: string;
10
+ };
11
+
12
+ export function normalizeForSearch(value: string): string {
13
+ return value
14
+ .toLowerCase()
15
+ .replace(/[^a-z0-9]+/g, " ")
16
+ .replace(/\s+/g, " ")
17
+ .trim();
18
+ }
19
+
20
+ export function buildSearchIndex(snippets: Snippet[], items: SelectItem[]): SearchIndexItem[] {
21
+ return snippets.map((snippet, idx) => {
22
+ const preview = getSnippetPreview(snippet);
23
+ const type = snippet.type;
24
+ const lang = snippet.language ?? "";
25
+ const raw = `${preview} ${type} ${lang} ${snippet.sourceLabel}`.toLowerCase();
26
+ return {
27
+ item: items[idx]!,
28
+ idx,
29
+ raw,
30
+ normalized: normalizeForSearch(raw),
31
+ };
32
+ });
33
+ }
34
+
35
+ export function rankedFilterItems(
36
+ filter: string,
37
+ items: SelectItem[],
38
+ searchIndex: SearchIndexItem[],
39
+ ): SelectItem[] {
40
+ const lower = filter.toLowerCase();
41
+ if (lower.length === 0) return items;
42
+
43
+ const norm = normalizeForSearch(lower);
44
+ const tokens = norm.length > 0 ? norm.split(" ") : [];
45
+ const scored: Array<{ item: SelectItem; idx: number; score: number }> = [];
46
+
47
+ for (const entry of searchIndex) {
48
+ let score = 0;
49
+
50
+ const rawIndex = entry.raw.indexOf(lower);
51
+ if (rawIndex !== -1) {
52
+ score = 1000 - rawIndex;
53
+ } else if (tokens.length > 0) {
54
+ let allMatch = true;
55
+ let firstPos = Number.MAX_SAFE_INTEGER;
56
+ for (const token of tokens) {
57
+ const pos = entry.normalized.indexOf(token);
58
+ if (pos === -1) {
59
+ allMatch = false;
60
+ break;
61
+ }
62
+ firstPos = Math.min(firstPos, pos);
63
+ }
64
+ if (!allMatch) continue;
65
+ score = 500 - firstPos;
66
+ } else {
67
+ continue;
68
+ }
69
+
70
+ scored.push({ item: entry.item, idx: entry.idx, score });
71
+ }
72
+
73
+ scored.sort((a, b) => {
74
+ if (b.score !== a.score) return b.score - a.score;
75
+ return a.idx - b.idx;
76
+ });
77
+
78
+ return scored.map((entry) => entry.item);
79
+ }
@@ -0,0 +1,179 @@
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 && typeof part === "object" && (part as { type?: string }).type === "text") {
62
+ const value = (part as { text?: string }).text;
63
+ if (value) text += value;
64
+ }
65
+ }
66
+
67
+ return text;
68
+ }
69
+
70
+ // ─────────────────────────────────────────────────────────────
71
+ // Filtering
72
+ // ─────────────────────────────────────────────────────────────
73
+
74
+ /** Check if content looks like a file path worth extracting */
75
+ function looksLikePath(content: string): boolean {
76
+ const slashCount = (content.match(/\//g) || []).length;
77
+
78
+ // ~/... or commands containing ~/ (e.g. "open ~/file.html")
79
+ if (/(?:^|[\s"'])~\//.test(content)) return true;
80
+
81
+ // ./... (current directory)
82
+ if (content.startsWith("./")) return true;
83
+
84
+ // Absolute paths: /foo/bar (2+ slashes) or /file.html (has extension)
85
+ if (content.startsWith("/")) {
86
+ return slashCount >= MIN_SLASH_COUNT || HAS_FILE_EXTENSION.test(content);
87
+ }
88
+
89
+ // Generic: foo/bar/baz requires 2+ slashes
90
+ return slashCount >= MIN_SLASH_COUNT;
91
+ }
92
+
93
+ function shouldIncludeInlineSnippet(content: string): boolean {
94
+ const trimmed = content.trim();
95
+
96
+ // Reject: empty, comments, known commands, short identifiers
97
+ if (trimmed.length === 0) return false;
98
+ if (trimmed.startsWith("//") || trimmed.startsWith("/*") || trimmed.startsWith("*")) return false;
99
+ if (IGNORED_COMMANDS.has(trimmed)) return false;
100
+ if (/^[A-Za-z0-9._-]{1,5}$/.test(trimmed)) return false;
101
+
102
+ return looksLikePath(trimmed);
103
+ }
104
+
105
+ // ─────────────────────────────────────────────────────────────
106
+ // Extraction
107
+ // ─────────────────────────────────────────────────────────────
108
+
109
+ export function extractSnippets(
110
+ text: string,
111
+ messageId: string,
112
+ sourceLabel: string,
113
+ startId: number,
114
+ includeInline: boolean,
115
+ limit: number,
116
+ ): Snippet[] {
117
+ const snippets: Snippet[] = [];
118
+ const fencedRanges: Array<{ start: number; end: number }> = [];
119
+ const fencedRegex = /```([^\n`]*)\n([\s\S]*?)```/g;
120
+ let match: RegExpExecArray | null;
121
+
122
+ while ((match = fencedRegex.exec(text))) {
123
+ if (snippets.length >= limit) return snippets;
124
+ const language = match[1]?.trim() || undefined;
125
+ const content = match[2]?.replace(/\n$/, "") ?? "";
126
+ snippets.push({
127
+ id: startId + snippets.length,
128
+ type: "block",
129
+ language,
130
+ content,
131
+ messageId,
132
+ sourceLabel,
133
+ });
134
+ fencedRanges.push({ start: match.index, end: match.index + match[0].length });
135
+ }
136
+
137
+ if (!includeInline) return snippets;
138
+
139
+ const inlineRegex = /`([^`\n]+)`/g;
140
+ while ((match = inlineRegex.exec(text))) {
141
+ if (snippets.length >= limit) return snippets;
142
+ const index = match.index;
143
+ const inFence = fencedRanges.some((range) => index >= range.start && index < range.end);
144
+ if (inFence) continue;
145
+
146
+ const content = match[1] ?? "";
147
+ if (!shouldIncludeInlineSnippet(content)) continue;
148
+
149
+ snippets.push({
150
+ id: startId + snippets.length,
151
+ type: "inline",
152
+ content,
153
+ messageId,
154
+ sourceLabel,
155
+ });
156
+ }
157
+
158
+ return snippets;
159
+ }
160
+
161
+ export function getSnippetPreview(snippet: Snippet): string {
162
+ const content = snippet.content.trim();
163
+ if (content.length === 0) return "(empty)";
164
+
165
+ if (snippet.type === "block") {
166
+ return content.replace(/\s+/g, " ");
167
+ }
168
+
169
+ const lines = content.split(/\r?\n/);
170
+ const firstNonEmpty = lines.find((line) => line.trim().length > 0) ?? lines[0] ?? "";
171
+ const preview = firstNonEmpty.trim();
172
+ return preview.length > 0 ? preview : "(empty)";
173
+ }
174
+
175
+ export function truncatePreview(value: string, width: number): string {
176
+ if (value.length <= width) return value;
177
+ if (width <= 1) return value.slice(0, width);
178
+ return `${value.slice(0, width - 1)}…`;
179
+ }