loom-agent 1.2.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.
- package/.env.example +25 -0
- package/CHANGELOG.md +402 -0
- package/LICENSE +21 -0
- package/LOOM.md +235 -0
- package/README.md +433 -0
- package/bin/loom-tui.js +43 -0
- package/bin/loom.js +44 -0
- package/docs/acp.md +151 -0
- package/docs/web.md +205 -0
- package/package.json +97 -0
- package/scripts/acp-smoke.js +146 -0
- package/src/acp/acp-server.js +287 -0
- package/src/config/provider-cmd.js +37 -0
- package/src/config/settings.js +164 -0
- package/src/core/agents.js +361 -0
- package/src/core/background-tasks.js +103 -0
- package/src/core/cli.js +579 -0
- package/src/core/custom-commands.js +70 -0
- package/src/core/errors.js +29 -0
- package/src/core/events.js +24 -0
- package/src/core/file-diffs.js +282 -0
- package/src/core/format.js +206 -0
- package/src/core/graph.js +257 -0
- package/src/core/hooks.js +82 -0
- package/src/core/lsp.js +385 -0
- package/src/core/memory.js +87 -0
- package/src/core/model-router.js +87 -0
- package/src/core/permissions.js +327 -0
- package/src/core/platform.js +33 -0
- package/src/core/plugin-cmd.js +380 -0
- package/src/core/restore.js +207 -0
- package/src/core/session-store.js +167 -0
- package/src/core/session.js +910 -0
- package/src/core/subagent-log.js +134 -0
- package/src/core/tokens.js +31 -0
- package/src/core/update.js +6 -0
- package/src/core/usage.js +166 -0
- package/src/index.js +41 -0
- package/src/mcp/mcp-client.js +201 -0
- package/src/mcp/mcp-manager.js +193 -0
- package/src/providers/anthropic.js +243 -0
- package/src/providers/google.js +29 -0
- package/src/providers/index.js +175 -0
- package/src/providers/local.js +27 -0
- package/src/providers/nvidia.js +85 -0
- package/src/providers/openai-compat.js +269 -0
- package/src/providers/openai.js +35 -0
- package/src/providers/openrouter.js +43 -0
- package/src/providers/registry.js +196 -0
- package/src/providers/tokenrouter.js +19 -0
- package/src/skills/skill-matcher.js +133 -0
- package/src/skills/skills-manager.js +213 -0
- package/src/tools/index.js +543 -0
- package/src/tui/App.tsx +1578 -0
- package/src/tui/components/BreadcrumbBar.tsx +34 -0
- package/src/tui/components/ChatArea.tsx +518 -0
- package/src/tui/components/InputBar.tsx +354 -0
- package/src/tui/components/MdText.tsx +105 -0
- package/src/tui/components/Modals.tsx +851 -0
- package/src/tui/components/PermissionPopup.tsx +264 -0
- package/src/tui/components/Sidebar.tsx +182 -0
- package/src/tui/components/SplashScreen.tsx +51 -0
- package/src/tui/components/SubagentPanel.tsx +217 -0
- package/src/tui/components/ToastOverlay.tsx +34 -0
- package/src/tui/keybinds.ts +318 -0
- package/src/tui/mcp-presets.ts +189 -0
- package/src/tui/md-render.ts +228 -0
- package/src/tui/store.ts +714 -0
- package/src/tui/suite-home.ts +20 -0
- package/src/tui/theme.ts +313 -0
- package/src/tui/themes.generated.ts +968 -0
- package/src/tui/tool-display.ts +176 -0
- package/src/tui/toolname.ts +60 -0
- package/src/tui/tui-config.ts +28 -0
- package/src/tui-open.tsx +51 -0
- package/src/web/attach.js +242 -0
- package/src/web/graph-view.html +262 -0
- package/src/web/index.html +824 -0
- package/src/web/web-server.js +470 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// Curated MCP presets — one-key installs for well-known servers.
|
|
2
|
+
//
|
|
3
|
+
// Presets are vendor-published packages, vendor docker images, or the vendors'
|
|
4
|
+
// documented remote MCP endpoints (run through the mcp-remote proxy, which
|
|
5
|
+
// handles OAuth). Secrets are collected as env vars and never embedded into
|
|
6
|
+
// the command line.
|
|
7
|
+
//
|
|
8
|
+
// Each preset: name, package, description, defaults (env placeholders the user
|
|
9
|
+
// must fill), optional prompts, and optional argsTail (non-secret args).
|
|
10
|
+
// `command` overrides the npx wrapper (e.g. docker-based servers).
|
|
11
|
+
|
|
12
|
+
function npxRun(pkg, args) {
|
|
13
|
+
const isWin = process.platform === "win32";
|
|
14
|
+
const args2 = ["-y", pkg].concat(args || []);
|
|
15
|
+
return isWin ? { command: "cmd", runArgs: ["/c", "npx"].concat(args2) } : { command: "npx", runArgs: args2 };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Dev-tool MCP servers (browsers, docs, search, monitoring, databases).
|
|
19
|
+
export const MCP_PRESETS = [
|
|
20
|
+
{
|
|
21
|
+
id: "playwright",
|
|
22
|
+
label: "Playwright MCP",
|
|
23
|
+
package: "@playwright/mcp",
|
|
24
|
+
description: "Drive a real browser: test, screenshot, debug pages",
|
|
25
|
+
args: [],
|
|
26
|
+
env: {},
|
|
27
|
+
prompts: [],
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
id: "github",
|
|
31
|
+
label: "GitHub MCP",
|
|
32
|
+
command: "docker",
|
|
33
|
+
description: "Official GitHub server: repos, issues, PRs, Actions (docker)",
|
|
34
|
+
args: ["run", "-i", "--rm", "-e", "GITHUB_PERSONAL_ACCESS_TOKEN", "ghcr.io/github/github-mcp-server"],
|
|
35
|
+
env: { GITHUB_PERSONAL_ACCESS_TOKEN: "" },
|
|
36
|
+
prompts: [
|
|
37
|
+
{ key: "GITHUB_PERSONAL_ACCESS_TOKEN", label: "GitHub PAT with repo scope (github.com/settings/tokens)", mask: true },
|
|
38
|
+
],
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
id: "context7",
|
|
42
|
+
label: "Context7 MCP",
|
|
43
|
+
package: "@upstash/context7-mcp",
|
|
44
|
+
description: "Live docs for 6000+ libraries — no stale API guesses",
|
|
45
|
+
args: [],
|
|
46
|
+
env: {},
|
|
47
|
+
prompts: [],
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
id: "sentry",
|
|
51
|
+
label: "Sentry MCP",
|
|
52
|
+
package: "@sentry/mcp-server",
|
|
53
|
+
description: "Pull issues, stack traces, and root-cause analysis",
|
|
54
|
+
args: ["--access-token", "$SENTRY_ACCESS_TOKEN"],
|
|
55
|
+
env: { SENTRY_ACCESS_TOKEN: "" },
|
|
56
|
+
prompts: [
|
|
57
|
+
{ key: "SENTRY_ACCESS_TOKEN", label: "Sentry auth token (sentry.io/settings/auth-tokens)", mask: true },
|
|
58
|
+
],
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
id: "figma",
|
|
62
|
+
label: "Figma MCP",
|
|
63
|
+
package: "figma-developer-mcp",
|
|
64
|
+
description: "Design context: nodes, layout, tokens, component refs",
|
|
65
|
+
args: [],
|
|
66
|
+
env: { FIGMA_API_KEY: "" },
|
|
67
|
+
prompts: [
|
|
68
|
+
{ key: "FIGMA_API_KEY", label: "Figma API key (figma.com/developers/api)", mask: true },
|
|
69
|
+
],
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
id: "exa",
|
|
73
|
+
label: "Exa Search MCP",
|
|
74
|
+
package: "exa-mcp-server",
|
|
75
|
+
description: "Web search + crawling via Exa",
|
|
76
|
+
args: [],
|
|
77
|
+
env: { EXA_API_KEY: "" },
|
|
78
|
+
prompts: [
|
|
79
|
+
{ key: "EXA_API_KEY", label: "Exa API key (dashboard.exa.ai)", mask: true },
|
|
80
|
+
],
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
id: "mongodb",
|
|
84
|
+
label: "MongoDB MCP",
|
|
85
|
+
package: "mongodb-mcp-server",
|
|
86
|
+
description: "Query MongoDB and manage Atlas (official server)",
|
|
87
|
+
args: [],
|
|
88
|
+
env: { MONGODB_CONNECTION_STRING: "" },
|
|
89
|
+
prompts: [
|
|
90
|
+
{ key: "MONGODB_CONNECTION_STRING", label: "Connection string (mongodb+srv://…)", mask: false },
|
|
91
|
+
],
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
id: "linear",
|
|
95
|
+
label: "Linear MCP",
|
|
96
|
+
package: "linear-mcp-server",
|
|
97
|
+
description: "Issues, cycles, and projects for Linear",
|
|
98
|
+
args: [],
|
|
99
|
+
env: { LINEAR_API_KEY: "" },
|
|
100
|
+
prompts: [
|
|
101
|
+
{ key: "LINEAR_API_KEY", label: "Linear API key (linear.app/settings/api)", mask: true },
|
|
102
|
+
],
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
id: "filesystem",
|
|
106
|
+
label: "Filesystem MCP",
|
|
107
|
+
package: "@modelcontextprotocol/server-filesystem",
|
|
108
|
+
description: "Scoped file access outside this project (official reference server)",
|
|
109
|
+
args: [],
|
|
110
|
+
env: {},
|
|
111
|
+
prompts: [],
|
|
112
|
+
optionalArgsPrompt: { flag: null, label: "Root path (optional, default: project dir) — Enter to skip" },
|
|
113
|
+
},
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
// Hosting / cloud-service connectors — platforms you "connect" your project to
|
|
117
|
+
// rather than dev-tool servers. Surfaced under /connectors, not /mcp.
|
|
118
|
+
export const CONNECTOR_PRESETS = [
|
|
119
|
+
{
|
|
120
|
+
id: "supabase",
|
|
121
|
+
label: "Supabase",
|
|
122
|
+
package: "@supabase/mcp-server-supabase",
|
|
123
|
+
description: "Query schema, run SQL, manage Supabase projects",
|
|
124
|
+
args: ["--access-token", "$SUPABASE_ACCESS_TOKEN"],
|
|
125
|
+
env: { SUPABASE_ACCESS_TOKEN: "" },
|
|
126
|
+
prompts: [
|
|
127
|
+
{ key: "SUPABASE_ACCESS_TOKEN", label: "Personal access token (supabase.com/dashboard/account/tokens)", mask: true },
|
|
128
|
+
],
|
|
129
|
+
optionalArgsPrompt: { flag: "--project-ref", label: "Project ref (optional, e.g. abcdefghijklmnop) — Enter to skip" },
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
id: "nextjs",
|
|
133
|
+
label: "Next.js",
|
|
134
|
+
package: "nextjs-mcp-server",
|
|
135
|
+
description: "Next.js project introspection (routes, pages, config)",
|
|
136
|
+
args: [],
|
|
137
|
+
env: {},
|
|
138
|
+
prompts: [],
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
id: "railway",
|
|
142
|
+
label: "Railway",
|
|
143
|
+
package: "@railway/mcp-server",
|
|
144
|
+
description: "Railway deploys, services, variables",
|
|
145
|
+
args: [],
|
|
146
|
+
env: { RAILWAY_API_TOKEN: "" },
|
|
147
|
+
prompts: [
|
|
148
|
+
{ key: "RAILWAY_API_TOKEN", label: "API token (railway.app/account/tokens)", mask: true },
|
|
149
|
+
],
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
id: "vercel",
|
|
153
|
+
label: "Vercel",
|
|
154
|
+
command: "npx",
|
|
155
|
+
args: ["-y", "mcp-remote", "https://mcp.vercel.com"],
|
|
156
|
+
description: "Vercel deployments and projects (remote MCP endpoint, OAuth)",
|
|
157
|
+
env: {},
|
|
158
|
+
prompts: [],
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
id: "netlify",
|
|
162
|
+
label: "Netlify",
|
|
163
|
+
command: "npx",
|
|
164
|
+
args: ["-y", "mcp-remote", "https://api.netlify.com/mcp"],
|
|
165
|
+
description: "Netlify sites, deploys, and environment vars (remote MCP endpoint, OAuth)",
|
|
166
|
+
env: {},
|
|
167
|
+
prompts: [],
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
id: "cloudflare",
|
|
171
|
+
label: "Cloudflare",
|
|
172
|
+
package: "@cloudflare/mcp-server-cloudflare",
|
|
173
|
+
description: "Workers, DNS, and account management",
|
|
174
|
+
args: [],
|
|
175
|
+
env: { CLOUDFLARE_API_TOKEN: "", CLOUDFLARE_ACCOUNT_ID: "" },
|
|
176
|
+
prompts: [
|
|
177
|
+
{ key: "CLOUDFLARE_API_TOKEN", label: "API token (dash.cloudflare.com/profile/api-tokens)", mask: true },
|
|
178
|
+
{ key: "CLOUDFLARE_ACCOUNT_ID", label: "Account ID (dash.cloudflare.com — right sidebar)", mask: false },
|
|
179
|
+
],
|
|
180
|
+
},
|
|
181
|
+
];
|
|
182
|
+
|
|
183
|
+
export function presetSpawn(preset, resolvedEnv) {
|
|
184
|
+
if (preset.command) {
|
|
185
|
+
return { command: preset.command, args: preset.args || [], env: Object.assign({}, preset.env, resolvedEnv || {}) };
|
|
186
|
+
}
|
|
187
|
+
const run = npxRun(preset.package, preset.args);
|
|
188
|
+
return { command: run.command, args: run.runArgs, env: Object.assign({}, preset.env, resolvedEnv || {}) };
|
|
189
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// md-render — the TUI's markdown renderer for assistant chat output.
|
|
2
|
+
//
|
|
3
|
+
// Block-level markdown needs a real parser to get right; line-level + inline
|
|
4
|
+
// markdown is predictable, cheap, and covers 99% of what the model prints in
|
|
5
|
+
// chat (bold, inline code, headers, bullets, quotes, rule lines). This keeps
|
|
6
|
+
// output styling predictable without dragging in the full MarkdownRenderable
|
|
7
|
+
// (which needs a syntax-style theme and handles streaming differently).
|
|
8
|
+
//
|
|
9
|
+
// Each logical line is rendered as a separate <text> element with styled
|
|
10
|
+
// <span>/<b>/<em>/<a> children so wrapping composes cleanly.
|
|
11
|
+
|
|
12
|
+
export type MdSpan =
|
|
13
|
+
| { text: string; bold?: false; italic?: false; code?: false; link?: undefined }
|
|
14
|
+
| { text: string; bold: true; italic?: boolean; code?: undefined; link?: undefined }
|
|
15
|
+
| { text: string; italic: true; bold?: boolean; code?: undefined; link?: undefined }
|
|
16
|
+
| { text: string; code: true; bold?: undefined; italic?: undefined; link?: undefined };
|
|
17
|
+
|
|
18
|
+
// Parse one line of markdown into styled spans. Order: code spans first
|
|
19
|
+
// (inside them everything is literal), then strong/em emphasis, then links.
|
|
20
|
+
function parseInline(line: string): { text: string; bold?: boolean; italic?: boolean; code?: boolean; link?: string }[] {
|
|
21
|
+
const tokens: { text: string; bold?: boolean; italic?: boolean; code?: boolean; link?: string }[] = [];
|
|
22
|
+
let i = 0;
|
|
23
|
+
let buf = "";
|
|
24
|
+
const flush = () => { if (buf) { tokens.push({ text: buf }); buf = ""; } };
|
|
25
|
+
while (i < line.length) {
|
|
26
|
+
// escaping: \* renders a literal *
|
|
27
|
+
if (line[i] === "\\" && i + 1 < line.length && /[\\*_`\[\]()#~-]/.test(line[i + 1])) {
|
|
28
|
+
buf += line[i + 1];
|
|
29
|
+
i += 2;
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
// inline code `code`
|
|
33
|
+
if (line[i] === "`") {
|
|
34
|
+
const close = line.indexOf("`", i + 1);
|
|
35
|
+
if (close > i) {
|
|
36
|
+
flush();
|
|
37
|
+
tokens.push({ text: line.slice(i + 1, close), code: true });
|
|
38
|
+
i = close + 1;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
// unmatched ` — literal
|
|
42
|
+
buf += line[i];
|
|
43
|
+
i++;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
// strong emphasis **bold** (greedy to the LAST closing ** permite nesting-ish)
|
|
47
|
+
if (line.startsWith("**", i)) {
|
|
48
|
+
const close = line.indexOf("**", i + 2);
|
|
49
|
+
if (close > i + 2) {
|
|
50
|
+
flush();
|
|
51
|
+
tokens.push({ text: line.slice(i + 2, close), bold: true });
|
|
52
|
+
i = close + 2;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// italic emphasis *italic* (next char must be non-space; closing * picked
|
|
57
|
+
// by skipping over "**" sequences so **bold** inside a paragraph is safe).
|
|
58
|
+
// Never treat a "**" opening as italic — the strong branch above owns those.
|
|
59
|
+
if (line[i] === "*" && line[i + 1] !== "*" && i + 1 < line.length && /\S/.test(line[i + 1])) {
|
|
60
|
+
let close = -1;
|
|
61
|
+
for (let j = i + 1; ; j = close + 1) {
|
|
62
|
+
close = line.indexOf("*", j);
|
|
63
|
+
if (close < 0) break;
|
|
64
|
+
if (line[close + 1] === "*") continue;
|
|
65
|
+
if (close > i + 1) break;
|
|
66
|
+
close = -1;
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
if (close > i + 1) {
|
|
70
|
+
flush();
|
|
71
|
+
tokens.push({ text: line.slice(i + 1, close), italic: true });
|
|
72
|
+
i = close + 1;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
// unmatched * — literal
|
|
76
|
+
buf += "*";
|
|
77
|
+
i++;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
// links [label](href)
|
|
81
|
+
if (line[i] === "[") {
|
|
82
|
+
const closeL = line.indexOf("](", i);
|
|
83
|
+
const closeP = closeL > i ? line.indexOf(")", closeL + 2) : -1;
|
|
84
|
+
if (closeL > i && closeP > closeL) {
|
|
85
|
+
flush();
|
|
86
|
+
const label = line.slice(i + 1, closeL);
|
|
87
|
+
const href = line.slice(closeL + 2, closeP);
|
|
88
|
+
tokens.push({ text: label + " (" + href + ")", link: href });
|
|
89
|
+
i = closeP + 1;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
// not a link — literal
|
|
93
|
+
buf += line[i];
|
|
94
|
+
i++;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
buf += line[i];
|
|
98
|
+
i++;
|
|
99
|
+
}
|
|
100
|
+
flush();
|
|
101
|
+
return tokens;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export type MdLine =
|
|
105
|
+
| { kind: "heading"; level: 1 | 2 | 3; spans: ReturnType<typeof parseInline> }
|
|
106
|
+
| { kind: "bullet"; indent: number; spans: ReturnType<typeof parseInline> }
|
|
107
|
+
| { kind: "quote"; spans: ReturnType<typeof parseInline> }
|
|
108
|
+
| { kind: "rule" }
|
|
109
|
+
| { kind: "text"; spans: ReturnType<typeof parseInline> }
|
|
110
|
+
| { kind: "code"; code: string; lang: string };
|
|
111
|
+
|
|
112
|
+
// Split markdown text into per-line blocks. Fenced ``` blocks keep verbatim
|
|
113
|
+
// (their contents are already "rendered" code; the ChatArea palette boxes them).
|
|
114
|
+
export function parseMarkdown(md: string): MdLine[] {
|
|
115
|
+
const out: MdLine[] = [];
|
|
116
|
+
const lines = String(md).split("\n");
|
|
117
|
+
let fence = false;
|
|
118
|
+
let fenceLang = "";
|
|
119
|
+
let codeBuf: string[] = [];
|
|
120
|
+
for (const raw of lines) {
|
|
121
|
+
const fenceM = raw.match(/^\s*```\s*([a-zA-Z0-9+-]*)?\s*$/);
|
|
122
|
+
if (fenceM) {
|
|
123
|
+
if (fence) {
|
|
124
|
+
out.push({ kind: "code", code: codeBuf.join("\n"), lang: fenceLang });
|
|
125
|
+
codeBuf = [];
|
|
126
|
+
fenceLang = "";
|
|
127
|
+
fence = false;
|
|
128
|
+
} else {
|
|
129
|
+
fence = true;
|
|
130
|
+
fenceLang = (fenceM[1] || "").toLowerCase();
|
|
131
|
+
}
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (fence) { codeBuf.push(raw); continue; }
|
|
135
|
+
|
|
136
|
+
if (/^\s*$/.test(raw)) { out.push({ kind: "text", spans: [] }); continue; }
|
|
137
|
+
|
|
138
|
+
// horizontal rule
|
|
139
|
+
if (/^\s*(-{3,}|_{3,}|\*{3,})\s*$/.test(raw)) { out.push({ kind: "rule" }); continue; }
|
|
140
|
+
|
|
141
|
+
// heading: # text / ## text / ### text
|
|
142
|
+
const hm = raw.match(/^(#{1,3})\s+(.*)$/);
|
|
143
|
+
if (hm) {
|
|
144
|
+
out.push({ kind: "heading", level: Math.min(3, hm[1].length) as 1 | 2 | 3, spans: parseInline(hm[2]) });
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// blockquote
|
|
149
|
+
const qm = raw.match(/^\s*>\s?(.*)$/);
|
|
150
|
+
if (qm) { out.push({ kind: "quote", spans: parseInline(qm[1]) }); continue; }
|
|
151
|
+
|
|
152
|
+
// list bullet: leading spaces + -/*/+ or 1. marker
|
|
153
|
+
const bm = raw.match(/^(\s*)([-*+]|\d+\.)\s+(.*)$/);
|
|
154
|
+
if (bm) {
|
|
155
|
+
out.push({ kind: "bullet", indent: Math.min(12, bm[1].length), spans: parseInline(bm[3]) });
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// plain paragraph line — merge inline styles
|
|
160
|
+
out.push({ kind: "text", spans: parseInline(raw) });
|
|
161
|
+
}
|
|
162
|
+
if (fence && codeBuf.length) out.push({ kind: "code", code: codeBuf.join("\n"), lang: fenceLang });
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ─── Lightweight code highlighting ───
|
|
167
|
+
// opencode-style palette mapping: keywords / strings / comments / numbers /
|
|
168
|
+
// calls get distinct colors instead of one flat white block. Regex-per-line
|
|
169
|
+
// tokenising is plenty for chat output (we never need parse trees).
|
|
170
|
+
export type CodeTok = { text: string; style: "kw" | "str" | "com" | "num" | "call" | "plain" };
|
|
171
|
+
|
|
172
|
+
const KW: Record<string, string[]> = {
|
|
173
|
+
js: ["const", "let", "var", "function", "return", "if", "else", "for", "while", "do", "switch", "case", "default", "break", "continue", "import", "export", "from", "class", "extends", "super", "new", "typeof", "instanceof", "try", "catch", "finally", "throw", "await", "async", "yield", "in", "of", "delete", "void", "null", "undefined", "true", "false", "this", "type", "interface", "enum", "implements", "public", "private", "protected", "static", "readonly", "namespace", "declare", "as", "satisfies"],
|
|
174
|
+
py: ["def", "return", "if", "elif", "else", "for", "while", "import", "from", "class", "try", "except", "finally", "with", "as", "lambda", "None", "True", "False", "and", "or", "not", "in", "is", "global", "nonlocal", "assert", "yield", "raise", "break", "continue", "pass", "del", "print", "range", "len"],
|
|
175
|
+
bash: ["echo", "if", "then", "else", "elif", "fi", "for", "while", "do", "done", "case", "esac", "function", "return", "exit", "export", "local", "source", "cd", "mkdir", "rm", "cp", "mv", "ls", "cat", "grep", "sed", "awk", "printf", "set"],
|
|
176
|
+
};
|
|
177
|
+
const KW_ALL = Array.from(new Set(KW.js.concat(KW.py, KW.bash)));
|
|
178
|
+
|
|
179
|
+
function langFamily(lang: string): string {
|
|
180
|
+
const l = (lang || "").toLowerCase();
|
|
181
|
+
if (/(^js$|jsx|ts|tsx|javascript|typescript|mjs|cjs)/.test(l)) return "js";
|
|
182
|
+
if (/(^py$|python|pyw)/.test(l)) return "py";
|
|
183
|
+
if (/(^sh$|bash|zsh|shell)/.test(l)) return "bash";
|
|
184
|
+
return "";
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function kwSetFor(lang: string): string[] {
|
|
188
|
+
const fam = langFamily(lang);
|
|
189
|
+
return fam && KW[fam] ? KW[fam] : KW_ALL;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// One regex line-scanner: comments → strings → numbers → keywords → calls.
|
|
193
|
+
// Anything else stays "plain". Cheap and predictable for chat-sized blocks.
|
|
194
|
+
function highlightLine(line: string, kws: string[]): CodeTok[] {
|
|
195
|
+
const out: CodeTok[] = [];
|
|
196
|
+
const isPyBash = kws.includes("lambda") || kws.includes("elif");
|
|
197
|
+
// Keep a strict group layout: 1=comment 2=string 3=number 4=keyword 5=call.
|
|
198
|
+
// The keyword alternation must NOT add inner captures or the later groups shift.
|
|
199
|
+
const kwAlt = "\\b(?:" + kws.join("|") + ")\\b";
|
|
200
|
+
const re = new RegExp(
|
|
201
|
+
"(" + (isPyBash ? "#.*$" : "\\/\\/.*$|\\/\\*.*") + ")" + // 1 comment
|
|
202
|
+
"|(" + "\"(?:[^\"\\\\\\n]|\\\\.)*\"|'(?:[^'\\\\\\n]|\\\\.)*'|`(?:[^`\\\\\\n]|\\\\.)*`" + ")" + // 2 string
|
|
203
|
+
"|(\\b\\d[\\d._xa-fA-F]*\\b)" + // 3 number
|
|
204
|
+
"|(" + kwAlt + ")" + // 4 keyword
|
|
205
|
+
"|([A-Za-z_$][\\w$]*(?=\\())", // 5 call
|
|
206
|
+
"g"
|
|
207
|
+
);
|
|
208
|
+
let last = 0;
|
|
209
|
+
let m: RegExpExecArray | null;
|
|
210
|
+
const pushPlain = (t: string) => { if (t) out.push({ text: t, style: "plain" }); };
|
|
211
|
+
while ((m = re.exec(line))) {
|
|
212
|
+
if (m.index > last) pushPlain(line.slice(last, m.index));
|
|
213
|
+
if (m[1]) out.push({ text: m[1], style: "com" });
|
|
214
|
+
else if (m[2]) out.push({ text: m[2], style: "str" });
|
|
215
|
+
else if (m[3]) out.push({ text: m[3], style: "num" });
|
|
216
|
+
else if (m[4]) out.push({ text: m[4], style: "kw" });
|
|
217
|
+
else if (m[5]) out.push({ text: m[5], style: "call" });
|
|
218
|
+
last = m.index + m[0].length;
|
|
219
|
+
if (m[0].length === 0) re.lastIndex++;
|
|
220
|
+
}
|
|
221
|
+
if (last < line.length) pushPlain(line.slice(last));
|
|
222
|
+
return out;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function highlightCode(code: string, lang: string): CodeTok[][] {
|
|
226
|
+
const kws = kwSetFor(lang);
|
|
227
|
+
return String(code).split("\n").map(l => highlightLine(l, kws));
|
|
228
|
+
}
|