Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion extensions/levelcode-ai/agent.js
Original file line number Diff line number Diff line change
Expand Up @@ -508,7 +508,9 @@ async function runTool(tu, ctx) {
+ 'continue without it, or tell the user what you needed it for.';
}
}
ctx.post({ type: 'agentTool', icon: 'sparkle', text: '🔌 ' + route.server + ' · ' + route.tool });
// kind:'mcp' lets the webview fold this run-node into the approval chip that gated it (one row
// instead of "Approved …" + "🔌 …"); harmless for an allow-listed call, which has no chip to fold into.
ctx.post({ type: 'agentTool', icon: 'sparkle', text: '🔌 ' + route.server + ' · ' + route.tool, kind: 'mcp' });
return await server.call(route.tool, input); // never throws — failures come back as `ERROR: …`
}
return 'ERROR: unknown tool ' + tu.name;
Expand Down
34 changes: 31 additions & 3 deletions extensions/levelcode-ai/media/chat.html
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,14 @@
.tl { display: flex; gap: 11px; margin: 0 4px; position: relative; }
.tl-rail { flex: 0 0 22px; position: relative; }
.tl-rail::before { content: ''; position: absolute; left: 11px; top: 0; bottom: 0; width: 2px; transform: translateX(-1px); background: var(--border); }
/* Thread the rail across #log's flex-gap between CONSECUTIVE top-level rows, so a run of tool /
approval / group nodes reads as ONE connected line (GitHub/Cursor/Claude-style) instead of the
disconnected stubs a per-row rail otherwise leaves — this is what made an MCP run (approval →
tool → approval → tool …) look "cut in the middle". A narration bubble between two rows still
breaks the rail (intended — a new train of thought). The -12px MUST equal #log's `gap`; it is
scoped to #log's DIRECT children so group members (nested .tl rows inside a .groupbody, already
contiguous within their own rail) are never touched. */
#log > .tl + .tl > .tl-rail::before { top: -12px; }
.tl-node { position: absolute; left: 0; top: 3px; width: 22px; height: 22px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center;
background: var(--vscode-editor-background, var(--field-bg)); border: 1.5px solid var(--border); color: var(--muted); z-index: 1; }
.tl-node .ci { width: 12px; height: 12px; }
Expand Down Expand Up @@ -1849,12 +1857,25 @@
function addAgentLine(icon, text, label, kind, path){
clearEmpty(); clearStatus();
const st = chipStep(icon, text, label, kind, path); st.status = 'done';
const d = document.createElement('div'); d.className = 'tl tl-tool';
// Inside a group the row reads as the model's sentence ("Read the runAgent call site"); the raw
// tool text stays as the tooltip so the underlying path/query is never lost.
const shown = (groupsOn && st.base) ? st.base : String(text || '');
d.innerHTML = '<div class="tl-rail"><span class="tl-node">' + (IC[icon] ? codicon(icon) : esc(icon || '•')) + '</span></div>'
const rowHtml = '<div class="tl-rail"><span class="tl-node">' + (IC[icon] ? codicon(icon) : esc(icon || '•')) + '</span></div>'
+ '<div class="tl-body"><span class="toolt" title="' + escAttr(text || '') + '">' + esc(shown) + '</span></div>';
// MCP: an APPROVED tool call's run-node folds INTO the approval chip that gated it (mcpMergePending,
// set in addMcpApproval) so the pair is ONE row — Copilot-style — instead of "Approved …" plus a
// separate "🔌 …". An allow-listed call has no pending chip and renders its own node below; a
// skipped call never posts a node at all.
if (kind === 'mcp' && mcpMergePending){
mcpMergePending.className = 'tl tl-tool';
mcpMergePending.innerHTML = rowHtml;
mcpMergePending = null;
scrollIfStuck();
return;
}
mcpMergePending = null; // any other timeline row closes a stale merge window
const d = document.createElement('div'); d.className = 'tl tl-tool';
d.innerHTML = rowHtml;
groupAppend(d, st);
scrollIfStuck();
}
Expand Down Expand Up @@ -1894,6 +1915,10 @@
// I being asked", which is exactly what it is for. On decision it collapses to a one-line verdict;
// the live run card follows with the output.
let pendingApproval = null; // { done } while a decision is awaited — Enter approves, Esc skips
// An APPROVED MCP tool call's run-node folds into the approval chip that gated it (set in
// addMcpApproval's done(), consumed in addAgentLine) so the call is ONE timeline row, not "Approved …"
// plus a separate "🔌 …". Null except in the brief window between granting approval and the node arriving.
let mcpMergePending = null;
// MCP tool call (S4) — its own card: server · tool · arguments, so the user sees exactly what a
// third-party tool is about to do. Args are shown in full (capped host-side): that IS the decision.
// G1 consent card: a repo-authored .levelcode/mcp.json wants to SPAWN A PROCESS. This is the one
Expand Down Expand Up @@ -1982,6 +2007,9 @@
'<div class="cmdhead"><span class="cmdverb">' + verb + '</span>'
+ '<span class="cmdchips"><code>' + esc(m.server || '') + '</code><code>' + esc(m.tool || '') + '</code></span>'
+ '<span class="cmdstate ' + (approved ? 'ok' : 'bad') + '">' + codicon(approved ? 'check-circle' : 'circle-slash') + '</span></div>';
// Approved → the tool runs next and posts its own run-node; hand THIS row to it so the two merge
// into one line (addAgentLine). Skipped → nothing runs, so the chip stays as the record.
mcpMergePending = approved ? card : null;
forceStick();
};
card.querySelector('.approve').onclick = () => done(true, false); // Allow once (primary)
Expand Down Expand Up @@ -3238,7 +3266,7 @@
else if (m.type === 'autoContext'){ addAutoCtx(m.names); }
else if (m.type === 'mode'){ applyMode(!!m.agent); }
else if (m.type === 'autopilot'){ applyAutopilot(!!m.on); }
else if (m.type === 'agentStart'){ closeGroup(); setStreaming(true); agentBubble = null; agentRaw = ''; turnLabeled = false; renderPlan([]); setAgentStatus('thinking…'); }
else if (m.type === 'agentStart'){ closeGroup(); setStreaming(true); agentBubble = null; agentRaw = ''; turnLabeled = false; mcpMergePending = null; renderPlan([]); setAgentStatus('thinking…'); }
else if (m.type === 'agentStatus'){ finishAgentBubble(); setAgentStatus(m.text || 'working…'); }
else if (m.type === 'agentDelta'){ clearStatus(); setWork('Responding…'); if (!agentBubble){ agentBubble = makeStream(add('assistant', '')); agentRaw = ''; } agentRaw += m.text; streamFeed(agentBubble, agentRaw); }
else if (m.type === 'agentTurnEnd'){ finishAgentBubble(); }
Expand Down
30 changes: 30 additions & 0 deletions extensions/levelcode-ai/test/webviewCss.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -184,4 +184,34 @@ test('the /mcp command is wired end to end and lists CONFIGURED servers', () =>
assert.ok(/mcpcmd/.test(html) && /overflow-wrap: anywhere/.test(html), 'and wraps rather than truncating');
});

test('the timeline rail bridges the #log gap between consecutive rows (offsets stay in sync)', () => {
// The rail is drawn per-row (.tl-rail::before spans ONE row). #log stacks rows with a flex `gap`,
// so a run of tool/approval/group nodes only reads as one connected line if each consecutive row's
// rail is pulled UP by exactly that gap. Drift re-breaks it: a smaller bridge leaves the "cut in
// the middle" MCP-run stubs; a larger one paints the rail through an intended narration break.
const gap = css.match(/#log\s*\{[^}]*?\bgap:\s*(\d+)px/);
assert.ok(gap, '#log declares a flex gap');
const bridge = css.match(/#log\s*>\s*\.tl\s*\+\s*\.tl\s*>\s*\.tl-rail::before\s*\{\s*top:\s*-(\d+)px/);
assert.ok(bridge, 'consecutive top-level .tl rows bridge the gap (scoped to #log > direct children)');
assert.strictEqual(bridge[1], gap[1], 'the rail-bridge offset must equal #log gap, or the rail drifts');
});

test('an approved MCP tool call folds its run-node into the approval chip (one row, not two)', () => {
// Removes the redundancy where a manually-approved MCP call showed BOTH an "Approved …" chip AND a
// separate "🔌 server · tool" node. It is a three-part handshake; break any leg and the pair splits
// back into two rows (or folds into a stale chip):
// 1. agent.js tags the run-node kind:'mcp' so the webview can recognise it,
// 2. addMcpApproval arms the fold ONLY on approval (a skip keeps its own chip as the record),
// 3. addAgentLine folds a kind==='mcp' node into that pending row, and clears the window otherwise.
const agent = fs.readFileSync(path.join(__dirname, '..', 'agent.js'), 'utf8');
assert.ok(/type: 'agentTool'[^}]*route\.tool[^}]*kind: 'mcp'/.test(agent), 'agent.js posts the MCP run-node with kind:mcp');

const done = html.slice(html.indexOf('function addMcpApproval'), html.indexOf('function addApproval'));
assert.ok(/mcpMergePending = approved \? card : null/.test(done), 'addMcpApproval arms the fold only when approved');

const line = html.slice(html.indexOf('function addAgentLine'), html.indexOf('function setAgentStatus'));
assert.ok(/kind === 'mcp' && mcpMergePending/.test(line), 'addAgentLine folds an MCP node into the pending chip');
assert.ok(/mcpMergePending = null;/.test(line), 'and closes a stale merge window on any other row');
});

console.log('webviewCss: ' + n + ' tests passed');