Skip to content

Commit b736104

Browse files
authored
Merge pull request #35 from levelcodeai/feat/auto-preview
feat(ai): auto-open the built-in browser when the agent starts a web server
2 parents d39ea06 + 738e85f commit b736104

5 files changed

Lines changed: 260 additions & 5 deletions

File tree

extensions/levelcode-ai/agent.js

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ const fs = require('fs');
1414
const path = require('path');
1515
const cp = require('child_process');
1616
const providers = require('./providers/index');
17-
const { formatVerifyFeedback, verifyOutcome, looksUnrunnable, sniffPort, looksReady } = require('./verify');
17+
const { formatVerifyFeedback, verifyOutcome, looksUnrunnable, sniffPort, sniffPreviewUrl, looksReady } = require('./verify');
1818
const { classifyCommand, dangerLabel } = require('./commandSafety');
1919
const { loadProjectRules } = require('./projectRules');
2020
const { loadServerConfig, buildAgentTools, toolCountsByServer, classifyMcpTool, explainMcpRefusal } = require('./mcpConfig');
@@ -69,6 +69,11 @@ function buildSystem(menu) {
6969
}
7070
const TOOLS_TOKENS_EST = Math.round(JSON.stringify(TOOLS).length / 4);
7171

72+
// How much of a background command's accumulated output the sniffers re-read on each chunk. Generous
73+
// next to any single log line, so a url split across chunk boundaries is still found, yet small enough
74+
// that a noisy watcher which never prints an address costs nothing to keep scanning.
75+
const SNIFF_TAIL = 8192;
76+
7277
const FILE_EXCLUDES = '{**/node_modules/**,**/.git/**,**/out/**,**/dist/**,**/.vscode-test/**,**/*.map}';
7378

7479
// ---- ripgrep search (self-contained) ---------------------------------------
@@ -385,15 +390,35 @@ async function runTool(tu, ctx) {
385390
const stops = ctx.commandStops; // shared registry so the Stop button / ■ can kill the process group
386391
// Only BACKGROUND commands get a registry entry (read_command_output reads it). Foreground
387392
// one-shots keep their old behavior + don't accumulate — the model already gets their output.
388-
const entry = bg ? { command: cmd, status: 'running', code: null, how: null, port: null, ready: false, ring: '', totalBytes: 0, lastReadOffset: 0, startedAt: Date.now() } : null;
393+
const entry = bg ? { command: cmd, status: 'running', code: null, how: null, port: null, ready: false, previewUrl: null, ring: '', totalBytes: 0, lastReadOffset: 0, startedAt: Date.now() } : null;
389394
if (entry && ctx.commandRuns) { ctx.commandRuns.set(runId, entry); }
390395
const onChunk = (chunk, stream) => {
391396
ctx.post({ type: 'termOutput', id: runId, chunk: chunk, stream: stream });
392397
if (entry) {
393398
entry.ring = (entry.ring + chunk).slice(-100000); // bounded tail for read_command_output
394399
entry.totalBytes += chunk.length;
395-
if (!entry.port) { const p = sniffPort(chunk); if (p) { entry.port = p; ctx.post({ type: 'bgTask', id: runId, port: p }); } }
396-
if (!entry.ready && looksReady(chunk)) { entry.ready = true; ctx.post({ type: 'bgTask', id: runId, ready: true }); }
400+
// Sniff the accumulated TAIL, never the raw chunk: stdout arrives in arbitrary slices, so
401+
// a line can straddle a boundary ("http://local" + "host:5173/") and match neither half.
402+
// A few KB is far more than any single line needs, and bounding it keeps the rescan cheap
403+
// for a chatty server that never prints an address at all. (Applies to all three sniffs —
404+
// port and ready had the same latent gap.)
405+
const tail = entry.ring.slice(-SNIFF_TAIL);
406+
if (!entry.port) { const p = sniffPort(tail); if (p) { entry.port = p; ctx.post({ type: 'bgTask', id: runId, port: p }); } }
407+
if (!entry.ready && looksReady(tail)) { entry.ready = true; ctx.post({ type: 'bgTask', id: runId, ready: true }); }
408+
// Auto-preview: the moment a background command advertises a LOCAL address, offer to show
409+
// it in the built-in browser. Fired at most ONCE per run — if the user closes the tab we
410+
// must not reopen it on the next log line, and a restart-on-save server would otherwise
411+
// spawn a tab per reload. The host decides whether to honour it (setting + dedupe).
412+
if (!entry.previewUrl && typeof ctx.openPreview === 'function') {
413+
const url = sniffPreviewUrl(tail);
414+
if (url) {
415+
entry.previewUrl = url;
416+
dbg('preview.detected', { id: runId, url: url });
417+
// Never let a preview reject inside a live stream handler — showing a browser tab
418+
// must not be able to disturb a running command's output.
419+
Promise.resolve(ctx.openPreview(url)).catch((e) => dbg('preview.rejected', { id: runId, error: String((e && e.message) || e) }));
420+
}
421+
}
397422
}
398423
};
399424
const onExit = (code, ms, how) => {

extensions/levelcode-ai/extension.js

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ const { registerInlineComplete } = require('./inlineComplete');
2222
const { runAgent } = require('./agent');
2323
const { findCompactionCut, estimateMsgTokens } = require('./agentMemory');
2424
const { registerReview } = require('./reviewSession');
25-
const { formatDiagnosticLines, diagKey } = require('./verify');
25+
const { formatDiagnosticLines, diagKey, createPreviewGate } = require('./verify');
2626
const { loadSkills, skillsMenu, getSkillBody } = require('./skills');
2727
const { openCustomize } = require('./customize');
2828
const { importFromVscode } = require('./importVscode');
@@ -665,6 +665,46 @@ function reapCommands() {
665665
for (const [, stop] of commandStops) { try { stop(); } catch (e) { /* already gone */ } }
666666
commandStops.clear();
667667
bgRuns.clear();
668+
previewGate.clear(); // the servers are gone; a fresh session may legitimately preview again
669+
}
670+
671+
// Auto-preview (see openPreview): tracks which addresses have actually been SHOWN this session, so a
672+
// chatty server can't stack tabs and — more importantly — so closing the tab is RESPECTED rather than
673+
// undone by the next log line. A FAILED open stays retryable; see createPreviewGate.
674+
const previewGate = createPreviewGate();
675+
676+
/**
677+
* Show a locally-served URL in the built-in Simple Browser, beside the chat.
678+
*
679+
* This exists because the browser was already there and nobody knew: LevelCode ships VS Code's
680+
* simple-browser, but you had to know the command name to find it. Opening it automatically the moment
681+
* the agent brings a site up turns an invisible feature into the obvious one.
682+
*
683+
* Three deliberate choices: `preserveFocus` so a server coming up never steals the caret from whoever
684+
* is typing; `Beside` so the site sits next to the work rather than replacing it; and dedupe-by-URL so
685+
* the user closing the tab is final. Never throws — a preview must not be able to fail a run.
686+
*
687+
* The gate records a URL as shown only AFTER the open succeeds (PR #35 review): marking it up-front
688+
* meant one transient failure — Simple Browser disabled for a moment — blacklisted that address for the
689+
* whole session, so the preview silently never appeared again. See createPreviewGate.
690+
*/
691+
async function openPreview(url) {
692+
if (!aiConfig().get('preview.autoOpen', true)) { return; }
693+
if (!previewGate.shouldOpen(url)) { return; }
694+
previewGate.begin(url);
695+
try {
696+
await vscode.commands.executeCommand('simpleBrowser.api.open', vscode.Uri.parse(url), {
697+
preserveFocus: true,
698+
viewColumn: vscode.ViewColumn.Beside
699+
});
700+
previewGate.succeeded(url); // it really appeared → never reopen, so closing the tab is final
701+
post({ type: 'agentTool', icon: 'globe', text: '🌐 preview · ' + url });
702+
} catch (e) {
703+
// simple-browser disabled or the id moved upstream — log, never surface as a run failure. The
704+
// URL stays retryable: nothing was shown, so a later run should be free to try again.
705+
previewGate.failed(url);
706+
dbg('preview.failed', { url: url, error: String((e && e.message) || e) });
707+
}
668708
}
669709

670710
// Workspace checkpoints: a per-user-turn stack of file pre-images so the user can roll the workspace back
@@ -987,6 +1027,7 @@ async function agentFlow(text) {
9871027
toolPolicy: userScopedSetting(cfg.inspect('mcp.toolPolicy'), {})
9881028
},
9891029
contextLimit: contextLimitFor(req.providerId, capsModel(req.model)), // Auto → flagship window; the model SENT stays req.model
1030+
openPreview: openPreview, // background server advertised a local URL → show it in-editor
9901031
commandStops: commandStops, // runId → stop() (process-group kill); used by Stop button / ■
9911032
commandRuns: bgRuns, // runId → background-process registry (read_command_output reads it)
9921033
commandTimeout: cfg.get('commandTimeout', 120000), // backstop before a command is force-killed (0 = off)

extensions/levelcode-ai/package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,11 @@
436436
"default": false,
437437
"description": "Treat editor warnings (not just errors) as verification failures the agent must fix. Off by default — only errors block."
438438
},
439+
"levelcode.ai.preview.autoOpen": {
440+
"type": "boolean",
441+
"default": true,
442+
"markdownDescription": "When the agent starts a web server in the background, automatically open the site in LevelCode's built-in browser, beside the chat.\n\nOnly **local** addresses are ever opened — `localhost`, `127.0.0.1`, or the IPv6 loopback `[::1]`. The bind addresses `0.0.0.0` and `[::]` count as local and are shown as `localhost`, since a browser can't resolve them. A remote URL printed by a dev script is ignored, never opened.\n\nEach address opens at most once per session, so closing the tab is respected, and your focus is never taken. Turn this off to open previews yourself with **Simple Browser: Show**."
443+
},
439444
"levelcode.ai.mcp.servers": {
440445
"type": "object",
441446
"default": {},
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Unit tests for verify.js's output sniffers — run: node test/verify.test.js
3+
*
4+
* These read a background command's STDOUT, which is whatever a repo's dev script chose to print —
5+
* i.e. untrusted text. One direction is load-bearing:
6+
*
7+
* sniffPreviewUrl decides what the editor's built-in browser is pointed at, automatically. If it
8+
* ever returns a REMOTE url, a hostile repo can navigate the user's editor anywhere just by logging
9+
* a line. Every "remote" case below must return null (or the local url found elsewhere in the text).
10+
* Erring toward opening nothing is always safe; erring toward opening is not.
11+
*--------------------------------------------------------------------------------------------*/
12+
// @ts-check
13+
'use strict';
14+
15+
const assert = require('assert');
16+
const { sniffPreviewUrl, sniffPort, looksReady, createPreviewGate } = require('../verify');
17+
18+
let n = 0;
19+
function test(name, fn) { fn(); n++; console.log(' ok - ' + name); }
20+
21+
// ---- 1. the security bound: only LOCAL addresses may ever be opened ---------------------------
22+
23+
// Real-looking lines a hostile or merely misconfigured repo could print. None may be opened.
24+
const REMOTE = [
25+
'Local: http://evil.example.com:3000/',
26+
' ➜ Network: https://attacker.test:8080/pwn',
27+
'Server started at http://169.254.169.254:80/latest/meta-data', // cloud metadata endpoint
28+
'listening: http://10.0.0.5:3000',
29+
'open http://sub.domain.co.uk:4200 to view',
30+
'http://localhost.evil.com:3000', // suffix trick — the host is NOT localhost
31+
'http://127.0.0.1.evil.com:3000' // same trick with the loopback literal
32+
];
33+
34+
test('SECURITY: remote-only output opens NOTHING', () => {
35+
// Strictly null, not "null or some localhost url". An earlier version of this assertion allowed a
36+
// localhost fallback, which quietly permitted the thing it was meant to forbid: the port would be
37+
// re-extracted from the refused remote address and we'd open localhost:<their port> — a preview
38+
// conjured entirely out of a line we ignored. If the fixture is remote-only, the answer is nothing.
39+
for (const line of REMOTE) {
40+
assert.strictEqual(sniffPreviewUrl(line), null,
41+
'remote-only output must open nothing, got ' + JSON.stringify(sniffPreviewUrl(line)) +
42+
' from ' + JSON.stringify(line));
43+
}
44+
});
45+
46+
test('SECURITY: a local url still wins when a remote one is printed alongside it', () => {
47+
// Vite prints both; we must take the Local line and ignore Network, whatever its host.
48+
const out = sniffPreviewUrl(' ➜ Local: http://localhost:5173/\n ➜ Network: http://192.168.1.14:5173/');
49+
assert.strictEqual(out, 'http://localhost:5173/');
50+
});
51+
52+
// ---- 2. finding the address the user actually wants -------------------------------------------
53+
54+
test('PREVIEW: prefers the full printed url, keeping scheme, port and base path', () => {
55+
assert.strictEqual(sniffPreviewUrl(' ➜ Local: http://localhost:5173/'), 'http://localhost:5173/');
56+
assert.strictEqual(sniffPreviewUrl('ready - started server on http://localhost:3000/app'), 'http://localhost:3000/app');
57+
assert.strictEqual(sniffPreviewUrl('https://localhost:8443/'), 'https://localhost:8443/');
58+
assert.strictEqual(sniffPreviewUrl('running at http://127.0.0.1:4200'), 'http://127.0.0.1:4200');
59+
});
60+
61+
test('PREVIEW: bind addresses are rewritten to something a browser can actually resolve', () => {
62+
// 0.0.0.0 / [::] mean "every interface", not a destination — opening them literally often fails.
63+
assert.strictEqual(sniffPreviewUrl('Listening on http://0.0.0.0:8000'), 'http://localhost:8000');
64+
assert.strictEqual(sniffPreviewUrl('serving on http://[::]:9000/'), 'http://localhost:9000/');
65+
});
66+
67+
test('PREVIEW: falls back to localhost when only a port is announced', () => {
68+
// Plenty of servers never print a url — the Express boilerplate is exactly this line.
69+
assert.strictEqual(sniffPreviewUrl('Server running on port 3000'), 'http://localhost:3000');
70+
assert.strictEqual(sniffPreviewUrl('listening on :8080'), 'http://localhost:8080');
71+
});
72+
73+
test('PREVIEW: silence when nothing resembles a server', () => {
74+
for (const quiet of ['', ' ', 'building...', 'Compiled 42 modules', 'error TS2304: cannot find name', null, undefined]) {
75+
assert.strictEqual(sniffPreviewUrl(quiet), null, JSON.stringify(quiet) + ' should not open anything');
76+
}
77+
});
78+
79+
test('PREVIEW: garbage input returns null — not merely "does not throw"', () => {
80+
// This runs inside a stdout handler, so not throwing is necessary but nowhere near sufficient:
81+
// returning a non-null url would still pop a browser tab. Assert the value, not just the absence
82+
// of an exception.
83+
for (const junk of [{}, [], 42, true, Symbol.iterator.toString(), NaN, () => {}]) {
84+
let got;
85+
assert.doesNotThrow(() => { got = sniffPreviewUrl(/** @type {any} */(junk)); }, 'threw on ' + String(junk));
86+
assert.strictEqual(got, null, 'opened something from junk input: ' + String(junk));
87+
}
88+
});
89+
90+
test('SPLIT: a url straddling two stdout chunks matches only once reassembled', () => {
91+
// Why agent.js sniffs the accumulated ring TAIL rather than the raw chunk. runCommand streams
92+
// arbitrary slices, so a dev server's address routinely arrives in two pieces — and each piece on
93+
// its own is invisible to the sniffer, which would mean the preview silently never opened.
94+
const first = ' ➜ Local: http://local';
95+
const second = 'host:5173/\n';
96+
assert.strictEqual(sniffPreviewUrl(first), null, 'the leading half must not match on its own');
97+
assert.strictEqual(sniffPreviewUrl(second), null, 'the trailing half must not match on its own');
98+
assert.strictEqual(sniffPreviewUrl(first + second), 'http://localhost:5173/', 'reassembled, it must');
99+
});
100+
101+
// ---- 3. the sniffers the preview builds on (previously untested) ------------------------------
102+
103+
test('PORT: the most specific pattern wins, so later logs cannot masquerade as the server', () => {
104+
assert.strictEqual(sniffPort('http://localhost:5173/'), '5173');
105+
assert.strictEqual(sniffPort('Server running on port 3000'), '3000');
106+
assert.strictEqual(sniffPort('no port here'), null);
107+
// A url earlier in the text takes precedence over a bare "port N" mentioned later.
108+
assert.strictEqual(sniffPort('http://localhost:5173/ ... connected to db on port 5432'), '5173');
109+
});
110+
111+
test('READY: recognises the common "it is up" lines, and nothing else', () => {
112+
for (const up of ['compiled successfully', 'Listening on :3000', 'server is running', 'ready in 412 ms', ' Local: http://x']) {
113+
assert.strictEqual(looksReady(up), true, JSON.stringify(up));
114+
}
115+
for (const notUp of ['', 'building...', 'error: failed to compile']) {
116+
assert.strictEqual(looksReady(notUp), false, JSON.stringify(notUp));
117+
}
118+
});
119+
120+
// ---- 3b. the preview gate: shown-once, but a FAILED open stays retryable ----------------------
121+
122+
test('GATE: a successful open is final — closing the tab is never undone', () => {
123+
const g = createPreviewGate();
124+
const url = 'http://localhost:3000';
125+
assert.strictEqual(g.shouldOpen(url), true);
126+
g.begin(url);
127+
g.succeeded(url);
128+
// The user may now close that tab. Nothing — no later run, no chatty log line — may reopen it.
129+
assert.strictEqual(g.shouldOpen(url), false);
130+
});
131+
132+
test('GATE: a FAILED open is retryable — the bug this gate exists for', () => {
133+
// PR #35 review: the first version marked the URL as previewed BEFORE attempting to open it, so a
134+
// single transient failure (Simple Browser disabled for a moment) blacklisted that address for the
135+
// rest of the session — the preview then silently never appeared, with nothing to point at.
136+
const g = createPreviewGate();
137+
const url = 'http://localhost:5173';
138+
g.begin(url);
139+
g.failed(url);
140+
assert.strictEqual(g.shouldOpen(url), true, 'a failed open must not suppress later attempts');
141+
// …and a later attempt that works still closes the door exactly once.
142+
g.begin(url); g.succeeded(url);
143+
assert.strictEqual(g.shouldOpen(url), false);
144+
});
145+
146+
test('GATE: an in-flight open blocks a concurrent duplicate', () => {
147+
// Opening is async, so two runs advertising the same address could both pass the check and stack
148+
// two tabs. This is why "have we shown it" alone is not enough state.
149+
const g = createPreviewGate();
150+
const url = 'http://localhost:8080';
151+
g.begin(url);
152+
assert.strictEqual(g.shouldOpen(url), false, 'must not open the same URL twice concurrently');
153+
});
154+
155+
test('GATE: distinct URLs are independent, and clear() resets everything', () => {
156+
const g = createPreviewGate();
157+
g.begin('http://localhost:3000'); g.succeeded('http://localhost:3000');
158+
assert.strictEqual(g.shouldOpen('http://localhost:4000'), true, 'a different port is a different site');
159+
g.clear();
160+
assert.strictEqual(g.shouldOpen('http://localhost:3000'), true, 'New Chat may legitimately preview again');
161+
});
162+
163+
test('GATE: junk urls are never openable', () => {
164+
const g = createPreviewGate();
165+
for (const bad of [null, undefined, '']) { assert.strictEqual(g.shouldOpen(/** @type {any} */(bad)), false); }
166+
});
167+
168+
// ---- 4. source hygiene ------------------------------------------------------------------------
169+
170+
test('SOURCE: verify.js contains no raw control bytes', () => {
171+
// It shipped with a raw NUL in the diagKey separator, which made `file` report "data" and made grep
172+
// and diff skip the whole module in silence — you could not search your own source, and a reviewer
173+
// saw only "Binary file matches". The runtime value of an escape is identical, so nothing else
174+
// catches this. (Same defect was caught by review in the MCP modules.)
175+
const buf = require('fs').readFileSync(require('path').join(__dirname, '..', 'verify.js'));
176+
const bad = [];
177+
for (let i = 0; i < buf.length; i++) {
178+
const b = buf[i];
179+
if (b < 9 || (b > 13 && b < 32)) { bad.push(i); }
180+
}
181+
assert.deepStrictEqual(bad, [], 'raw control bytes at ' + bad.slice(0, 5).join(', ') + ' — use an escape');
182+
});
183+
184+
console.log('\nverify.js: ' + n + ' tests passed.');

extensions/levelcode-ai/verify.js

3.69 KB
Binary file not shown.

0 commit comments

Comments
 (0)