loom-agent 1.2.33 → 1.2.37

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 (39) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/README.md +14 -30
  3. package/bin/loom-bun.js +95 -67
  4. package/bin/loom-tui.js +21 -1
  5. package/bin/loom.js +0 -10
  6. package/package.json +14 -9
  7. package/scripts/postinstall.js +57 -5
  8. package/src/connectors/convex.js +356 -0
  9. package/src/connectors/deploy.js +390 -0
  10. package/src/connectors/index.js +23 -0
  11. package/src/connectors/supabase.js +279 -0
  12. package/src/core/agents.js +32 -2
  13. package/src/core/cli.js +56 -7
  14. package/src/core/permissions.js +12 -1
  15. package/src/core/plugin-cmd.js +1 -0
  16. package/src/core/session.js +87 -6
  17. package/src/mcp/mcp-client.js +42 -2
  18. package/src/skills/skills-manager.js +25 -2
  19. package/src/tools/browser.js +204 -0
  20. package/src/tools/index.js +116 -5
  21. package/src/tui/App.tsx +19 -4
  22. package/src/tui/components/BreadcrumbBar.tsx +10 -9
  23. package/src/tui/components/ChatArea.tsx +3 -3
  24. package/src/tui/components/InputBar.tsx +22 -11
  25. package/src/tui/components/MdText.tsx +2 -1
  26. package/src/tui/components/Modals.tsx +31 -17
  27. package/src/tui/components/PermissionPopup.tsx +15 -12
  28. package/src/tui/components/Sidebar.tsx +6 -2
  29. package/src/tui/components/SplashScreen.tsx +17 -24
  30. package/src/tui/components/SubagentPanel.tsx +1 -1
  31. package/src/tui/components/ToastOverlay.tsx +23 -12
  32. package/src/tui/store.ts +4 -2
  33. package/src/tui/theme.ts +45 -30
  34. package/src/tui/tool-display.ts +13 -2
  35. package/docs/web.md +0 -205
  36. package/src/web/attach.js +0 -242
  37. package/src/web/graph-view.html +0 -262
  38. package/src/web/index.html +0 -824
  39. package/src/web/web-server.js +0 -470
@@ -0,0 +1,204 @@
1
+ // Headless browser control for the Web agent (and any build-mode session).
2
+ // Wraps Playwright behind a single-page singleton: the first action launches
3
+ // a headless Chromium and every later action reuses it, so "open a page, click
4
+ // around, read the DOM" works across tool calls without re-launching.
5
+ //
6
+ // Engine resolution order:
7
+ // 1. `playwright` (bundled chromium download, if the user installed it)
8
+ // 2. `playwright-core` + a system Chrome / Edge channel (zero download)
9
+ // Everything runs in the page context only — no filesystem access beyond the
10
+ // screenshot path the caller passes (enforced by the tool-layer sandbox for
11
+ // writes is intentionally NOT applied here: screenshots land in the OS temp
12
+ // dir by default and the tool returns the absolute path).
13
+ const fs = require('fs');
14
+ const os = require('os');
15
+ const path = require('path');
16
+
17
+ const INSTALL_HINT = 'Install a browser engine: npm i playwright then npx playwright install chromium (or install playwright-core and have Chrome/Edge available)';
18
+ const ACTION_TIMEOUT_MS = 30000;
19
+
20
+ /** @type {{ browser: any, page: any }|null} */
21
+ let _engine = null;
22
+ let _playwright = null; // null = not tried, false = unavailable, else module
23
+
24
+ const ACTIONS = ['goto', 'click', 'fill', 'press', 'text', 'eval', 'links', 'screenshot', 'close'];
25
+
26
+ function loadPlaywright() {
27
+ if (_playwright !== null) return _playwright || null;
28
+ for (const mod of ['playwright', 'playwright-core']) {
29
+ try { _playwright = require(mod); return _playwright; } catch {}
30
+ }
31
+ _playwright = false;
32
+ return null;
33
+ }
34
+
35
+ function withTimeout(promise, ms) {
36
+ let timer;
37
+ const timeout = new Promise((_, reject) => {
38
+ timer = setTimeout(() => reject(new Error('browser action timed out after ' + ms + 'ms')), ms);
39
+ });
40
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
41
+ }
42
+
43
+ function checkUrl(url) {
44
+ const u = String(url || '').trim();
45
+ if (!u) return { error: 'goto needs a url.' };
46
+ let parsed;
47
+ try { parsed = new URL(u); } catch { return { error: 'Invalid url: ' + u }; }
48
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
49
+ return { error: 'Only http/https urls are allowed (got ' + parsed.protocol + ').' };
50
+ }
51
+ return { url: parsed.href };
52
+ }
53
+
54
+ async function pageSnapshot(page) {
55
+ const title = await page.title().catch(() => '');
56
+ return 'URL: ' + page.url() + (title ? '\nTitle: ' + title : '');
57
+ }
58
+
59
+ /** Launch (or reuse) the headless browser. Returns the page or {error}. */
60
+ async function ensurePage() {
61
+ if (_engine && _engine.page && !_engine.page.isClosed()) return _engine.page;
62
+ const pw = loadPlaywright();
63
+ if (!pw) {
64
+ return { error: 'No browser engine found. ' + INSTALL_HINT };
65
+ }
66
+ const attempts = [
67
+ () => pw.chromium.launch({ headless: true }),
68
+ () => pw.chromium.launch({ headless: true, channel: 'chrome' }),
69
+ () => pw.chromium.launch({ headless: true, channel: 'msedge' }),
70
+ ];
71
+ let lastErr = null;
72
+ for (const attempt of attempts) {
73
+ try {
74
+ const browser = await withTimeout(attempt(), 20000);
75
+ const context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
76
+ const page = await context.newPage();
77
+ _engine = { browser, page };
78
+ return page;
79
+ } catch (err) { lastErr = err; }
80
+ }
81
+ return { error: 'Failed to launch a browser: ' + (lastErr && lastErr.message) + '. ' + INSTALL_HINT };
82
+ }
83
+
84
+ /** Close the singleton browser (also used as the `close` action). */
85
+ async function closeBrowser() {
86
+ if (!_engine) return 'No browser is running.';
87
+ try { await _engine.browser.close(); } catch {}
88
+ _engine = null;
89
+ return 'Browser closed.';
90
+ }
91
+
92
+ /**
93
+ * Execute one browser action. `params` comes straight from the tool call:
94
+ * action, url, selector, text, key, script, path
95
+ * @param {Record<string, any>} params
96
+ * @returns {Promise<string|{error: string}>}
97
+ */
98
+ async function run(params) {
99
+ const action = String(params.action || '').toLowerCase().trim();
100
+ if (!ACTIONS.includes(action)) {
101
+ return { error: 'Unknown action "' + action + '". Use one of: ' + ACTIONS.join(' | ') };
102
+ }
103
+ if (action === 'close') return closeBrowser();
104
+
105
+ // Validate inputs BEFORE launching a browser so bad calls fail fast and
106
+ // never spawn a headless process.
107
+ if (action === 'goto') {
108
+ const u = checkUrl(params.url);
109
+ if (u.error) return u;
110
+ }
111
+ if ((action === 'click' || action === 'fill') && !String(params.selector || '').trim()) {
112
+ return { error: action + ' needs a CSS selector.' };
113
+ }
114
+ if (action === 'press' && !String(params.key || '').trim()) {
115
+ return { error: 'press needs a key (e.g. Enter, Tab, Escape, Control+A).' };
116
+ }
117
+ if (action === 'eval' && !String(params.script || '').trim()) {
118
+ return { error: 'eval needs a JavaScript expression (runs in the page context).' };
119
+ }
120
+
121
+ const page = await ensurePage();
122
+ if (page && typeof page === 'object' && 'error' in page) return page;
123
+
124
+ try {
125
+ switch (action) {
126
+ case 'goto': {
127
+ const u = checkUrl(params.url);
128
+ if (u.error) return u;
129
+ await withTimeout(page.goto(u.url, { waitUntil: 'domcontentloaded', timeout: ACTION_TIMEOUT_MS }), ACTION_TIMEOUT_MS + 2000);
130
+ await page.waitForLoadState('load', { timeout: 10000 }).catch(() => {});
131
+ return 'Opened ' + (await pageSnapshot(page));
132
+ }
133
+ case 'click': {
134
+ const sel = String(params.selector || '').trim();
135
+ if (!sel) return { error: 'click needs a CSS selector.' };
136
+ await withTimeout(page.click(sel, { timeout: 10000 }), 12000);
137
+ await page.waitForLoadState('domcontentloaded', { timeout: 5000 }).catch(() => {});
138
+ return 'Clicked "' + sel + '".\n' + (await pageSnapshot(page));
139
+ }
140
+ case 'fill': {
141
+ const sel = String(params.selector || '').trim();
142
+ if (!sel) return { error: 'fill needs a CSS selector.' };
143
+ await withTimeout(page.fill(sel, String(params.text ?? ''), { timeout: 10000 }), 12000);
144
+ return 'Filled "' + sel + '" with ' + String(params.text ?? '').length + ' chars.';
145
+ }
146
+ case 'press': {
147
+ const key = String(params.key || '').trim();
148
+ if (!key) return { error: 'press needs a key (e.g. Enter, Tab, Escape, Control+A).' };
149
+ await page.keyboard.press(key);
150
+ await page.waitForLoadState('domcontentloaded', { timeout: 5000 }).catch(() => {});
151
+ return 'Pressed ' + key + '.\n' + (await pageSnapshot(page));
152
+ }
153
+ case 'text': {
154
+ const body = await withTimeout(page.innerText('body').catch(() => ''), 10000);
155
+ const text = String(body || '').replace(/\n{3,}/g, '\n\n').trim();
156
+ const head = await pageSnapshot(page);
157
+ return head + '\n\n' + (text ? text.slice(0, 10000) : '(page has no visible text — try action="eval" or action="links")');
158
+ }
159
+ case 'eval': {
160
+ const script = String(params.script || '').trim();
161
+ if (!script) return { error: 'eval needs a JavaScript expression (runs in the page context).' };
162
+ // Function-body form keeps multi-statement scripts working.
163
+ const result = await withTimeout(page.evaluate((src) => {
164
+ try { return JSON.stringify({ ok: true, value: new Function('return (' + src + ')')() }); }
165
+ catch (e) { return JSON.stringify({ ok: false, error: String(e && e.message || e) }); }
166
+ }, script), 10000);
167
+ const parsed = JSON.parse(String(result));
168
+ if (!parsed.ok) return { error: 'eval failed: ' + parsed.error };
169
+ const out = typeof parsed.value === 'string' ? parsed.value : JSON.stringify(parsed.value, null, 2);
170
+ return String(out == null ? 'null' : out).slice(0, 10000);
171
+ }
172
+ case 'links': {
173
+ const links = await withTimeout(
174
+ page.$$eval('a[href]', (els) => els.slice(0, 100).map((a) => ({
175
+ href: a.getAttribute('href') || '',
176
+ text: (a.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 120),
177
+ }))),
178
+ 10000,
179
+ );
180
+ if (!links.length) return (await pageSnapshot(page)) + '\n\n(no links found)';
181
+ return links.map((l, i) => (i + 1) + '. ' + l.text + ' — ' + l.href).join('\n').slice(0, 10000);
182
+ }
183
+ case 'screenshot': {
184
+ const dest = params.path
185
+ ? path.resolve(String(params.path))
186
+ : path.join(os.tmpdir(), 'loom-browser-' + Date.now() + '.png');
187
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
188
+ await withTimeout(page.screenshot({ path: dest, fullPage: false }), 15000);
189
+ return 'Screenshot saved: ' + dest + '\n' + (await pageSnapshot(page));
190
+ }
191
+ default:
192
+ return { error: 'Unknown action "' + action + '".' };
193
+ }
194
+ } catch (err) {
195
+ return { error: 'browser ' + action + ' failed: ' + (err && err.message || err) };
196
+ }
197
+ }
198
+
199
+ // Test/diagnostic hook — true when a browser page is live.
200
+ function isRunning() {
201
+ return !!( _engine && _engine.page && !_engine.page.isClosed() );
202
+ }
203
+
204
+ module.exports = { run, closeBrowser, isRunning, ACTIONS };
@@ -5,6 +5,7 @@ const { execSync, spawn } = require('child_process');
5
5
  const { glob: globLib } = require('glob');
6
6
  const { commandRiskLabel } = require('../core/permissions');
7
7
  const { loadConfig } = require('../config/settings');
8
+ const dnsPromises = require('dns').promises;
8
9
 
9
10
  const cwd = process.cwd();
10
11
 
@@ -15,6 +16,74 @@ function globIgnore(full) {
15
16
  return ['node_modules', '.git'].map((n) => path.posix.join(base, '**', n, '**'));
16
17
  }
17
18
 
19
+ // ---------------------------------------------------------------------------
20
+ // SSRF guard (webfetch)
21
+ //
22
+ // The agent model decides which URLs webfetch opens, and prompt-injected page
23
+ // content can steer it toward "check http://169.254.169.254/latest/meta-data"
24
+ // or "read localhost:5984/_config". Those must fail closed:
25
+ // - only http/https, never file:/ftp:/data: transport tricks
26
+ // - loopback, link-local (cloud metadata!), RFC1918 private ranges, CGNAT,
27
+ // IPv6 ULA/link-local and IPv4-mapped addresses are refused
28
+ // - hostnames are resolved BEFORE connecting so a DNS name pointing into
29
+ // internal space is caught (checking the literal hostname is not enough)
30
+ // - redirects are followed manually and every hop re-checked, because a
31
+ // public URL can bounce straight at an internal one
32
+ // ---------------------------------------------------------------------------
33
+ /**
34
+ * True when an IPv4/IPv6 address string falls in a range the agent must never
35
+ * fetch (loopback, link-local/metadata, private, CGNAT, malformed).
36
+ * @param {string} ip
37
+ * @returns {boolean}
38
+ */
39
+ function isPrivateAddress(ip) {
40
+ const s = String(ip || '').toLowerCase();
41
+ // IPv6 handling first: exact forms and prefix families we care about.
42
+ if (s.includes(':')) {
43
+ let bare = s.replace(/^\[|\]$/g, '');
44
+ if (bare === '::' || bare === '::1') return true;
45
+ const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/.exec(bare);
46
+ if (mapped) return isPrivateAddress(mapped[1]);
47
+ if (/^f[cd][0-9a-f]{2}:/.test(bare)) return true; // fc00::/7 ULA
48
+ if (/^fe[89ab][0-9a-f]:/.test(bare)) return true; // fe80::/10 link-local
49
+ if (/^2001:db8:/.test(bare)) return true; // documentation
50
+ return false;
51
+ }
52
+ const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(s);
53
+ if (!m) return false;
54
+ const o = m.slice(1).map(Number);
55
+ if (o.some((x) => x > 255)) return true; // malformed → refuse
56
+ const [a, b] = o;
57
+ return (
58
+ a === 0 || // this-network
59
+ a === 10 || // RFC1918
60
+ a === 127 || // loopback
61
+ (a === 100 && b >= 64 && b <= 127) || // CGNAT 100.64/10
62
+ (a === 169 && b === 254) || // link-local / metadata
63
+ (a === 172 && b >= 16 && b <= 31) || // RFC1918
64
+ (a === 192 && b === 168) // RFC1918
65
+ );
66
+ }
67
+
68
+ /**
69
+ * Resolve a URL's host and report whether every resolved address is public.
70
+ * Unresolvable hosts fail closed (false).
71
+ * @param {URL} u
72
+ * @returns {Promise<boolean>}
73
+ */
74
+ async function urlIsPublic(u) {
75
+ const host = u.hostname.toLowerCase().replace(/^\[|\]$/g, '');
76
+ if (!host || host === 'localhost' || host.endsWith('.localhost')) return false;
77
+ if (isPrivateAddress(host)) return false;
78
+ let addrs;
79
+ try {
80
+ addrs = /** @type {{address: string}[]} */ (await dnsPromises.lookup(host, { all: true }));
81
+ } catch {
82
+ return false;
83
+ }
84
+ return addrs.length > 0 && addrs.every((a) => !isPrivateAddress(a.address));
85
+ }
86
+
18
87
  const MODES = ['build', 'plan', 'chat'];
19
88
 
20
89
  // Tools that never mutate the filesystem/state — safe to expose in plan mode.
@@ -251,10 +320,36 @@ const TOOLS = {
251
320
  url: { type: 'string', required: true, description: 'URL to fetch' },
252
321
  },
253
322
  async execute(params) {
323
+ let u;
324
+ try {
325
+ u = new URL(String(params.url || ''));
326
+ } catch {
327
+ return { error: 'Invalid URL' };
328
+ }
329
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') {
330
+ return { error: 'Blocked by webfetch policy: only http/https URLs are allowed' };
331
+ }
254
332
  try {
255
- const resp = await fetch(params.url, { signal: AbortSignal.timeout(15000) });
256
- const text = await resp.text();
257
- return text.slice(0, 10000);
333
+ // Follow redirects manually so every hop passes the SSRF guard — a
334
+ // public URL may legitimately bounce into an internal address.
335
+ let current = u;
336
+ const signal = AbortSignal.timeout(15000);
337
+ for (let hop = 0; hop < 5; hop++) {
338
+ if (!(await urlIsPublic(current))) {
339
+ return { error: `Blocked by webfetch policy: ${current.hostname} resolves to a private/reserved address (SSRF guard)` };
340
+ }
341
+ const resp = await fetch(current.toString(), { redirect: 'manual', signal });
342
+ if ([301, 302, 303, 307, 308].includes(resp.status)) {
343
+ const loc = resp.headers.get('location');
344
+ try { resp.body && resp.body.cancel(); } catch {}
345
+ if (!loc) return { error: `Fetch failed: redirect ${resp.status} without Location header` };
346
+ current = new URL(loc, current);
347
+ continue;
348
+ }
349
+ const text = await resp.text();
350
+ return text.slice(0, 10000);
351
+ }
352
+ return { error: 'Fetch failed: too many redirects' };
258
353
  } catch (e) {
259
354
  return { error: `Fetch failed: ${e.message}` };
260
355
  }
@@ -345,7 +440,7 @@ const TOOLS = {
345
440
  },
346
441
  task: {
347
442
  name: 'task',
348
- description: 'Delegate a self-contained subtask to a subagent that runs fully autonomously and returns its final answer. Use when a piece of work can run independently (parallelizable), needs a focused toolset, or benefits from a different model. Pick the smallest suitable agent: explore (fast read-only code search), scout (web/docs research), general (full toolset except delegation). Pass the agent a COMPLETE, self-contained instruction: the goal, relevant file paths, and exactly what to return. The subagent may run tools autonomously; fold its findings into your own answer afterwards.',
443
+ description: 'Delegate a self-contained subtask to a subagent that runs fully autonomously and returns its final answer. Use when a piece of work can run independently (parallelizable), needs a focused toolset, or benefits from a different model. Agents: general (full toolset, default), explore (fast read-only code search), scout (quick docs lookups), research (deep multi-source investigation with a sourced report), coder (isolated implementation packages), reviewer (severity-ranked code review), tester (write/run tests until green), web (headless-browser work on live pages). Pick the smallest suitable agent. Pass a COMPLETE, self-contained instruction: the goal, relevant file paths, and exactly what to return. The subagent may run tools autonomously; fold its findings into your own answer afterwards.',
349
444
  parameters: {
350
445
  prompt: { type: 'string', required: true, description: 'Complete instructions for the subagent — goal, relevant file paths, and the exact output to return' },
351
446
  agent: { type: 'string', required: false, description: 'Subagent id: general (default), explore, scout, or a custom one from config.agents' },
@@ -367,6 +462,22 @@ const TOOLS = {
367
462
  return usage + '\n\n' + res.content;
368
463
  }
369
464
  },
465
+ browser: {
466
+ name: 'browser',
467
+ description: 'Drive a real headless browser (Playwright). Actions: goto (open an http/https url), click (CSS selector), fill (selector + text), press (key like Enter), text (visible page text + title/url), eval (JS expression in the page context), links (list page links), screenshot (save PNG, returns the file path), close. The browser stays open between calls so you can navigate, interact, and re-read the page. Prefer this over webfetch when the page needs JavaScript rendering or interaction.',
468
+ parameters: {
469
+ action: { type: 'string', required: true, description: '"goto" | "click" | "fill" | "press" | "text" | "eval" | "links" | "screenshot" | "close"' },
470
+ url: { type: 'string', required: false, description: 'Page url (action=goto)' },
471
+ selector: { type: 'string', required: false, description: 'CSS selector (action=click or fill)' },
472
+ text: { type: 'string', required: false, description: 'Text to type into the selector (action=fill)' },
473
+ key: { type: 'string', required: false, description: 'Key to press, e.g. Enter (action=press)' },
474
+ script: { type: 'string', required: false, description: 'JS expression evaluated in the page (action=eval)' },
475
+ path: { type: 'string', required: false, description: 'Screenshot destination path (action=screenshot; default: OS temp dir)' },
476
+ },
477
+ async execute(params) {
478
+ return require('./browser').run(params);
479
+ }
480
+ },
370
481
  mcp: {
371
482
  name: 'mcp',
372
483
  description: 'Manage MCP (tool) servers for this workspace. Use when the user asks to add/remove/enable/disable an MCP server or connector (e.g. github, filesystem, gmail) or when a task requires tools the session does not have yet. In the TUI, hosting/cloud services (supabase, vercel, nextjs, railway, netlify, cloudflare) live under /connectors; dev-tool MCP servers (playwright, github, sentry, figma, postgres, mongodb, linear, slack, brave-search, exa, context7, filesystem, sqlite, puppeteer) live under /mcp. Common packages: Playwright=@playwright/mcp, Sentry=@sentry/mcp-server (needs --access-token TOKEN), Figma=figma-developer-mcp (env FIGMA_API_KEY), Postgres=@modelcontextprotocol/server-postgres (env DATABASE_URL), MongoDB=mongodb-mcp-server (env MONGODB_CONNECTION_STRING), Linear=linear-mcp-server (env LINEAR_API_KEY), Slack=@modelcontextprotocol/server-slack (env SLACK_BOT_TOKEN + SLACK_TEAM_ID), Brave=@modelcontextprotocol/server-brave-search (env BRAVE_API_KEY), Exa=exa-mcp-server (env EXA_API_KEY), Context7=@upstash/context7-mcp, Vercel=vercel-mcp-server (env VERCEL_TOKEN), Railway=@railway/mcp-server (env RAILWAY_API_TOKEN), Netlify=netlify-mcp-server (env NETLIFY_AUTH_TOKEN), Cloudflare=@cloudflare/mcp-server-cloudflare (env CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID), Supabase=@supabase/mcp-server-supabase (needs --access-token TOKEN), GitHub=ghcr.io/github/github-mcp-server (docker). If unsure about an MCP package, verify it exists first with webfetch (e.g. https://registry.npmjs.org/<pkg>) or web search (fetch or bash curl) — never trust an unverified npm package.',
@@ -540,4 +651,4 @@ async function getAllToolDefinitions(mode = 'build') {
540
651
  }
541
652
  }
542
653
 
543
- module.exports = { TOOLS, MODES, READ_ONLY_TOOLS, getToolDefinitions, getAllToolDefinitions, executeTool };
654
+ module.exports = { TOOLS, MODES, READ_ONLY_TOOLS, getToolDefinitions, getAllToolDefinitions, executeTool, isPrivateAddress };
package/src/tui/App.tsx CHANGED
@@ -464,12 +464,27 @@ export function App(props: { initialPrompt?: string; resumeSession?: string; aut
464
464
  // Keep the partial text in the bubble; the session already stored it,
465
465
  // so the user can type "continue" to resume the task.
466
466
  patchMessageAt(idx, { thinking: false, interrupted: true, thinkTime: Date.now() - t0, isError: false, parts: snapshotParts() });
467
- appendMessage({ role: "system", content: "Interrupted \u2014 partial response kept. Type \"continue\" to resume the task." });
467
+ var keptChars = String(resp.content || "").length;
468
+ var inProgress = null;
469
+ var tdNow = sess.todos || [];
470
+ for (var ti = 0; ti < tdNow.length; ti++) { if (tdNow[ti].status === "in_progress") { inProgress = tdNow[ti]; break; } }
471
+ appendMessage({ role: "system", content: "Interrupted \u2014 kept " + keptChars + " chars of the partial response" + (inProgress ? " (in progress: " + inProgress.content + ")" : "") + ". Type \"continue\" to resume the task." });
468
472
  } else {
469
- patchMessageAt(idx, { content: resp.content || "(no response)", thinking: false, thinkTime: Date.now() - t0, isError: isErr, parts: snapshotParts() });
473
+ patchMessageAt(idx, { content: resp.content || "(the model ended the turn without any text)", thinking: false, thinkTime: Date.now() - t0, isError: isErr, parts: snapshotParts() });
474
+ // Real leftovers, not a generic stop line: when the turn wrote todos
475
+ // and still has open ones, the session reports exactly which.
476
+ var openTodos = resp.openTodos;
477
+ if (!isErr && openTodos && openTodos.length) {
478
+ var openNames = [];
479
+ for (var oi = 0; oi < Math.min(openTodos.length, 8); oi++) openNames.push("- [" + openTodos[oi].status + "] " + openTodos[oi].content);
480
+ appendMessage({ role: "system", content: "Stopped with " + openTodos.length + " todo(s) still open:\n" + openNames.join("\n") + (openTodos.length > 8 ? "\n- \u2026" : "") + "\nSay \"continue\" to keep going." });
481
+ }
470
482
  }
471
483
  if (!isErr && sess.mode === "plan") {
472
- appendMessage({ role: "system", content: "Plan complete \u2014 press Tab to switch to Build, then send \"go\" to execute." });
484
+ var plannedSteps = 0;
485
+ var planTodos = sess.todos || [];
486
+ for (var pi = 0; pi < planTodos.length; pi++) { if (planTodos[pi].status !== "completed" && planTodos[pi].status !== "cancelled") plannedSteps++; }
487
+ appendMessage({ role: "system", content: "Plan complete \u2014 " + (plannedSteps ? plannedSteps + " step(s) planned. " : "") + "press Tab to switch to Build, then send \"go\" to execute." });
473
488
  }
474
489
  setSpeedStats({ live: null, last: sess.getSpeed().last });
475
490
  }).catch(function(e) {
@@ -710,7 +725,7 @@ export function App(props: { initialPrompt?: string; resumeSession?: string; aut
710
725
  fsx.mkdirSync(dir, { recursive: true });
711
726
  const esc = (s: string) => String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
712
727
  const body = messages().map((m: any) => `<div class="m ${esc(m.role)}"><b>${esc(m.role)}</b><pre>${esc(String(m.content ?? ""))}</pre></div>`).join("\n");
713
- const html = `<!doctype html><meta charset="utf-8"><title>Loom session</title><style>body{background:#191817;color:#e8e2d9;font-family:ui-monospace,monospace;max-width:900px;margin:2rem auto;padding:0 1rem}.m{border-left:3px solid #555;padding:.25rem .75rem;margin:.6rem 0}.m.user{border-color:#d7a35f}.m.assistant{border-color:#7aa2f7}.m.system{color:#9a938a}pre{white-space:pre-wrap;font-family:inherit;margin:.2rem 0}</style><h1>Loom Code session</h1>${body}`;
728
+ const html = `<!doctype html><meta charset="utf-8"><title>Loom session</title><style>body{background:#171512;color:#f2ede4;font-family:ui-monospace,monospace;max-width:900px;margin:2rem auto;padding:0 1rem}.m{border-left:3px solid #463f36;padding:.25rem .75rem;margin:.6rem 0}.m.user{border-color:#a8c496}.m.assistant{border-color:#e8845c}.m.system{color:#a89d8d}pre{white-space:pre-wrap;font-family:inherit;margin:.2rem 0}</style><h1>Loom Code session</h1>${body}`;
714
729
  const f = pathx.join(dir, (sessionId() || "session") + ".html");
715
730
  fsx.writeFileSync(f, html);
716
731
  showToast("Shared: " + f, "ok", 6000);
@@ -1,6 +1,7 @@
1
- // Top breadcrumb bar — project path, session info, model.
1
+ // Top breadcrumb bar — project path, session info, model. bgPanel surface
2
+ // plus a dim right-hand cluster; no frame (the TUI stays grid-line-free).
2
3
  import { palette } from "../theme.ts";
3
- import { providerName, modelName, sessionId, cwdShort, username, inputMode } from "../store.ts";
4
+ import { providerName, modelName, sessionId, cwdShort, inputMode } from "../store.ts";
4
5
 
5
6
  const MODE_LABELS: Record<string, string> = { build: "B", plan: "P", chat: "C" };
6
7
 
@@ -15,19 +16,19 @@ export function BreadcrumbBar() {
15
16
  paddingX={1}
16
17
  backgroundColor={ui.bgPanel}
17
18
  >
18
- <box flexDirection="row">
19
+ <box flexDirection="row" alignItems="center">
19
20
  <text fg={ui.primary}>{"loom"}</text>
20
- <text fg={ui.fgMuted}>{" / "}</text>
21
+ <text fg={ui.fgMuted}>{" · "}</text>
21
22
  <text fg={ui.fg}>{cwdShort() || "~"}</text>
22
- <text fg={ui.fgMuted}>{" · "}</text>
23
- <text fg={ui.fgDim}>{sessionId().slice(0, 8)}</text>
24
- <text fg={ui.fgMuted}>{" · "}</text>
23
+ <text fg={ui.fgMuted}>{" · "}</text>
24
+ <text fg={ui.fgMuted}>{sessionId().slice(0, 8)}</text>
25
+ <text fg={ui.fgMuted}>{" "}</text>
25
26
  <text fg={modeColors[inputMode()] || ui.primary}>{"[" + (MODE_LABELS[inputMode()] || "B") + "]"}</text>
26
27
  </box>
27
- <box flexDirection="row">
28
+ <box flexDirection="row" alignItems="center">
28
29
  <text fg={ui.secondary}>{providerName()}</text>
29
30
  <text fg={ui.fgMuted}>{" / "}</text>
30
- <text fg={ui.fg}>{modelName()}</text>
31
+ <text fg={ui.fgDim}>{modelName()}</text>
31
32
  </box>
32
33
  </box>
33
34
  );
@@ -441,8 +441,8 @@ function MsgBubble(props: { m: any; idx: number }) {
441
441
 
442
442
  if (m.role === "system") {
443
443
  return (
444
- <box marginBottom={1} paddingX={2}>
445
- <text fg={ui.fgMuted}>{m.content}</text>
444
+ <box marginBottom={1} paddingX={2} backgroundColor={ui.bgMsg}>
445
+ <text fg={ui.fgMuted}>{"· " + m.content}</text>
446
446
  </box>
447
447
  );
448
448
  }
@@ -486,7 +486,7 @@ function MsgBubble(props: { m: any; idx: number }) {
486
486
  {!todoShown && m.todos?.length ? <TodosBlock todos={m.todos} /> : null}
487
487
  {!isThink ? (
488
488
  <box flexDirection="row" paddingLeft={3} marginTop={1}>
489
- <text fg={isErr ? ui.error : ui.secondary}>{"\u25A3 " + (agentTag || "Loom")}</text>
489
+ <text fg={isErr ? ui.error : ui.primary}>{"\u25A3 " + (agentTag || "Loom")}</text>
490
490
  <text fg={ui.fgMuted}>
491
491
  {(m.thinkTime ? " \u00B7 " + fmtMs(m.thinkTime) : "") + (isStop ? " \u00B7 interrupted" : "")}
492
492
  </text>
@@ -201,17 +201,19 @@ export function InputBar() {
201
201
  </text>
202
202
  {slashWin().items.map((item, i) => {
203
203
  const abs = slashWin().start + i;
204
+ const act = abs === autoIndex();
204
205
  return (
205
206
  <box
206
- flexDirection="row" paddingY={0}
207
+ flexDirection="row" paddingY={0}
208
+ backgroundColor={act ? ui.bgPanelAlt : undefined}
207
209
  onMouseDown={() => selectSuggestionAt(abs)}
208
210
  onMouseUp={() => pickSuggestionAt(abs)}
209
211
  >
210
- <text fg={abs === autoIndex() ? ui.primary : ui.fgDim}>
211
- {(abs === autoIndex() ? "\u25B6 " : " ") + item.label.split(" ")[0]}
212
+ <text fg={act ? ui.primary : ui.fgDim}>
213
+ {(act ? "▸ " : " ") + item.label.split(" ")[0]}
212
214
  </text>
213
215
  <Show when={!!item.desc}>
214
- <text fg={abs === autoIndex() ? ui.fg : ui.fgMuted}>
216
+ <text fg={act ? ui.fg : ui.fgMuted}>
215
217
  {" " + item.desc}
216
218
  </text>
217
219
  </Show>
@@ -230,15 +232,19 @@ export function InputBar() {
230
232
  >
231
233
  {fileWin().items.map((item, i) => {
232
234
  const abs = fileWin().start + i;
235
+ const act = abs === autoIndex();
233
236
  return (
234
- <text
235
-
236
- fg={abs === autoIndex() ? ui.primary : ui.fgDim}
237
+ // Box wrapper: text nodes ignore a direct backgroundColor prop.
238
+ <box
239
+ flexDirection="row"
240
+ backgroundColor={act ? ui.bgPanelAlt : undefined}
237
241
  onMouseDown={() => selectSuggestionAt(abs)}
238
242
  onMouseUp={() => pickSuggestionAt(abs)}
239
243
  >
240
- {(abs === autoIndex() ? "> " : " ") + item.label}
241
- </text>
244
+ <text fg={act ? ui.primary : ui.fgDim}>
245
+ {(act ? "▸ " : " ") + item.label}
246
+ </text>
247
+ </box>
242
248
  );
243
249
  })}
244
250
  <text fg={ui.fgMuted}>{" UP/DN TAB/ENTER pick wheel/click ESC close" + rangeHint(fileWin().start, fileWin().total)}</text>
@@ -271,8 +277,13 @@ export function InputBar() {
271
277
  <box flexGrow={1} flexDirection="column" justifyContent="center">
272
278
  <text>
273
279
  {/* @ts-ignore OpenTUI text nodes take fg/bg via the style prop (TextNodeOptions), not as direct props */}
274
- <span style={{ fg: showCursor() ? ui.fgMuted : ui.bgInput }}>{"\u2588"}</span>
275
- <span style={{ fg: ui.fgMuted }}>{" Ask anything... /commands @file !shell"}</span>
280
+ <span style={{ fg: showCursor() ? ui.fg : ui.bgInput }}>{"\u2588"}</span>
281
+ <span style={{ fg: ui.fgMuted }}>{" Ask anything... "}</span>
282
+ <span style={{ fg: ui.primary }}>{"/commands"}</span>
283
+ <span style={{ fg: ui.fgMuted }}>{" "}</span>
284
+ <span style={{ fg: ui.accent }}>{"@file"}</span>
285
+ <span style={{ fg: ui.fgMuted }}>{" "}</span>
286
+ <span style={{ fg: ui.warning }}>{"!shell"}</span>
276
287
  </text>
277
288
  </box>
278
289
  }
@@ -57,7 +57,8 @@ function spansToNodes(spans: any[], keyPrefix: string) {
57
57
 
58
58
  function HeadingText(props: { level: 1 | 2 | 3; spans: any[]; keyPrefix: string }) {
59
59
  const marker = props.level === 1 ? "# " : props.level === 2 ? "## " : "### ";
60
- return <text fg={ui.primary}>{marker}{spansToNodes(props.spans, props.keyPrefix)}</text>;
60
+ const color = props.level === 1 ? ui.primary : props.level === 2 ? ui.primarySoft : ui.secondary;
61
+ return <text fg={color}>{marker}{spansToNodes(props.spans, props.keyPrefix)}</text>;
61
62
  }
62
63
 
63
64
  export function MdText(props: { md: string; wrap?: boolean }) {