Skip to content

Commit 599173f

Browse files
ndemiancclaude
andcommitted
Atom++ AI: chat/agent UX overhaul + tool-use data-safety fixes
UX: full markdown rendering; clickable file chips (reveal+open); terminal-style run-command approval; response action bar (retry/copy/feedback + model·credits) with inlined VS Code codicons; sticky plan + review bars; always-visible context-usage meter; ask_user clickable clarifying questions; shimmer status + animated atom; per-response copy; stick-to-bottom scroll; in-chat debug logging; account sign-in/profile panel; neutral context chips with colored type badges; light-theme contrast. Fixes: truncated tool_use (max_tokens mid-arguments) is finalized with input:{} so the agent recovers instead of 400-ing (tool_use.input: Field required); fileChip name collision that rendered the active-file chip as 'undefined'; EOL/BOM-tolerant edits; apply-then-review Undo/snapshot hardening. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 4eaf917 commit 599173f

6 files changed

Lines changed: 1214 additions & 130 deletions

File tree

‎extensions/atom-ai/agent.js‎

Lines changed: 136 additions & 22 deletions
Large diffs are not rendered by default.

‎extensions/atom-ai/extension.js‎

Lines changed: 154 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ let contextFiles = [];
4545
/** @type {AbortController | null} */
4646
let abort = null;
4747
/** Agent mode: sending runs the autonomous tool loop instead of plain chat. */
48-
let agentMode = false;
48+
let agentMode = true;
4949
/** Apply-then-review session (Keep/Undo for applied agent edits). Set in activate(). */
5050
let review;
5151
/** Persistent agent transcript for the session (tool calls + results), so follow-up goals
@@ -56,6 +56,9 @@ function post(msg) { if (activeWebview) { activeWebview.postMessage(msg); } }
5656

5757
function aiConfig() { return vscode.workspace.getConfiguration('atompp.ai'); }
5858

59+
/** Inline debug trace — prints a 🐛 DEBUG line in the chat (gated by atompp.ai.debug). */
60+
function dbg(label, data) { if (aiConfig().get('debug', true)) { post({ type: 'debug', label, data: data != null ? data : null }); } }
61+
5962
async function promptForKey() {
6063
const key = await vscode.window.showInputBox({
6164
title: 'Atom++ AI — Anthropic API Key',
@@ -370,12 +373,16 @@ let approvalSeq = 0;
370373
/** Ask the webview to approve an action; resolves true/false. */
371374
function requestApproval(req) {
372375
const id = String(++approvalSeq);
376+
const wf = vscode.workspace.workspaceFolders;
377+
const cwd = (wf && wf[0]) ? wf[0].name : '~';
378+
dbg('approval.request', { id, kind: req.kind, cmd: req.command });
373379
return new Promise((resolve) => {
374380
pendingApprovals.set(id, resolve);
375-
post(Object.assign({ type: 'agentApproval', id }, req));
381+
post(Object.assign({ type: 'agentApproval', id, cwd }, req));
376382
});
377383
}
378384
function resolveApproval(id, approved) {
385+
dbg('approval.response', { id, approved: !!approved });
379386
const r = pendingApprovals.get(id);
380387
if (r) { pendingApprovals.delete(id); r(!!approved); }
381388
}
@@ -384,6 +391,29 @@ function clearApprovals() {
384391
pendingApprovals.clear();
385392
}
386393

394+
/** Pending in-chat clarifying-question prompts (ask_user), keyed by id, resolved by the webview. */
395+
const pendingQuestions = new Map();
396+
let questionSeq = 0;
397+
398+
/** Ask the webview to present clickable multiple-choice questions; resolves with {answers, notes} or null. */
399+
function requestQuestions(req) {
400+
const id = String(++questionSeq);
401+
dbg('ask.request', { id, questions: (req.questions || []).length });
402+
return new Promise((resolve) => {
403+
pendingQuestions.set(id, resolve);
404+
post({ type: 'agentQuestions', id, questions: req.questions || [] });
405+
});
406+
}
407+
function resolveQuestions(id, answers, notes) {
408+
dbg('ask.response', { id, answered: Array.isArray(answers) ? answers.length : 0, notes: !!notes });
409+
const r = pendingQuestions.get(id);
410+
if (r) { pendingQuestions.delete(id); r({ answers: answers || [], notes: notes || '' }); }
411+
}
412+
function clearQuestions() {
413+
for (const [, r] of pendingQuestions) { r(null); }
414+
pendingQuestions.clear();
415+
}
416+
387417
/** Heal any dangling tool_use (e.g. from a run the user stopped mid-tool) by inserting the
388418
* missing tool_result blocks, so the next API call doesn't 400. */
389419
function repairAgentMemory() {
@@ -412,7 +442,9 @@ function trimAgentMemory() {
412442
if (cut > 0 && cut < agentMessages.length) { agentMessages.splice(0, cut); }
413443
}
414444

445+
let lastAgentGoal = null; // remembered so the response bar's Retry can re-run it
415446
async function agentFlow(text) {
447+
if (text && text.trim()) { lastAgentGoal = text; }
416448
const cfg = aiConfig();
417449
if (cfg.get('provider', 'claude') !== 'claude') {
418450
post({ type: 'agentError', message: 'Agent mode currently requires the Claude provider.' });
@@ -428,19 +460,23 @@ async function agentFlow(text) {
428460
repairAgentMemory();
429461
agentMessages.push({ role: 'user', content: text });
430462
trimAgentMemory();
463+
dbg('agent.start', { model: cfg.get('claude.model', 'claude-sonnet-4-6'), maxSteps: cfg.get('agent.maxSteps', 25), transcriptMsgs: agentMessages.length, goalChars: text.length });
431464
try {
432465
await runAgent({
433466
messages: agentMessages, // persists across runs → the agent remembers the session
434467
apiKey: key,
435468
model: cfg.get('claude.model', 'claude-sonnet-4-6'),
436469
maxSteps: Math.max(1, cfg.get('agent.maxSteps', 25)),
437-
post,
470+
post, dbg,
438471
approve: requestApproval, // run_command only
472+
ask: requestQuestions, // ask_user — clickable clarifying questions
473+
contextLimit: cfg.get('contextWindow', 200000), // model context window → drives the usage meter
439474
applyEdit: (req) => review.applyEdit(req), // file edits: apply-then-review
440475
signal: abort.signal
441476
});
442477
} finally {
443478
clearApprovals();
479+
clearQuestions();
444480
abort = null;
445481
}
446482
}
@@ -450,6 +486,7 @@ async function handleSend(text) {
450486
if (agentMode) { await agentFlow(text); return; }
451487
const cfg = aiConfig();
452488
const provider = cfg.get('provider', 'claude');
489+
dbg('chat.send', { provider, model: provider === 'claude' ? cfg.get('claude.model') : cfg.get('ollama.model'), chars: text.length, history: conversation.length });
453490

454491
const blocks = [];
455492

@@ -569,13 +606,21 @@ class ChatViewProvider {
569606
view.webview.html = getHtml();
570607
view.webview.onDidReceiveMessage(async (msg) => {
571608
switch (msg.type) {
572-
case 'ready': sendConfigToWebview(); postActiveFile(); postContextFiles(); post({ type: 'mode', agent: agentMode }); break;
609+
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;
573610
case 'setMode': agentMode = !!msg.agent; post({ type: 'mode', agent: agentMode }); break;
574611
case 'send': await handleSend(msg.text); break;
575-
case 'stop': if (abort) { abort.abort(); } clearApprovals(); break;
612+
case 'stop': dbg('stop.clicked'); if (abort) { abort.abort(); } clearApprovals(); clearQuestions(); break;
576613
case 'approvalResponse': resolveApproval(msg.id, msg.approved); break;
577-
case 'reviewKeepFile': review.keepFile(msg.id, 'kept'); break;
578-
case 'reviewUndoFile': await review.undoFile(msg.id); break;
614+
case 'questionsResponse': resolveQuestions(msg.id, msg.answers, msg.notes); break;
615+
case 'accountSignIn': await accountSignIn(msg.provider, msg.create); break;
616+
case 'accountSignOut': await accountSignOut(); break;
617+
case 'accountManage': await accountManage(); break;
618+
case 'copy': try { await vscode.env.clipboard.writeText(String(msg.text || '')); } catch (e) { /* clipboard unavailable */ } break;
619+
case 'retry': if (lastAgentGoal && !abort) { dbg('retry', { goalChars: lastAgentGoal.length }); await agentFlow(lastAgentGoal); } break;
620+
case 'feedback': dbg('feedback', { value: msg.value }); break;
621+
case 'openFile': await openWorkspaceFile(msg.path); break;
622+
case 'reviewKeepFile': dbg('review.keep', { id: msg.id }); review.keepFile(msg.id, 'kept'); break;
623+
case 'reviewUndoFile': dbg('review.undo', { id: msg.id }); await review.undoFile(msg.id); break;
579624
case 'reviewKeepAll': review.keepAll(); break;
580625
case 'reviewUndoAll': await review.undoAll(); break;
581626
case 'reviewOpenDiff': await review.openDiff(msg.id); break;
@@ -602,6 +647,100 @@ function getHtml() {
602647
return html.replace(/__CSP__/g, csp).replace(/__NONCE__/g, nonce);
603648
}
604649

650+
// ---- Atom++ Cloud account (sign-in + sync) -------------------------------------------------
651+
// IMPORTANT: this is a SEPARATE, optional layer. The AI path stays BYO-key / no-backend — the
652+
// account only syncs settings/skills/profile, never proxies AI or sees your provider keys.
653+
// It is gated on a configured `atompp.cloud.endpoint`; with none set, sign-in is honestly inert.
654+
const ACCOUNT_TOKEN_KEY = 'atompp.cloud.token'; // session token → SecretStorage
655+
const ACCOUNT_PROFILE_KEY = 'atompp.cloud.profile'; // {name,email,plan} → globalState
656+
function cloudEndpoint() { return String(vscode.workspace.getConfiguration('atompp.cloud').get('endpoint', '') || '').replace(/\/+$/, ''); }
657+
async function currentAccount() {
658+
const token = ctx ? await ctx.secrets.get(ACCOUNT_TOKEN_KEY) : null;
659+
if (token) {
660+
const p = (ctx && ctx.globalState.get(ACCOUNT_PROFILE_KEY)) || {};
661+
return { signedIn: true, name: p.name || p.email || 'Atom++ user', email: p.email || '', plan: p.plan || '' };
662+
}
663+
return { signedIn: false, status: cloudEndpoint() ? 'signedout' : 'unconfigured' };
664+
}
665+
async function postAccount(open) {
666+
const a = await currentAccount();
667+
post(Object.assign({ type: 'account', open: !!open }, a));
668+
}
669+
async function accountSignIn(provider, create) {
670+
const endpoint = cloudEndpoint();
671+
if (!endpoint) {
672+
dbg('account.signin', { unconfigured: true });
673+
await postAccount(true);
674+
vscode.window.showInformationMessage('Atom++ Cloud isn’t connected yet. Set "atompp.cloud.endpoint" to your account server to sign in. (Accounts + sync are on the roadmap — M9.)');
675+
return;
676+
}
677+
// Canonical editor OAuth callback: open the server's auth page with a redirect back to us.
678+
const cb = await vscode.env.asExternalUri(vscode.Uri.parse(`${vscode.env.uriScheme}://atompp.atom-ai/auth/callback`));
679+
const path = create ? 'signup' : (provider ? 'oauth/' + encodeURIComponent(provider) : 'login');
680+
const url = `${endpoint}/auth/${path}?redirect_uri=${encodeURIComponent(cb.toString())}`;
681+
dbg('account.signin', { provider: provider || 'browser', create: !!create });
682+
await vscode.env.openExternal(vscode.Uri.parse(url));
683+
}
684+
async function accountSignOut() {
685+
if (ctx) { await ctx.secrets.delete(ACCOUNT_TOKEN_KEY); await ctx.globalState.update(ACCOUNT_PROFILE_KEY, undefined); }
686+
dbg('account.signout');
687+
await postAccount();
688+
}
689+
async function accountManage() {
690+
const endpoint = cloudEndpoint();
691+
if (endpoint) { await vscode.env.openExternal(vscode.Uri.parse(endpoint + '/account')); }
692+
else { await postAccount(true); }
693+
}
694+
// The browser redirects to atom-plus-plus://atompp.atom-ai/auth/callback?token=…&name=…&email=…
695+
async function handleAuthCallback(uri) {
696+
try {
697+
const q = new URLSearchParams(uri.query || '');
698+
const token = q.get('token');
699+
if (!token) { return; }
700+
if (ctx) {
701+
await ctx.secrets.store(ACCOUNT_TOKEN_KEY, token);
702+
await ctx.globalState.update(ACCOUNT_PROFILE_KEY, { name: q.get('name') || '', email: q.get('email') || '', plan: q.get('plan') || '' });
703+
}
704+
dbg('account.callback', { ok: true });
705+
await postAccount(true);
706+
vscode.window.showInformationMessage('Signed in to Atom++.');
707+
} catch (e) { dbg('account.callback', { error: String((e && e.message) || e) }); }
708+
}
709+
710+
// ---- Workspace file index (powers clickable file chips in chat) -------------------------------
711+
let workspaceFiles = new Map(); // relPath -> vscode.Uri
712+
let fileIndexTimer = null;
713+
async function buildFileIndex() {
714+
try {
715+
const map = new Map();
716+
const exclude = '{**/node_modules/**,**/.git/**,**/out/**,**/dist/**,**/build/**,**/.DS_Store}';
717+
const uris = await vscode.workspace.findFiles('**/*', exclude, 8000);
718+
const rels = [];
719+
for (const u of uris) { const rel = vscode.workspace.asRelativePath(u, false); map.set(rel, u); rels.push(rel); }
720+
workspaceFiles = map;
721+
post({ type: 'fileIndex', files: rels });
722+
dbg('fileIndex', { count: rels.length });
723+
} catch (e) { /* ignore index build errors */ }
724+
}
725+
function scheduleFileIndex() { if (fileIndexTimer) { clearTimeout(fileIndexTimer); } fileIndexTimer = setTimeout(buildFileIndex, 400); }
726+
async function openWorkspaceFile(rel) {
727+
if (!rel) { return; }
728+
let uri = workspaceFiles.get(rel);
729+
if (!uri) {
730+
for (const f of (vscode.workspace.workspaceFolders || [])) {
731+
const cand = vscode.Uri.joinPath(f.uri, rel);
732+
try { await vscode.workspace.fs.stat(cand); uri = cand; break; } catch (e) { /* not here */ }
733+
}
734+
if (!uri) { const found = await vscode.workspace.findFiles('**/' + rel.split('/').pop(), '**/node_modules/**', 1); if (found.length) { uri = found[0]; } }
735+
}
736+
if (!uri) { vscode.window.showWarningMessage('Atom++: could not locate ' + rel); return; }
737+
try {
738+
await vscode.commands.executeCommand('revealInExplorer', uri); // reveal in the (nested) Explorer tree
739+
await vscode.window.showTextDocument(uri, { preview: false }); // open in the main editor
740+
dbg('openFile', { rel: rel });
741+
} catch (e) { dbg('openFile.error', { msg: String((e && e.message) || e) }); }
742+
}
743+
605744
function activate(context) {
606745
ctx = context;
607746
context.subscriptions.push(
@@ -619,8 +758,15 @@ function activate(context) {
619758
await context.secrets.delete(SECRET_KEY);
620759
vscode.window.showInformationMessage('Atom++ AI: Anthropic API key cleared.');
621760
}),
761+
vscode.commands.registerCommand('atompp.ai.account', () => postAccount(true)),
762+
vscode.window.registerUriHandler({ handleUri(uri) { if (uri.path === '/auth/callback') { handleAuthCallback(uri); } } }),
763+
vscode.workspace.onDidCreateFiles(scheduleFileIndex),
764+
vscode.workspace.onDidDeleteFiles(scheduleFileIndex),
765+
vscode.workspace.onDidRenameFiles(scheduleFileIndex),
766+
vscode.workspace.onDidChangeWorkspaceFolders(scheduleFileIndex),
622767
vscode.workspace.onDidChangeConfiguration((e) => {
623768
if (e.affectsConfiguration('atompp.ai')) { sendConfigToWebview(); }
769+
if (e.affectsConfiguration('atompp.cloud')) { postAccount(); }
624770
})
625771
);
626772

@@ -644,7 +790,7 @@ function activate(context) {
644790
});
645791

646792
// Apply-then-review: agent edits land immediately and are reviewed with Keep/Undo (editor + panel).
647-
review = registerReview(context, post);
793+
review = registerReview(context, post, dbg);
648794

649795
// Inline (ghost-text) tab-completion. Silent key lookup — it must never prompt mid-typing.
650796
registerInlineComplete(context, {

0 commit comments

Comments
 (0)