Skip to content

Commit b40b97f

Browse files
committed
Everything is in place: the live-terminal + stop/kill feature is running, and PLAN.md now records both the shipped work and the M5 background-execution item
1 parent 8351dcb commit b40b97f

5 files changed

Lines changed: 123 additions & 13 deletions

File tree

‎PLAN.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ A deep UX overhaul + correctness pass landed on top of the agentic core, all in
118118
- **Response action bar** — Retry / Copy / 👍 / 👎 + *model · credits*, with inlined VS Code **codicons** (a self-contained `IC` registry + `codicon()` — no font/CSP changes). All UI emojis replaced by codicons.
119119
- **Sticky surfaces** — review bar (Keep all / Undo all + `+/-` totals) moved between the log and composer; an **active plan bar** pinned above it (opaque, doesn't scroll behind); a **context-usage meter** in the footer.
120120
- **Interaction polish** — `ask_user` clickable clarifying questions; shimmer "working" text + animated atom indicator; per-response Copy; stick-to-bottom scroll + jump-to-latest; in-chat 🐛 debug logging; the **account** sign-in/profile panel (M9 client, OAuth-callback wired, BYO-key privacy preserved); neutral context chips with colored type badges; light-theme contrast fixes.
121+
- **Live terminal for approved commands** — `run_command` now **streams** real stdout/stderr (`cp.spawn`, not buffered `exec`) into a terminal card as it runs, ending with `✓ exit 0 · 1.2s` / `✗ exit N` / `⊘ stopped` / `⚠ timed out`. Each command spawns **`detached`** (own process group) so a **Stop button** (and the main ◼) can `process.kill(-pid)` the whole tree — killing a server *and its children* with no orphans (verified). Backstop timeout is configurable (`atompp.ai.commandTimeout`, `0` = off); the model still receives the capped output as the tool result.
121122
- **Data-safety fixes** — a truncated `tool_use` (cut off by `max_tokens` mid-arguments) is now finalized with `input:{}` in `providers.js`, so the agent **recovers** instead of 400-ing (`tool_use.input: Field required`); EOL/BOM-tolerant edits; apply-then-review snapshot/Undo hardening; fixed a `fileChip` name collision that rendered the active-file chip as "undefined".
122123

123124
---
@@ -128,6 +129,7 @@ M0→M4 is where you stop wanting to switch editors. These milestones are what m
128129

129130
**M5 — Close the loop & earn hands-off trust.**
130131
- **● Diagnostics-driven auto-verify loop** *(M)* — after edits land, auto-run a configurable verify command (typecheck/lint/test) + pull `vscode.languages.getDiagnostics` for touched files, feed failures back into the transcript, self-correct until green (bounded by a round budget). *The capability every 2026 source calls the defining trait of autonomous agents — and we already have `run_command` + diagnostics + a persistent loop, so this makes the agent "finish the job" instead of handing back broken code.*
132+
- **Background command execution** *(M)* — detect long-running commands (dev servers, watchers, `tail -f`) and run them **detached in the background** instead of blocking the agent loop: the agent gets an immediate "started on :3000 (running in background)" and continues, while the live terminal card keeps streaming and a Stop button stays available; the agent can read recent output on demand. *Today an approved server **blocks** the agent until you Stop it or the backstop timeout fires — the live-terminal + process-group kill (M4+) made stopping reliable, but the agent still can't proceed while a server runs. This is how Cursor/Windsurf handle servers, and it pairs with the diagnostics→fix loop (start server → run tests against it → fix → repeat).*
131133
- **● Workspace checkpoints / restore timeline** *(M)* — snapshot touched files per agent step, with one-click "Restore to here." *Cursor's checkpoints are explicitly "what makes people comfortable letting the agent make sweeping changes"; `reviewSession.js` already snapshots from disk, so this is mostly promoting it to a per-step stack.*
132134
- **Prompt transparency + editable system prompts** *(S)* — view and override the real `SYSTEM` prompts and see the exact context sent. *A stated PLAN pillar (§4, from Void), still unbuilt; a privacy/trust wedge against closed tools.*
133135
- **Real token + cost accounting** *(S)* — ✅ *context-usage half shipped*: `providers.js` now captures real Anthropic `usage` (input/output + cache tokens) from the stream, surfaced as an always-visible footer meter + a warning bar as the window fills (`atompp.ai.contextWindow`). *Remaining:* running $ spend + replace `lmProvider`'s length/4 estimate. *Cost opacity is the #1 criticism of BOTH Cursor and Windsurf — a no-backend editor showing exact per-message cost flips their biggest weakness into ours.*

‎extensions/atom-ai/agent.js‎

Lines changed: 52 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -160,14 +160,49 @@ function isBinaryFile(abs) {
160160
try { const buf = fs.readFileSync(abs); const n = Math.min(buf.length, 8000); for (let i = 0; i < n; i++) { if (buf[i] === 0) { return true; } } return false; } catch { return false; }
161161
}
162162

163-
function runCommand(root, command) {
163+
// Run a shell command, STREAMING its output: onChunk(text, 'stdout'|'stderr') fires as it runs (live
164+
// terminal in the chat), onExit(code, ms, how) at the end. onStart(child, stop) exposes a stop() that
165+
// kills the whole process GROUP (server + children) — needed because a server is a child of the shell.
166+
// Returns the (capped) full output for the model.
167+
function runCommand(root, command, onChunk, onExit, onStart, timeoutMs) {
164168
return new Promise((resolve) => {
165-
cp.exec(command, { cwd: root, timeout: 60000, maxBuffer: 1 << 20 }, (err, stdout, stderr) => {
166-
let out = (stdout || '') + (stderr ? '\n[stderr]\n' + stderr : '');
167-
if (err && err.killed) { out += '\n[timed out after 60s]'; }
168-
else if (err) { out += '\n[exit ' + (err.code != null ? err.code : '?') + ']'; }
169-
resolve(out.slice(0, 8000) || '(no output)');
170-
});
169+
let out = '', streamed = 0, settled = false, timedOut = false, stopped = false;
170+
const STREAM_CAP = 100000; // max we push to the live terminal view
171+
const start = Date.now();
172+
const cap = (s, stream) => {
173+
out += s;
174+
if (out.length > 262144) { out = out.slice(-262144); } // bound the model buffer's memory
175+
if (onChunk && streamed < STREAM_CAP) {
176+
const room = STREAM_CAP - streamed;
177+
const piece = s.length > room ? s.slice(0, room) : s;
178+
onChunk(piece, stream);
179+
streamed += piece.length;
180+
if (streamed >= STREAM_CAP) { onChunk('\n[output truncated in view]', 'stderr'); }
181+
}
182+
};
183+
let child;
184+
// detached:true → the shell becomes its own process-group leader, so process.kill(-pid) kills the
185+
// whole tree (the shell AND the server/children it spawned), not just the shell (which orphans the server).
186+
try { child = cp.spawn(command, { cwd: root, shell: true, detached: true }); }
187+
catch (e) { if (onExit) { onExit(-1, 0, 'error'); } resolve('ERROR: ' + ((e && e.message) || e)); return; }
188+
const killGroup = (sig) => { try { if (child.pid) { process.kill(-child.pid, sig); } else { child.kill(sig); } } catch (e) { try { child.kill(sig); } catch (e2) { /* already gone */ } } };
189+
const stop = () => { if (settled || stopped) { return; } stopped = true; killGroup('SIGTERM'); setTimeout(() => { if (!settled) { killGroup('SIGKILL'); } }, 1500); };
190+
if (onStart) { onStart(child, stop); }
191+
if (child.stdout) { child.stdout.on('data', (d) => cap(d.toString(), 'stdout')); }
192+
if (child.stderr) { child.stderr.on('data', (d) => cap(d.toString(), 'stderr')); }
193+
const to = (timeoutMs && timeoutMs > 0) ? setTimeout(() => { timedOut = true; killGroup('SIGKILL'); }, timeoutMs) : null;
194+
const finish = (code) => {
195+
if (settled) { return; } settled = true;
196+
if (to) { clearTimeout(to); }
197+
const ms = Date.now() - start;
198+
const how = stopped ? 'stopped' : timedOut ? 'timeout' : 'exit';
199+
const exit = stopped ? 130 : (timedOut ? 124 : (code == null ? -1 : code));
200+
if (onExit) { onExit(exit, ms, how); }
201+
const note = stopped ? '\n[stopped by user]' : timedOut ? '\n[timed out]' : (exit !== 0 ? '\n[exit ' + exit + ']' : '');
202+
resolve((out + note).slice(0, 8000) || '(no output)');
203+
};
204+
child.on('close', (code) => finish(code));
205+
child.on('error', (e) => { cap('\n' + ((e && e.message) || e), 'stderr'); finish(1); });
171206
});
172207
}
173208

@@ -231,10 +266,17 @@ async function runTool(tu, ctx) {
231266
return 'Applied edit to ' + input.path + ' (pending the user\'s Keep/Undo review).';
232267
}
233268
if (tu.name === 'run_command') {
234-
const approved = await ctx.approve({ kind: 'command', command: String(input.command || ''), explanation: input.explanation || '' });
269+
const cmd = String(input.command || '');
270+
const approved = await ctx.approve({ kind: 'command', command: cmd, explanation: input.explanation || '' });
235271
if (!approved) { return 'User skipped this command. Do not retry it.'; }
236-
ctx.post({ type: 'agentTool', icon: 'terminal', text: 'ran: ' + input.command });
237-
return await runCommand(root, String(input.command || ''));
272+
const runId = tu.id || ('run-' + Date.now());
273+
ctx.post({ type: 'termRun', id: runId, command: cmd, cwd: path.basename(root) || 'workspace' });
274+
const stops = ctx.commandStops; // shared registry so the Stop button / ■ can kill the process group
275+
return await runCommand(root, cmd,
276+
(chunk, stream) => ctx.post({ type: 'termOutput', id: runId, chunk: chunk, stream: stream }),
277+
(code, ms, how) => { if (stops) { stops.delete(runId); } ctx.post({ type: 'termExit', id: runId, code: code, ms: ms, how: how }); },
278+
(child, stop) => { if (stops) { stops.set(runId, stop); } },
279+
ctx.commandTimeout);
238280
}
239281
if (tu.name === 'ask_user') {
240282
const questions = Array.isArray(input.questions) ? input.questions : [];

‎extensions/atom-ai/extension.js‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,9 @@ function postActiveFile() {
366366
post({ type: 'activeFile', label: path.basename(ed.document.uri.fsPath) + ' · ' + ed.document.lineCount + ' lines' });
367367
}
368368

369+
/** Running approved commands: runId → stop() that kills the command's process group. */
370+
const commandStops = new Map();
371+
369372
/** Pending in-chat approval requests, keyed by id, resolved by the webview. */
370373
const pendingApprovals = new Map();
371374
let approvalSeq = 0;
@@ -471,6 +474,8 @@ async function agentFlow(text) {
471474
approve: requestApproval, // run_command only
472475
ask: requestQuestions, // ask_user — clickable clarifying questions
473476
contextLimit: cfg.get('contextWindow', 200000), // model context window → drives the usage meter
477+
commandStops: commandStops, // runId → stop() (process-group kill); used by Stop button / ■
478+
commandTimeout: cfg.get('commandTimeout', 120000), // backstop before a command is force-killed (0 = off)
474479
applyEdit: (req) => review.applyEdit(req), // file edits: apply-then-review
475480
signal: abort.signal
476481
});
@@ -609,7 +614,8 @@ class ChatViewProvider {
609614
case 'ready': sendConfigToWebview(); postActiveFile(); postContextFiles(); post({ type: 'mode', agent: agentMode }); postAccount(); buildFileIndex(); post({ type: 'contextUsage', input: 0, limit: aiConfig().get('contextWindow', 200000) }); if (review) { review.resync(); } break;
610615
case 'setMode': agentMode = !!msg.agent; post({ type: 'mode', agent: agentMode }); break;
611616
case 'send': await handleSend(msg.text); break;
612-
case 'stop': dbg('stop.clicked'); if (abort) { abort.abort(); } clearApprovals(); clearQuestions(); break;
617+
case 'stop': dbg('stop.clicked', { running: commandStops.size }); for (const [, stop] of commandStops) { try { stop(); } catch (e) { /* gone */ } } if (abort) { abort.abort(); } clearApprovals(); clearQuestions(); break;
618+
case 'stopCommand': { dbg('stopCommand', { id: msg.id }); const s = commandStops.get(msg.id); if (s) { try { s(); } catch (e) { /* gone */ } } break; }
613619
case 'approvalResponse': resolveApproval(msg.id, msg.approved); break;
614620
case 'questionsResponse': resolveQuestions(msg.id, msg.answers, msg.notes); break;
615621
case 'accountSignIn': await accountSignIn(msg.provider, msg.create); break;

‎extensions/atom-ai/media/chat.html‎

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,21 @@
176176
.term .termfoot .skip { background: var(--vscode-button-secondaryBackground, rgba(127,127,127,.25)); color: var(--vscode-button-secondaryForeground, var(--vscode-foreground)); }
177177
.term .verdict { font-size: 12px; color: var(--muted); }
178178
.term.ran .verdict { color: var(--vscode-terminal-ansiBrightGreen, var(--vscode-terminal-ansiGreen, #7ee787)); }
179+
/* live terminal output (streamed stdout/stderr) — flows under the prompt like a real terminal, no box */
180+
.termrun .termout { margin: 4px 0 0; padding: 0; border: none; border-radius: 0; background: none; min-height: 0;
181+
max-height: 320px; overflow-y: auto; white-space: pre-wrap; word-break: break-word;
182+
font-family: var(--vscode-editor-font-family, ui-monospace, monospace); font-size: 12.5px; line-height: 1.5;
183+
color: var(--vscode-terminal-foreground, var(--vscode-foreground)); }
184+
.termrun .termout:empty { display: none; margin: 0; }
185+
.termrun .termerr { color: var(--vscode-terminal-ansiRed, var(--vscode-errorForeground, #f14c4c)); }
186+
.termrun .termstatus { margin-top: 9px; font-size: 11.5px; color: var(--muted); display: flex; align-items: center; gap: 6px; }
187+
.termrun .termstatus.ok { color: var(--vscode-terminal-ansiBrightGreen, var(--vscode-terminal-ansiGreen, #7ee787)); }
188+
.termrun .termstatus.bad { color: var(--vscode-errorForeground, #f14c4c); }
189+
.termrun .termstatus .ci { width: 1em; height: 1em; }
190+
.termrun .termstop { margin-left: auto; cursor: pointer; border: 1px solid var(--border); border-radius: 5px; padding: 2px 9px; font-size: 11px; display: inline-flex; align-items: center; gap: 4px; background: var(--vscode-button-secondaryBackground, rgba(127,127,127,.22)); color: var(--vscode-foreground); }
191+
.termrun .termstop:hover { background: var(--vscode-errorForeground, #f14c4c); color: #fff; border-color: transparent; }
192+
.termrun .termstop[disabled] { opacity: .6; cursor: default; }
193+
.termrun .termstop .ci { width: 1em; height: 1em; }
179194

180195
/* ---- ask_user: clickable clarifying questions ---- */
181196
.questions { border: 1px solid var(--border); border-radius: 10px; margin: 6px 2px; padding: 10px 11px 11px; background: var(--field-bg); }
@@ -577,6 +592,7 @@
577592
log.addEventListener('keydown', function(e){ if (e.key === 'Enter' || e.key === ' '){ const c = e.target.closest && e.target.closest('.filechip'); if (c){ e.preventDefault(); openChip(c); } } });
578593
const reviewBar = document.getElementById('reviewBar');
579594
const editCards = {}; // id -> applied-edit card element
595+
const termCards = {}; // id -> live terminal-output card element
580596
reviewBar.querySelector('.keepall').onclick = () => vscode.postMessage({ type: 'reviewKeepAll' });
581597
reviewBar.querySelector('.undoall').onclick = () => vscode.postMessage({ type: 'reviewUndoAll' });
582598
const input = document.getElementById('input');
@@ -938,12 +954,48 @@
938954
vscode.postMessage({ type: 'approvalResponse', id: m.id, approved });
939955
card.classList.add(approved ? 'ran' : 'skipped');
940956
const caret = card.querySelector('.tcaret'); if (caret) { caret.remove(); }
941-
card.querySelector('.termfoot').innerHTML = '<span class="verdict">' + (approved ? codicon('check') + ' Running in terminal…' : codicon('circle-slash') + ' Skipped') + '</span>';
957+
card.querySelector('.termfoot').innerHTML = '<span class="verdict">' + (approved ? codicon('check') + ' Approved' : codicon('circle-slash') + ' Skipped') + '</span>';
942958
};
943959
card.querySelector('.approve').onclick = () => done(true);
944960
card.querySelector('.skip').onclick = () => done(false);
945961
}
946962

963+
// Live terminal: an approved command streams its real stdout/stderr here as it runs.
964+
function addTermRun(m){
965+
clearEmpty(); clearStatus(); agentBubble = null;
966+
const card = document.createElement('div'); card.className = 'approval term termrun';
967+
card.innerHTML =
968+
'<div class="termbar"><span class="tdots"><i class="r"></i><i class="y"></i><i class="g"></i></span>'
969+
+ '<span class="tbartitle">zsh — ' + esc(m.cwd || 'workspace') + '</span></div>'
970+
+ '<div class="termbody">'
971+
+ '<div class="termline"><span class="tprompt">➜</span> <span class="tcwd">' + esc(m.cwd || '') + '</span> '
972+
+ '<span class="tcmd">' + esc(m.command || '') + '</span></div>'
973+
+ '<pre class="termout"></pre>'
974+
+ '<div class="termstatus running"><span class="tcaret"></span> running… '
975+
+ '<button class="termstop" title="Stop this command">' + codicon('stop-circle') + ' Stop</button></div>'
976+
+ '</div>';
977+
log.appendChild(card); scrollIfStuck();
978+
termCards[m.id] = card;
979+
const sb = card.querySelector('.termstop'); if (sb) { sb.onclick = () => { sb.disabled = true; sb.textContent = 'stopping…'; vscode.postMessage({ type: 'stopCommand', id: m.id }); }; }
980+
}
981+
function termOutputAppend(m){
982+
const card = termCards[m.id]; if (!card) { return; }
983+
const out = card.querySelector('.termout'); if (!out) { return; }
984+
out.insertAdjacentHTML('beforeend', m.stream === 'stderr' ? '<span class="termerr">' + esc(m.chunk) + '</span>' : esc(m.chunk));
985+
out.scrollTop = out.scrollHeight; // keep the latest line visible inside the card
986+
scrollIfStuck();
987+
}
988+
function termExitFinish(m){
989+
const card = termCards[m.id]; if (!card) { return; }
990+
const st = card.querySelector('.termstatus'); if (!st) { return; }
991+
const dur = (typeof m.ms === 'number') ? (m.ms >= 1000 ? (m.ms / 1000).toFixed(1) + 's' : m.ms + 'ms') : '';
992+
if (m.how === 'stopped'){ st.innerHTML = codicon('circle-slash') + ' stopped' + (dur ? ' · ' + dur : ''); st.className = 'termstatus bad'; }
993+
else if (m.how === 'timeout'){ st.innerHTML = codicon('warning') + ' timed out' + (dur ? ' · ' + dur : ''); st.className = 'termstatus bad'; }
994+
else { const ok = m.code === 0; st.innerHTML = (ok ? codicon('check') : codicon('circle-slash')) + ' exit ' + m.code + (dur ? ' · ' + dur : ''); st.className = 'termstatus ' + (ok ? 'ok' : 'bad'); }
995+
delete termCards[m.id];
996+
scrollIfStuck();
997+
}
998+
947999
// Blocking clarifying questions (ask_user) — clickable numbered options the agent waits on.
9481000
function addQuestions(m){
9491001
clearEmpty(); clearStatus(); agentBubble = null;
@@ -1253,6 +1305,9 @@
12531305
else if (m.type === 'agentTool'){ finishAgentBubble(); addAgentLine(m.icon, m.text); }
12541306
else if (m.type === 'agentApproval'){ addApproval(m); }
12551307
else if (m.type === 'agentQuestions'){ addQuestions(m); }
1308+
else if (m.type === 'termRun'){ addTermRun(m); }
1309+
else if (m.type === 'termOutput'){ termOutputAppend(m); }
1310+
else if (m.type === 'termExit'){ termExitFinish(m); }
12561311
else if (m.type === 'editApplied'){ addEditCard(m); }
12571312
else if (m.type === 'editResolved'){ resolveEditCard(m); }
12581313
else if (m.type === 'reviewState'){ updateReviewBar(m.count, m.add, m.del); }
@@ -1268,7 +1323,7 @@
12681323
else if (m.type === 'reset'){
12691324
log.innerHTML = '<div id="empty"><div class="big">New chat</div>Ask about your code, or describe what to build.</div>';
12701325
selLabel = null; ctxFiles = []; renderChips();
1271-
for (const k in editCards) { delete editCards[k]; } updateReviewBar(0); renderPlan([]); renderContext({ input: 0 });
1326+
for (const k in editCards) { delete editCards[k]; } for (const k in termCards) { delete termCards[k]; } updateReviewBar(0); renderPlan([]); renderContext({ input: 0 });
12721327
pending = ''; shown = ''; doneSignaled = false; flushAll = false; current = null; setStreaming(false);
12731328
forceStick();
12741329
}

‎extensions/atom-ai/package.json‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,11 @@
120120
"default": "",
121121
"markdownDescription": "Base URL of your Atom++ Cloud account server (for sign-in + settings/skills sync). Leave empty until the backend exists (roadmap M9). **Your AI provider keys never go to this server — AI requests stay direct from your machine to your provider.**"
122122
},
123+
"atompp.ai.commandTimeout": {
124+
"type": "number",
125+
"default": 120000,
126+
"description": "Backstop timeout in milliseconds before an approved shell command is force-killed (its whole process group). The Stop button on the terminal card is the primary control; set 0 to disable the backstop so long-running servers run until you stop them."
127+
},
123128
"atompp.ai.contextWindow": {
124129
"type": "number",
125130
"default": 200000,

0 commit comments

Comments
 (0)