diff --git a/extensions/levelcode-ai/agent.js b/extensions/levelcode-ai/agent.js
index 38aca4a..bf1e58e 100644
--- a/extensions/levelcode-ai/agent.js
+++ b/extensions/levelcode-ai/agent.js
@@ -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;
diff --git a/extensions/levelcode-ai/media/chat.html b/extensions/levelcode-ai/media/chat.html
index cbee342..c2abaf9 100644
--- a/extensions/levelcode-ai/media/chat.html
+++ b/extensions/levelcode-ai/media/chat.html
@@ -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; }
@@ -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 = '
' + (IC[icon] ? codicon(icon) : esc(icon || '•')) + '
'
+ const rowHtml = '' + (IC[icon] ? codicon(icon) : esc(icon || '•')) + '
'
+ '' + esc(shown) + '
';
+ // 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();
}
@@ -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
@@ -1982,6 +2007,9 @@
'' + verb + ''
+ '' + esc(m.server || '') + '' + esc(m.tool || '') + ''
+ '' + codicon(approved ? 'check-circle' : 'circle-slash') + '
';
+ // 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)
@@ -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(); }
diff --git a/extensions/levelcode-ai/test/webviewCss.test.js b/extensions/levelcode-ai/test/webviewCss.test.js
index 1c10989..5059f55 100644
--- a/extensions/levelcode-ai/test/webviewCss.test.js
+++ b/extensions/levelcode-ai/test/webviewCss.test.js
@@ -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');