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.
Files changed (79) hide show
  1. package/.env.example +25 -0
  2. package/CHANGELOG.md +402 -0
  3. package/LICENSE +21 -0
  4. package/LOOM.md +235 -0
  5. package/README.md +433 -0
  6. package/bin/loom-tui.js +43 -0
  7. package/bin/loom.js +44 -0
  8. package/docs/acp.md +151 -0
  9. package/docs/web.md +205 -0
  10. package/package.json +97 -0
  11. package/scripts/acp-smoke.js +146 -0
  12. package/src/acp/acp-server.js +287 -0
  13. package/src/config/provider-cmd.js +37 -0
  14. package/src/config/settings.js +164 -0
  15. package/src/core/agents.js +361 -0
  16. package/src/core/background-tasks.js +103 -0
  17. package/src/core/cli.js +579 -0
  18. package/src/core/custom-commands.js +70 -0
  19. package/src/core/errors.js +29 -0
  20. package/src/core/events.js +24 -0
  21. package/src/core/file-diffs.js +282 -0
  22. package/src/core/format.js +206 -0
  23. package/src/core/graph.js +257 -0
  24. package/src/core/hooks.js +82 -0
  25. package/src/core/lsp.js +385 -0
  26. package/src/core/memory.js +87 -0
  27. package/src/core/model-router.js +87 -0
  28. package/src/core/permissions.js +327 -0
  29. package/src/core/platform.js +33 -0
  30. package/src/core/plugin-cmd.js +380 -0
  31. package/src/core/restore.js +207 -0
  32. package/src/core/session-store.js +167 -0
  33. package/src/core/session.js +910 -0
  34. package/src/core/subagent-log.js +134 -0
  35. package/src/core/tokens.js +31 -0
  36. package/src/core/update.js +6 -0
  37. package/src/core/usage.js +166 -0
  38. package/src/index.js +41 -0
  39. package/src/mcp/mcp-client.js +201 -0
  40. package/src/mcp/mcp-manager.js +193 -0
  41. package/src/providers/anthropic.js +243 -0
  42. package/src/providers/google.js +29 -0
  43. package/src/providers/index.js +175 -0
  44. package/src/providers/local.js +27 -0
  45. package/src/providers/nvidia.js +85 -0
  46. package/src/providers/openai-compat.js +269 -0
  47. package/src/providers/openai.js +35 -0
  48. package/src/providers/openrouter.js +43 -0
  49. package/src/providers/registry.js +196 -0
  50. package/src/providers/tokenrouter.js +19 -0
  51. package/src/skills/skill-matcher.js +133 -0
  52. package/src/skills/skills-manager.js +213 -0
  53. package/src/tools/index.js +543 -0
  54. package/src/tui/App.tsx +1578 -0
  55. package/src/tui/components/BreadcrumbBar.tsx +34 -0
  56. package/src/tui/components/ChatArea.tsx +518 -0
  57. package/src/tui/components/InputBar.tsx +354 -0
  58. package/src/tui/components/MdText.tsx +105 -0
  59. package/src/tui/components/Modals.tsx +851 -0
  60. package/src/tui/components/PermissionPopup.tsx +264 -0
  61. package/src/tui/components/Sidebar.tsx +182 -0
  62. package/src/tui/components/SplashScreen.tsx +51 -0
  63. package/src/tui/components/SubagentPanel.tsx +217 -0
  64. package/src/tui/components/ToastOverlay.tsx +34 -0
  65. package/src/tui/keybinds.ts +318 -0
  66. package/src/tui/mcp-presets.ts +189 -0
  67. package/src/tui/md-render.ts +228 -0
  68. package/src/tui/store.ts +714 -0
  69. package/src/tui/suite-home.ts +20 -0
  70. package/src/tui/theme.ts +313 -0
  71. package/src/tui/themes.generated.ts +968 -0
  72. package/src/tui/tool-display.ts +176 -0
  73. package/src/tui/toolname.ts +60 -0
  74. package/src/tui/tui-config.ts +28 -0
  75. package/src/tui-open.tsx +51 -0
  76. package/src/web/attach.js +242 -0
  77. package/src/web/graph-view.html +262 -0
  78. package/src/web/index.html +824 -0
  79. 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
+ }