@sean.holung/minicode 0.3.6 → 0.3.7

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 (32) hide show
  1. package/README.md +2 -1
  2. package/dist/scripts/run-benchmarks.js +1 -0
  3. package/dist/src/agent/config.js +2 -0
  4. package/dist/src/agent/editable-config.js +6 -0
  5. package/dist/src/serve/mcp-server.js +19 -13
  6. package/dist/src/serve/server.js +29 -0
  7. package/dist/src/shared/symbol-search.js +156 -0
  8. package/dist/src/tools/search-code-map.js +27 -35
  9. package/dist/src/web/app.js +103 -23
  10. package/dist/src/web/index.html +16 -0
  11. package/dist/src/web/style.css +48 -0
  12. package/dist/tests/config-api.test.js +5 -0
  13. package/dist/tests/config.test.js +9 -0
  14. package/dist/tests/graph-onboarding.test.js +12 -0
  15. package/dist/tests/mcp-and-plugin.test.js +3 -0
  16. package/dist/tests/search-code-map.test.js +9 -0
  17. package/dist/tests/serve.integration.test.js +26 -0
  18. package/dist/tests/system-prompt.test.js +1 -0
  19. package/dist/tests/test-utils.js +1 -0
  20. package/node_modules/@minicode/agent-sdk/dist/src/agent/types.d.ts +1 -0
  21. package/node_modules/@minicode/agent-sdk/dist/src/agent/types.d.ts.map +1 -1
  22. package/node_modules/@minicode/agent-sdk/dist/src/model/client.d.ts +8 -1
  23. package/node_modules/@minicode/agent-sdk/dist/src/model/client.d.ts.map +1 -1
  24. package/node_modules/@minicode/agent-sdk/dist/src/model/client.js +143 -27
  25. package/node_modules/@minicode/agent-sdk/dist/src/model/client.js.map +1 -1
  26. package/node_modules/@minicode/agent-sdk/dist/tests/model-client-openai.test.js +87 -0
  27. package/node_modules/@minicode/agent-sdk/dist/tests/model-client-openai.test.js.map +1 -1
  28. package/node_modules/@minicode/agent-sdk/dist/tests/test-utils.d.ts.map +1 -1
  29. package/node_modules/@minicode/agent-sdk/dist/tests/test-utils.js +1 -0
  30. package/node_modules/@minicode/agent-sdk/dist/tests/test-utils.js.map +1 -1
  31. package/node_modules/@minicode/agent-sdk/dist/tsconfig.tsbuildinfo +1 -1
  32. package/package.json +1 -1
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # minicode
2
2
 
3
- > Now supports connecting to [OpenRouter](https://openrouter.ai/) account via minicode UI. Sign in with OpenRouter account. Use for free with compatible free tier OpenRouter hosted models from MiniMax, Nvidia, Qwen, Google, etc.
3
+ > Now supports connecting to [OpenRouter](https://openrouter.ai/) account via minicode UI. Sign in with OpenRouter account. Use for free with compatible [free tier OpenRouter hosted models](https://openrouter.ai/models?q=free) from MiniMax, Nvidia, Qwen, Google, etc.
4
4
 
5
5
  A graph-native coding agent and code exploration environment built around structural context optimization that leverages symbol-aware retrieval, dependency graphs, and targeted context. It started as a way to make local models viable under tighter context budgets, and it now also works well with hosted frontier models through the same runtime, web UI, and OpenAI-compatible serve mode.
6
6
 
@@ -199,6 +199,7 @@ Nothing is written inside your workspace; config and cache live under `~/.minico
199
199
  | `COMMAND_DENYLIST` | No | none | Optional JSON array or comma-separated regex patterns appended to the built-in destructive-command denylist |
200
200
  | `MAX_STEPS` | No | `50` | Max agent loop iterations per user turn |
201
201
  | `MAX_TOKENS` | No | `4096` | Max model output tokens per model call |
202
+ | `MODEL_TIMEOUT_SECONDS` | No | `60` | Timeout waiting for a model API call to start responding before aborting and surfacing an error |
202
203
  | `MAX_CONTEXT_TOKENS` | No | `32000` | Approximate session history trimming target. For small models (e.g. 8k context), set lower (e.g. `6000`) to leave room for responses. |
203
204
  | `MAX_TOOL_OUTPUT_CHARS` | No | `8000` | Max chars per tool result before truncation. Set to `0` to disable. |
204
205
  | `WORKSPACE_ROOT` | No | current working directory | Root directory tools are allowed to access (set at runtime, not typically configured) |
@@ -99,6 +99,7 @@ export function buildConfig(options = {}) {
99
99
  model,
100
100
  maxSteps: getNumberSetting(getShellOverride("MAX_STEPS"), fileConfig.maxSteps, 50),
101
101
  maxTokens: getNumberSetting(getShellOverride("MAX_TOKENS"), fileConfig.maxTokens, 4096),
102
+ modelTimeoutSeconds: getNumberSetting(getShellOverride("MODEL_TIMEOUT_SECONDS"), fileConfig.modelTimeoutSeconds, 60),
102
103
  maxContextTokens: getNumberSetting(getShellOverride("MAX_CONTEXT_TOKENS"), fileConfig.maxContextTokens, 32000),
103
104
  workspaceRoot: repoRoot,
104
105
  commandTimeoutMs: getNumberSetting(getShellOverride("COMMAND_TIMEOUT_MS"), fileConfig.commandTimeoutMs, 30000),
@@ -16,6 +16,7 @@ export function formatConfigForDisplay(config) {
16
16
  "model: " + config.model,
17
17
  "maxSteps: " + config.maxSteps,
18
18
  "maxTokens: " + config.maxTokens,
19
+ "modelTimeoutSeconds: " + config.modelTimeoutSeconds,
19
20
  "maxContextTokens: " + config.maxContextTokens,
20
21
  "commandTimeoutMs: " + config.commandTimeoutMs,
21
22
  "maxFileSizeBytes: " + config.maxFileSizeBytes,
@@ -230,6 +231,7 @@ export async function loadAgentConfig(cwd = process.cwd(), options = {}) {
230
231
  model: env.MODEL ?? "",
231
232
  maxSteps: parseNumber(env.MAX_STEPS, 50),
232
233
  maxTokens: parseNumber(env.MAX_TOKENS, 4096),
234
+ modelTimeoutSeconds: parseNumber(env.MODEL_TIMEOUT_SECONDS, 60),
233
235
  maxContextTokens: parseNumber(env.MAX_CONTEXT_TOKENS, 32_000),
234
236
  workspaceRoot,
235
237
  commandTimeoutMs: parseNumber(env.COMMAND_TIMEOUT_MS, 30_000),
@@ -34,6 +34,12 @@ export const EDITABLE_CONFIG_DEFINITIONS = [
34
34
  type: "number",
35
35
  description: "Maximum completion tokens per model response",
36
36
  },
37
+ {
38
+ key: "modelTimeoutSeconds",
39
+ envVar: "MODEL_TIMEOUT_SECONDS",
40
+ type: "number",
41
+ description: "Maximum time to wait for a model API call to start responding",
42
+ },
37
43
  {
38
44
  key: "maxContextTokens",
39
45
  envVar: "MAX_CONTEXT_TOKENS",
@@ -11,6 +11,7 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
11
11
  import { z } from "zod";
12
12
  import { getSymbolDisplayName } from "../indexer/symbol-names.js";
13
13
  import { formatAmbiguousSymbolMatches, resolveSymbolInput, } from "../shared/symbol-resolution.js";
14
+ import { searchSymbols } from "../shared/symbol-search.js";
14
15
  /** Active transports keyed by session ID. */
15
16
  const transports = new Map();
16
17
  /**
@@ -157,22 +158,27 @@ function createMcpServer(bridge, emit) {
157
158
  kind: z.string().optional().describe("Filter by kind: function, class, interface, type, variable, method"),
158
159
  }, async ({ query, kind }) => {
159
160
  return wrapToolCall("search_code_map", { query, kind }, async () => {
160
- const symbols = bridge.getSymbols();
161
- const queryLower = query.toLowerCase();
162
- let matches = symbols.filter((s) => {
163
- const nameMatch = s.name.toLowerCase().includes(queryLower) ||
164
- s.qualifiedName.toLowerCase().includes(queryLower);
165
- return nameMatch;
166
- });
167
- if (kind) {
168
- matches = matches.filter((s) => s.kind === kind);
169
- }
170
- matches = matches.slice(0, 30);
171
- if (matches.length === 0) {
161
+ const result = searchSymbols(bridge.getSymbols().map((symbol) => ({
162
+ symbol,
163
+ record: {
164
+ name: symbol.name,
165
+ qualifiedName: symbol.qualifiedName,
166
+ kind: symbol.kind,
167
+ filePath: symbol.filePath,
168
+ startLine: symbol.startLine,
169
+ exported: symbol.exported,
170
+ },
171
+ lookupNames: [symbol.name, symbol.qualifiedName],
172
+ })), query, { kind, limit: 30 });
173
+ if (result.total === 0) {
172
174
  return { content: [{ type: "text", text: `No symbols matching "${query}"${kind ? ` (kind: ${kind})` : ""}.` }] };
173
175
  }
176
+ const matches = result.matches;
177
+ const heading = result.mode === "similar"
178
+ ? `No exact substring matches for "${query}"${kind ? ` (kind: ${kind})` : ""}. Showing ${matches.length} similar symbol(s):`
179
+ : `Found ${matches.length} symbol(s) matching "${query}":`;
174
180
  const lines = [
175
- `Found ${matches.length} symbol(s) matching "${query}":`,
181
+ heading,
176
182
  ...matches.map((s) => ` - ${s.name} (${s.kind}) — ${s.filePath}:${s.startLine} — qualified: ${s.qualifiedName}${s.signature ? `\n ${s.signature}` : ""}`),
177
183
  ];
178
184
  return { content: [{ type: "text", text: lines.join("\n") }] };
@@ -30,6 +30,19 @@ function sendJson(res, status, body) {
30
30
  res.writeHead(status, { "Content-Type": "application/json" });
31
31
  res.end(JSON.stringify(body));
32
32
  }
33
+ function resolveWorkspaceFilePath(workspaceRoot, requestedPath) {
34
+ const trimmedPath = requestedPath.trim();
35
+ if (trimmedPath.length === 0) {
36
+ return null;
37
+ }
38
+ const root = path.resolve(workspaceRoot);
39
+ const absolutePath = path.resolve(root, trimmedPath);
40
+ const relativePath = path.relative(root, absolutePath);
41
+ if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
42
+ return null;
43
+ }
44
+ return { absolutePath, relativePath };
45
+ }
33
46
  function readBody(req) {
34
47
  return new Promise((resolve, reject) => {
35
48
  const chunks = [];
@@ -423,6 +436,22 @@ export function createRequestHandler(bridge, emit, options = {}) {
423
436
  }
424
437
  return;
425
438
  }
439
+ if (pathname === "/api/file-source" && method === "GET") {
440
+ const requestedPath = url.searchParams.get("path") ?? "";
441
+ const resolved = resolveWorkspaceFilePath(config.workspaceRoot, requestedPath);
442
+ if (!resolved) {
443
+ sendJson(res, 403, { error: "Invalid workspace file path" });
444
+ return;
445
+ }
446
+ try {
447
+ const source = await readFile(resolved.absolutePath, "utf8");
448
+ sendJson(res, 200, { filePath: resolved.relativePath, source });
449
+ }
450
+ catch {
451
+ sendJson(res, 404, { error: `Could not read file: ${resolved.relativePath}` });
452
+ }
453
+ return;
454
+ }
426
455
  if (pathname === "/api/code-map" && method === "GET") {
427
456
  const budgetParam = url.searchParams.get("budget");
428
457
  const budget = budgetParam ? Number(budgetParam) : undefined;
@@ -0,0 +1,156 @@
1
+ const MIN_SIMILARITY_QUERY_LENGTH = 3;
2
+ const MIN_SIMILARITY_SCORE = 0.62;
3
+ function normalizeSearchText(value) {
4
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "");
5
+ }
6
+ function tokenizeSearchText(value) {
7
+ return value
8
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
9
+ .toLowerCase()
10
+ .split(/[^a-z0-9]+/)
11
+ .filter(Boolean);
12
+ }
13
+ function levenshteinDistance(a, b) {
14
+ if (a === b) {
15
+ return 0;
16
+ }
17
+ if (a.length === 0) {
18
+ return b.length;
19
+ }
20
+ if (b.length === 0) {
21
+ return a.length;
22
+ }
23
+ const previous = new Array(b.length + 1);
24
+ const current = new Array(b.length + 1);
25
+ for (let j = 0; j <= b.length; j += 1) {
26
+ previous[j] = j;
27
+ }
28
+ for (let i = 1; i <= a.length; i += 1) {
29
+ current[0] = i;
30
+ for (let j = 1; j <= b.length; j += 1) {
31
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
32
+ current[j] = Math.min(current[j - 1] + 1, previous[j] + 1, previous[j - 1] + cost);
33
+ }
34
+ for (let j = 0; j <= b.length; j += 1) {
35
+ previous[j] = current[j];
36
+ }
37
+ }
38
+ return previous[b.length];
39
+ }
40
+ function diceCoefficient(a, b) {
41
+ if (a === b) {
42
+ return 1;
43
+ }
44
+ if (a.length < 2 || b.length < 2) {
45
+ return 0;
46
+ }
47
+ const bigrams = new Map();
48
+ for (let i = 0; i < a.length - 1; i += 1) {
49
+ const bigram = a.slice(i, i + 2);
50
+ bigrams.set(bigram, (bigrams.get(bigram) ?? 0) + 1);
51
+ }
52
+ let matches = 0;
53
+ for (let i = 0; i < b.length - 1; i += 1) {
54
+ const bigram = b.slice(i, i + 2);
55
+ const count = bigrams.get(bigram) ?? 0;
56
+ if (count > 0) {
57
+ bigrams.set(bigram, count - 1);
58
+ matches += 1;
59
+ }
60
+ }
61
+ return (2 * matches) / ((a.length - 1) + (b.length - 1));
62
+ }
63
+ function computeSimilarityScore(pattern, candidate) {
64
+ const normalizedPattern = normalizeSearchText(pattern);
65
+ const normalizedCandidate = normalizeSearchText(candidate);
66
+ if (!normalizedPattern || !normalizedCandidate) {
67
+ return 0;
68
+ }
69
+ if (normalizedPattern === normalizedCandidate) {
70
+ return 1;
71
+ }
72
+ const maxLength = Math.max(normalizedPattern.length, normalizedCandidate.length, 1);
73
+ const lengthPenalty = Math.abs(normalizedCandidate.length - normalizedPattern.length) / maxLength;
74
+ let bestScore = 0;
75
+ if (normalizedCandidate.startsWith(normalizedPattern) ||
76
+ normalizedPattern.startsWith(normalizedCandidate)) {
77
+ bestScore = Math.max(bestScore, 0.93 - (lengthPenalty * 0.2));
78
+ }
79
+ const tokens = tokenizeSearchText(candidate);
80
+ if (tokens.some((token) => token.startsWith(normalizedPattern) || normalizedPattern.startsWith(token))) {
81
+ bestScore = Math.max(bestScore, 0.88 - (lengthPenalty * 0.15));
82
+ }
83
+ const editDistance = levenshteinDistance(normalizedPattern, normalizedCandidate);
84
+ const maxDistance = Math.max(1, Math.ceil(normalizedPattern.length * 0.34));
85
+ if (editDistance <= maxDistance) {
86
+ const editSimilarity = 1 - (editDistance / maxLength);
87
+ bestScore = Math.max(bestScore, 0.62 + (editSimilarity * 0.3));
88
+ }
89
+ const dice = diceCoefficient(normalizedPattern, normalizedCandidate);
90
+ if (dice >= 0.5) {
91
+ bestScore = Math.max(bestScore, 0.45 + (dice * 0.35));
92
+ }
93
+ return bestScore;
94
+ }
95
+ function compareSubstringCandidates(pattern, a, b) {
96
+ const lowerPattern = pattern.toLowerCase();
97
+ const aExact = Number(a.lookupNames.some((value) => value.toLowerCase() === lowerPattern));
98
+ const bExact = Number(b.lookupNames.some((value) => value.toLowerCase() === lowerPattern));
99
+ if (aExact !== bExact) {
100
+ return bExact - aExact;
101
+ }
102
+ return Number(b.record.exported ?? false) - Number(a.record.exported ?? false) ||
103
+ a.record.name.localeCompare(b.record.name) ||
104
+ a.record.filePath.localeCompare(b.record.filePath) ||
105
+ a.record.startLine - b.record.startLine ||
106
+ a.record.qualifiedName.localeCompare(b.record.qualifiedName);
107
+ }
108
+ function compareSimilarCandidates(a, b) {
109
+ return b.score - a.score ||
110
+ Number(b.candidate.record.exported ?? false) - Number(a.candidate.record.exported ?? false) ||
111
+ a.candidate.record.name.localeCompare(b.candidate.record.name) ||
112
+ a.candidate.record.filePath.localeCompare(b.candidate.record.filePath) ||
113
+ a.candidate.record.startLine - b.candidate.record.startLine ||
114
+ a.candidate.record.qualifiedName.localeCompare(b.candidate.record.qualifiedName);
115
+ }
116
+ export function searchSymbols(candidates, pattern, options = {}) {
117
+ const lowerPattern = pattern.toLowerCase();
118
+ const normalizedKind = options.kind?.trim().toLowerCase();
119
+ const skip = Math.max(0, options.skip ?? 0);
120
+ const limit = Math.max(1, options.limit ?? 30);
121
+ const filtered = candidates.filter((candidate) => {
122
+ if (normalizedKind && candidate.record.kind.toLowerCase() !== normalizedKind) {
123
+ return false;
124
+ }
125
+ return true;
126
+ });
127
+ const substringMatches = filtered
128
+ .filter((candidate) => candidate.lookupNames.some((value) => value.toLowerCase().includes(lowerPattern)))
129
+ .sort((a, b) => compareSubstringCandidates(pattern, a, b));
130
+ if (substringMatches.length > 0) {
131
+ return {
132
+ matches: substringMatches.slice(skip, skip + limit).map((candidate) => candidate.symbol),
133
+ mode: "substring",
134
+ total: substringMatches.length,
135
+ };
136
+ }
137
+ const normalizedPattern = normalizeSearchText(pattern);
138
+ if (normalizedPattern.length < MIN_SIMILARITY_QUERY_LENGTH) {
139
+ return { matches: [], mode: "none", total: 0 };
140
+ }
141
+ const similarMatches = filtered
142
+ .map((candidate) => ({
143
+ candidate,
144
+ score: Math.max(...candidate.lookupNames.map((value) => computeSimilarityScore(pattern, value))),
145
+ }))
146
+ .filter((entry) => entry.score >= MIN_SIMILARITY_SCORE)
147
+ .sort(compareSimilarCandidates);
148
+ if (similarMatches.length === 0) {
149
+ return { matches: [], mode: "none", total: 0 };
150
+ }
151
+ return {
152
+ matches: similarMatches.slice(skip, skip + limit).map((entry) => entry.candidate.symbol),
153
+ mode: "similar",
154
+ total: similarMatches.length,
155
+ };
156
+ }
@@ -1,26 +1,7 @@
1
1
  import { expectNonEmptyString, expectOptionalNumber } from "@minicode/agent-sdk";
2
2
  import { getSymbolDisplayName, getSymbolLookupNames } from "../indexer/symbol-names.js";
3
+ import { searchSymbols } from "../shared/symbol-search.js";
3
4
  const DEFAULT_LIMIT = 30;
4
- function compareMatchedSymbols(pattern, a, b) {
5
- const lowerPattern = pattern.toLowerCase();
6
- const aDisplay = getSymbolDisplayName(a).toLowerCase();
7
- const bDisplay = getSymbolDisplayName(b).toLowerCase();
8
- const aExact = Number(aDisplay === lowerPattern || a.qualifiedName.toLowerCase() === lowerPattern || a.name.toLowerCase() === lowerPattern);
9
- const bExact = Number(bDisplay === lowerPattern || b.qualifiedName.toLowerCase() === lowerPattern || b.name.toLowerCase() === lowerPattern);
10
- if (aExact !== bExact) {
11
- return bExact - aExact;
12
- }
13
- return Number(b.exported) - Number(a.exported) ||
14
- aDisplay.localeCompare(bDisplay) ||
15
- a.filePath.localeCompare(b.filePath) ||
16
- a.startLine - b.startLine ||
17
- a.qualifiedName.localeCompare(b.qualifiedName);
18
- }
19
- function matchesPattern(text, pattern) {
20
- const lowerText = text.toLowerCase();
21
- const lowerPattern = pattern.toLowerCase();
22
- return lowerText.includes(lowerPattern);
23
- }
24
5
  export function createSearchCodeMapTool(projectIndex) {
25
6
  return {
26
7
  name: "search_code_map",
@@ -58,28 +39,39 @@ export function createSearchCodeMapTool(projectIndex) {
58
39
  : undefined;
59
40
  const limit = Math.max(1, Math.min(100, expectOptionalNumber(input, "limit") ?? DEFAULT_LIMIT));
60
41
  const skip = Math.max(0, expectOptionalNumber(input, "skip") ?? 0);
61
- const symbols = [...projectIndex.symbols.values()];
62
- const matches = symbols.filter((sym) => {
63
- const lookupNames = getSymbolLookupNames(sym);
64
- if (!lookupNames.some((candidate) => matchesPattern(candidate, pattern))) {
65
- return false;
66
- }
67
- if (kind && sym.kind !== kind) {
68
- return false;
69
- }
70
- return true;
71
- }).sort((a, b) => compareMatchedSymbols(pattern, a, b));
72
- const shown = matches.slice(skip, skip + limit);
42
+ const result = searchSymbols([...projectIndex.symbols.values()].map((sym) => ({
43
+ symbol: sym,
44
+ record: {
45
+ name: getSymbolDisplayName(sym),
46
+ qualifiedName: sym.qualifiedName,
47
+ kind: sym.kind,
48
+ filePath: sym.filePath,
49
+ startLine: sym.startLine,
50
+ exported: sym.exported,
51
+ },
52
+ lookupNames: getSymbolLookupNames(sym),
53
+ })), pattern, { kind, limit, skip });
54
+ const shown = result.matches;
73
55
  const lines = shown.map((s) => `- ${getSymbolDisplayName(s)} (${s.kind}) — ${s.filePath}:${s.startLine} — qualified: ${s.qualifiedName}`);
74
- const remaining = matches.length - skip - shown.length;
56
+ const remaining = result.total - skip - shown.length;
75
57
  const footer = remaining > 0
76
58
  ? `\n... and ${remaining} more (use skip: ${skip + limit}, limit: ${limit} for next page)`
77
59
  : "";
78
- if (matches.length === 0) {
60
+ if (result.total === 0) {
79
61
  return `No symbols matching "${pattern}"${kind ? ` (kind: ${kind})` : ""}. Try a shorter or different pattern.`;
80
62
  }
63
+ if (result.mode === "similar") {
64
+ return [
65
+ `# No exact substring matches for "${pattern}"${kind ? ` (kind: ${kind})` : ""}`,
66
+ "",
67
+ `Showing similar symbols instead (${result.total} total):`,
68
+ "",
69
+ ...lines,
70
+ footer,
71
+ ].join("\n");
72
+ }
81
73
  return [
82
- `# Symbols matching "${pattern}" (${matches.length} total)`,
74
+ `# Symbols matching "${pattern}" (${result.total} total)`,
83
75
  "",
84
76
  ...lines,
85
77
  footer,
@@ -2,6 +2,22 @@ var __defProp = Object.defineProperty;
2
2
  var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
3
3
  var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
4
4
 
5
+ // src/web/modal-state.ts
6
+ function syncBodyModalOpenState() {
7
+ const anyModalOpen = [...document.querySelectorAll(".modal")].some((modal) => !modal.classList.contains("hidden"));
8
+ document.body.classList.toggle("modal-open", anyModalOpen);
9
+ }
10
+ function openModal(modal) {
11
+ modal.classList.remove("hidden");
12
+ modal.setAttribute("aria-hidden", "false");
13
+ syncBodyModalOpenState();
14
+ }
15
+ function closeModal(modal) {
16
+ modal.classList.add("hidden");
17
+ modal.setAttribute("aria-hidden", "true");
18
+ syncBodyModalOpenState();
19
+ }
20
+
5
21
  // node_modules/marked/lib/marked.esm.js
6
22
  function M() {
7
23
  return { async: false, breaks: false, extensions: null, gfm: true, hooks: null, pedantic: false, renderer: null, silent: false, tokenizer: null, walkTokens: null };
@@ -1660,6 +1676,8 @@ var analysisReport = null;
1660
1676
  var activeAnalysisFindingId = null;
1661
1677
  var activeAnalysisFilter = "all";
1662
1678
  var analysisExplanationCache = /* @__PURE__ */ new Map();
1679
+ var filePreviewModalInitialized = false;
1680
+ var latestFilePreviewRequestId = 0;
1663
1681
  var LAYOUT_OPTIONS = {
1664
1682
  name: "cose",
1665
1683
  nodeRepulsion: function() {
@@ -1685,6 +1703,7 @@ async function initGraph() {
1685
1703
  initialized = true;
1686
1704
  const cyEl = document.getElementById("cy");
1687
1705
  const detailEl = document.getElementById("symbol-detail");
1706
+ setupFilePreviewModal();
1688
1707
  try {
1689
1708
  const [graphRes, symbolsRes, focusRes] = await Promise.all([
1690
1709
  fetch("/api/graph"),
@@ -1823,6 +1842,78 @@ function focusFileInGraph(filePath) {
1823
1842
  refreshAnalysisGraphState();
1824
1843
  runLayout();
1825
1844
  }
1845
+ function getFilePreviewLanguage(filePath) {
1846
+ const ext = filePath.split(".").pop()?.toLowerCase() || "";
1847
+ const langMap = {
1848
+ ts: "typescript",
1849
+ tsx: "typescript",
1850
+ js: "javascript",
1851
+ jsx: "javascript",
1852
+ json: "json",
1853
+ md: "markdown",
1854
+ css: "css",
1855
+ html: "xml"
1856
+ };
1857
+ return langMap[ext] || "plaintext";
1858
+ }
1859
+ function closeFilePreview() {
1860
+ const modal = document.getElementById("file-preview-modal");
1861
+ if (!modal) return;
1862
+ closeModal(modal);
1863
+ }
1864
+ function setupFilePreviewModal() {
1865
+ if (filePreviewModalInitialized) return;
1866
+ filePreviewModalInitialized = true;
1867
+ const modal = document.getElementById("file-preview-modal");
1868
+ const backdrop = document.getElementById("file-preview-backdrop");
1869
+ const closeBtn = document.getElementById("file-preview-close");
1870
+ if (!modal || !backdrop || !closeBtn) {
1871
+ return;
1872
+ }
1873
+ backdrop.addEventListener("click", () => closeFilePreview());
1874
+ closeBtn.addEventListener("click", () => closeFilePreview());
1875
+ document.addEventListener("keydown", (event) => {
1876
+ if (event.key === "Escape" && !modal.classList.contains("hidden")) {
1877
+ closeFilePreview();
1878
+ }
1879
+ });
1880
+ }
1881
+ async function openFilePreview(filePath) {
1882
+ const modal = document.getElementById("file-preview-modal");
1883
+ const pathEl = document.getElementById("file-preview-path");
1884
+ const codeEl = document.getElementById("file-preview-code");
1885
+ if (!modal || !pathEl || !codeEl) {
1886
+ return;
1887
+ }
1888
+ latestFilePreviewRequestId += 1;
1889
+ const requestId = latestFilePreviewRequestId;
1890
+ pathEl.textContent = filePath;
1891
+ codeEl.className = "file-preview-code";
1892
+ codeEl.textContent = "Loading...";
1893
+ openModal(modal);
1894
+ try {
1895
+ const res = await fetch(`/api/file-source?path=${encodeURIComponent(filePath)}`);
1896
+ if (!res.ok) {
1897
+ codeEl.textContent = "(file unavailable)";
1898
+ return;
1899
+ }
1900
+ const data = await res.json();
1901
+ if (requestId !== latestFilePreviewRequestId) {
1902
+ return;
1903
+ }
1904
+ pathEl.textContent = data.filePath;
1905
+ codeEl.className = `file-preview-code language-${getFilePreviewLanguage(data.filePath)}`;
1906
+ codeEl.textContent = data.source;
1907
+ if (typeof hljs !== "undefined") {
1908
+ hljs.highlightElement(codeEl);
1909
+ }
1910
+ } catch {
1911
+ if (requestId !== latestFilePreviewRequestId) {
1912
+ return;
1913
+ }
1914
+ codeEl.textContent = "(file unavailable)";
1915
+ }
1916
+ }
1826
1917
  async function focusSymbolInGraph(symbolId, options = {}) {
1827
1918
  await focusSymbolsInGraph([symbolId], options);
1828
1919
  }
@@ -2349,7 +2440,7 @@ async function showDetail(node, detailEl) {
2349
2440
  <span class="detail-name">${escapeHtml(data.label)}</span>
2350
2441
  <span class="detail-kind-badge" style="background:${kindColor}20;color:${kindColor}">${kind}</span>
2351
2442
  </div>
2352
- <div class="detail-file">${escapeHtml(data.file || "unknown")}${data.startLine ? ":" + data.startLine : ""}</div>
2443
+ <div class="detail-file">${data.file ? `<button type="button" class="detail-file-link" data-file="${escapeHtml(data.file)}">${escapeHtml(data.file)}${data.startLine ? ":" + data.startLine : ""}</button>` : "unknown"}</div>
2353
2444
  `;
2354
2445
  html += `<div class="detail-actions">`;
2355
2446
  html += `<button class="detail-pin header-btn" data-name="${escapeHtml(data.qualifiedName)}">${isPinned ? "Unpin" : "Pin to focus"}</button>`;
@@ -2391,6 +2482,12 @@ async function showDetail(node, detailEl) {
2391
2482
  const name = pinBtn.dataset.name || "";
2392
2483
  await togglePin(name, node, pinBtn);
2393
2484
  });
2485
+ const fileLink = detailEl.querySelector(".detail-file-link");
2486
+ fileLink?.addEventListener("click", () => {
2487
+ const filePath = fileLink.dataset.file || "";
2488
+ if (!filePath) return;
2489
+ void openFilePreview(filePath);
2490
+ });
2394
2491
  const explainBtn = detailEl.querySelector(".detail-explain-btn");
2395
2492
  explainBtn.addEventListener("click", () => {
2396
2493
  const name = explainBtn.dataset.name || "";
@@ -2599,6 +2696,7 @@ function setupToolbar() {
2599
2696
  dropdown.classList.add("hidden");
2600
2697
  if (type === "file") {
2601
2698
  focusFileInGraph(id);
2699
+ void openFilePreview(id);
2602
2700
  return;
2603
2701
  }
2604
2702
  void focusSymbolInGraph(id, {
@@ -3170,16 +3268,6 @@ function closeHeaderMenus() {
3170
3268
  modelDropdown.classList.add("hidden");
3171
3269
  sessionDropdown.classList.add("hidden");
3172
3270
  }
3173
- function isSettingsModalOpen() {
3174
- return !settingsModal.classList.contains("hidden");
3175
- }
3176
- function isOpenRouterConnectModalOpen() {
3177
- return !openRouterConnectModal.classList.contains("hidden");
3178
- }
3179
- function syncModalOpenState() {
3180
- const anyModalOpen = isSettingsModalOpen() || isOpenRouterConnectModalOpen();
3181
- document.body.classList.toggle("modal-open", anyModalOpen);
3182
- }
3183
3271
  function formatSettingsValue(value) {
3184
3272
  return value === null ? "(unset)" : String(value);
3185
3273
  }
@@ -3394,30 +3482,22 @@ function updateSettingsActions() {
3394
3482
  }
3395
3483
  function openSettings() {
3396
3484
  closeHeaderMenus();
3397
- settingsModal.classList.remove("hidden");
3398
- settingsModal.setAttribute("aria-hidden", "false");
3399
- syncModalOpenState();
3485
+ openModal(settingsModal);
3400
3486
  void loadSettings();
3401
3487
  }
3402
3488
  function closeSettings() {
3403
- settingsModal.classList.add("hidden");
3404
- settingsModal.setAttribute("aria-hidden", "true");
3405
- syncModalOpenState();
3489
+ closeModal(settingsModal);
3406
3490
  clearSettingsBanner();
3407
3491
  }
3408
3492
  function openOpenRouterConnectModal() {
3409
3493
  closeHeaderMenus();
3410
- openRouterConnectModal.classList.remove("hidden");
3411
- openRouterConnectModal.setAttribute("aria-hidden", "false");
3494
+ openModal(openRouterConnectModal);
3412
3495
  openRouterPersistCheckbox.checked = false;
3413
3496
  openRouterConnectContinueBtn.disabled = false;
3414
- syncModalOpenState();
3415
3497
  }
3416
3498
  function closeOpenRouterConnectModal() {
3417
- openRouterConnectModal.classList.add("hidden");
3418
- openRouterConnectModal.setAttribute("aria-hidden", "true");
3499
+ closeModal(openRouterConnectModal);
3419
3500
  openRouterConnectContinueBtn.disabled = false;
3420
- syncModalOpenState();
3421
3501
  }
3422
3502
  chatForm.addEventListener("submit", (e) => {
3423
3503
  e.preventDefault();
@@ -235,6 +235,22 @@ MODEL=your-model-name</pre>
235
235
  </section>
236
236
  </div>
237
237
 
238
+ <div id="file-preview-modal" class="modal hidden" aria-hidden="true">
239
+ <div id="file-preview-backdrop" class="modal-backdrop"></div>
240
+ <section class="modal-panel modal-panel-file-preview" role="dialog" aria-modal="true" aria-labelledby="file-preview-title">
241
+ <div class="modal-header">
242
+ <div>
243
+ <h2 id="file-preview-title">File preview</h2>
244
+ <p id="file-preview-path" class="modal-subtitle">Loading…</p>
245
+ </div>
246
+ <button id="file-preview-close" class="header-btn" type="button">Close</button>
247
+ </div>
248
+ <div class="file-preview-body">
249
+ <pre id="file-preview-code" class="file-preview-code">Loading...</pre>
250
+ </div>
251
+ </section>
252
+ </div>
253
+
238
254
  <script type="module" src="app.js"></script>
239
255
  </body>
240
256
  </html>