diff --git a/extensions/levelcode-ai/extension.js b/extensions/levelcode-ai/extension.js index 078ef15..ce9d9e9 100644 --- a/extensions/levelcode-ai/extension.js +++ b/extensions/levelcode-ai/extension.js @@ -3291,13 +3291,15 @@ function sendConfigToWebview() { // "unconstrained": the way to widen the measure is a large number, not 0. const proseSize = clampSetting(cfg.get('chat.fontSize', 0), 8, 24); const proseWidth = clampSetting(cfg.get('chat.proseWidth', 0), 320, 2000); + // The light round the message box while LevelCode works (media/chat.html, "the working aura"). + const workingAura = cfg.get('chat.workingAura', true) !== false; if (providerMode() === 'gateway' && cloudSignedIn) { const model = gatewayModel(); post({ type: 'config', provider: 'gateway', proseSize, proseWidth, model: gatewayModelLabel(model), modelId: model, providerLabel: 'LevelCode Cloud', contextLimit: contextLimitFor('openai', capsModel(model)), gateway: true, plan: cloudPlanName() || 'Free', paid: isPaidCloudPlan(cloudPlanName()), - groupActivity: groupActivity, canSeeImages: supportsVisionForModel('openai', capsModel(model)), maxImages: maxImagesPerMessage() + groupActivity: groupActivity, workingAura, canSeeImages: supportsVisionForModel('openai', capsModel(model)), maxImages: maxImagesPerMessage() }); return; } @@ -3306,7 +3308,7 @@ function sendConfigToWebview() { // Carry the model's context window so the footer meter updates the moment the model changes. // canSeeImages travels with the model so the composer can refuse an attachment BEFORE anything is // typed and lost, rather than after a send that the provider would reject. - post({ type: 'config', provider: providerId, proseSize, proseWidth, model: activeModel(cfg, providerId), providerLabel: p.label, contextLimit: currentContextLimit(), groupActivity: groupActivity, canSeeImages: supportsVisionForModel(providerId, activeModel(cfg, providerId)), maxImages: maxImagesPerMessage() }); + post({ type: 'config', provider: providerId, proseSize, proseWidth, model: activeModel(cfg, providerId), providerLabel: p.label, contextLimit: currentContextLimit(), groupActivity: groupActivity, workingAura, canSeeImages: supportsVisionForModel(providerId, activeModel(cfg, providerId)), maxImages: maxImagesPerMessage() }); } /** diff --git a/extensions/levelcode-ai/media/chat.html b/extensions/levelcode-ai/media/chat.html index 047c3e9..608c71b 100644 --- a/extensions/levelcode-ai/media/chat.html +++ b/extensions/levelcode-ai/media/chat.html @@ -984,6 +984,50 @@ } #composer.focused { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); } + /* ---- the working aura: a light that travels round the message box while LevelCode works ---- + Thinking, streaming, running a command, verifying: a short bright arc circles the box, trailing a soft + glow over a faint whole ring. Waiting on you (a command to approve, a question to answer) it stops and + dims to a thin ring with no glow — paused, because nothing happens until you act. Off: nothing at all. + Two rings on the box's own edge, cut to a band by a mask so the box's inside is untouched: ::after is + the sharp line, ::before the blurred glow under it. Both turn on one angle, so the glow never lags. + Colours come from the theme: the progress colour while working (the theme's own "busy"), the focus + colour while waiting. The speed is set per box (syncAura) so a wide panel does not look frantic. */ + @property --aura-angle { syntax: ''; inherits: false; initial-value: 0deg; } + @keyframes aura-turn { to { --aura-angle: 360deg; } } + #composer { + position: relative; + --aura-color: var(--vscode-progressBar-background, var(--accent)); + --aura-head: color-mix(in srgb, var(--aura-color) 62%, var(--vscode-foreground, #fff)); + } + #composer::before, #composer::after { + content: ''; position: absolute; border-radius: inherit; pointer-events: none; opacity: 0; + transition: opacity .4s ease; + -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); -webkit-mask-composite: xor; + mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); mask-composite: exclude; + } + #composer::before { + inset: -6px; padding: 6px; filter: blur(5px); + background: conic-gradient(from var(--aura-angle), transparent 0deg 255deg, + color-mix(in srgb, var(--aura-color) 38%, transparent) 318deg, color-mix(in srgb, var(--aura-head) 80%, transparent) 354deg, transparent 360deg); + } + #composer::after { + inset: -1px; padding: 2px; + /* A faint whole ring (the box is busy) with the bright arc travelling over it. */ + background: conic-gradient(from var(--aura-angle), color-mix(in srgb, var(--aura-color) 22%, transparent) 0deg 262deg, + color-mix(in srgb, var(--aura-color) 60%, transparent) 320deg, var(--aura-head) 355deg, color-mix(in srgb, var(--aura-color) 22%, transparent) 360deg); + } + body.aura-working #composer::before, body.aura-working #composer::after { opacity: 1; animation: aura-turn var(--aura-turn, 2.4s) linear infinite; } + /* Waiting on you: paused — a thin, dim ring with no glow, weaker than working and than the box's own focus. */ + body.aura-waiting #composer::after { opacity: 1; padding: 1px; background: color-mix(in srgb, var(--accent) 45%, transparent); } + @media (prefers-reduced-motion: reduce) { + /* No motion: working is a whole, bright ring with its glow — still distinct from waiting's dim one. */ + body.aura-working #composer::before, body.aura-working #composer::after { animation: none; } + body.aura-working #composer::after { background: color-mix(in srgb, var(--aura-color) 75%, transparent); } + body.aura-working #composer::before { background: color-mix(in srgb, var(--aura-color) 35%, transparent); } + } + /* High contrast draws its own borders; a soft glow there is noise. The working bar still says it. */ + @media (forced-colors: active) { #composer::before, #composer::after { display: none; } } + #chips { display: flex; flex-wrap: wrap; gap: 5px; align-items: center; } .chip { display: inline-flex; align-items: center; gap: 5px; @@ -3444,6 +3488,33 @@ } // Waiting on you (a command to approve, a question to answer): the step in progress stops spinning. function agentParked(){ return !!pendingApproval || !!log.querySelector('.questions:not(.answered)'); } + // ── the working aura (the light round the message box; its look is in the CSS) ── + // `var`, not `let`: syncAura is reached from syncSendBtn, which can run before this line does, and a + // decoration must never stop the chat with a temporal-dead-zone error. + var auraEnabled = true; // levelcode.ai.chat.workingAura, from the config message + var auraShown = 'off'; + /** What the aura shows: off (idle, or turned off), working (LevelCode is busy), waiting (busy, but on you). */ + function auraState(isBusy, parked, enabled){ return (!enabled || !isBusy) ? 'off' : parked ? 'waiting' : 'working'; } + /** Seconds per turn: grows with the box's perimeter, but slower than it, so a wide panel stays calm. */ + function auraDuration(w, h){ + const p = 2 * (Math.max(0, w || 0) + Math.max(0, h || 0)); + return Math.min(3, Math.max(1.6, 0.55 + 0.06 * Math.sqrt(p))); + } + function syncAura(){ + try { + const s = auraState(busy(), agentParked(), auraEnabled); + if (s === auraShown) { return; } + // The speed is fixed when the light starts: a duration changed mid-turn would only apply on a restart. + if (s === 'working') { const r = composer.getBoundingClientRect(); composer.style.setProperty('--aura-turn', auraDuration(r.width, r.height).toFixed(2) + 's'); } + auraShown = s; + document.body.classList.toggle('aura-working', s === 'working'); + document.body.classList.toggle('aura-waiting', s === 'waiting'); + } catch (e) { /* before the page is wired up: the next change draws it */ } + } + // An approval card or a question appearing, or being answered, is a class or a node in the transcript: + // watched here, so no card has to remember to tell the aura. + new MutationObserver(function(){ if (auraShown !== 'off' || busy()) { syncAura(); } }) + .observe(log, { childList: true, subtree: true, attributes: true, attributeFilter: ['class'] }); function renderTurnBox(){ if (turnBox.parentNode !== log) { log.appendChild(turnBox); } // the log is rebuilt on reset / resume const v = turnView(turn, agentParked()); @@ -3670,6 +3741,7 @@ sendBtn.classList.toggle('stop', stop); sendBtn.innerHTML = stop ? '■' : '↵'; // ■ stops the run, ↵ sends (or queues) sendBtn.title = stop ? 'Stop' : queueing ? 'Queue — it sends when the agent is ready (Enter)' : 'Send (Enter)'; + syncAura(); // busy() changes with the button: a message on its way already counts as LevelCode working } function updateReviewBar(count, add, del){ if (count > 0){ @@ -3811,6 +3883,7 @@ if (workbarEl){ workbarEl.hidden = !on; } // the working bar is visible for the ENTIRE run if (on){ setWork('Working…'); } else { setTimeout(drainQueue, 60); } // the run is over: a queued message goes next + syncAura(); } // Shell-style history recall in the composer: ↑ = previous sent message, ↓ = newer (back to your draft). // Session-only. In multiline text, ↑/↓ recall only when the caret is on the first/last line (arrows still move otherwise). @@ -5069,6 +5142,7 @@ shell.style.setProperty('--prose-size', m.proseSize ? m.proseSize + 'px' : ''); shell.style.setProperty('--prose-max', m.proseWidth ? m.proseWidth + 'px' : ''); } + if (typeof m.workingAura === 'boolean'){ auraEnabled = m.workingAura; syncAura(); } const label = m.provider === 'claude' ? prettyModel(m.model) : m.model; const mEl = document.getElementById('model'); mEl.innerHTML = esc(label) + ' ▾'; diff --git a/extensions/levelcode-ai/package.json b/extensions/levelcode-ai/package.json index 0ab3be8..b6a72b3 100644 --- a/extensions/levelcode-ai/package.json +++ b/extensions/levelcode-ai/package.json @@ -452,6 +452,12 @@ "default": true, "description": "Fold consecutive agent actions (reads, edits, commands) into one collapsible activity card in the chat — the header shows the live step while running and a summary when done. Turn off for the flat, ungrouped timeline." }, + "levelcode.ai.chat.workingAura": { + "type": "boolean", + "scope": "application", + "default": true, + "description": "Show a light that travels round the message box while LevelCode works — thinking, writing, running a command, checking its edits. While it waits on you (a command to approve, a question to answer) the light stops and stays as a still ring. With reduced motion it is a still ring throughout. Turn off for no light at all; the working line above the message box still shows what is happening." + }, "levelcode.cloud.endpoint": { "type": "string", "default": "https://levelcode.ai", diff --git a/extensions/levelcode-ai/scripts/working-aura-editor-check.js b/extensions/levelcode-ai/scripts/working-aura-editor-check.js new file mode 100644 index 0000000..c26545d --- /dev/null +++ b/extensions/levelcode-ai/scripts/working-aura-editor-check.js @@ -0,0 +1,283 @@ +#!/usr/bin/env node +/*--------------------------------------------------------------------------------------------- + * LevelCode — the working aura, checked in the REAL editor + * + * test/workingAura.test.js runs the aura's decisions. This runs the product: the dev build + * (vscode/.build/electron) as a throwaway instance with THIS checkout's extension, a stand-in model on + * localhost, driven through the DevTools protocol (the harness of diagram-editor-check.js). It reads the + * light round the message box from the chat page's own computed style, in each state, and takes a + * picture of each: + * working the model "thinks" for a while: the ring shows, animates, and its angle moves + * waiting a command waits for approval: the ring is whole and still + * idle the run is over: nothing on the box + * reduced with reduced motion emulated: a still ring while working + * off with levelcode.ai.chat.workingAura false: nothing, even while working + * + * RUN: node extensions/levelcode-ai/scripts/working-aura-editor-check.js [--vscode ] [--out ] [--theme ] [--keep] + * --theme a colour theme to look at it in, e.g. "LevelCode One Light" (default: the build's own) + * From a worktree, pass the main checkout's build: --vscode
/vscode + * NEEDS: the dev build (./scripts/run-dev.sh has been run once), macOS or Linux, Node 22+. + * NOTE: an editor window opens for about a minute: a separate instance with its own profile. + * EXIT: 0 all checks pass · 1 a check failed · 2 could not run (no build, no window). + * + * Not part of scripts/test-extensions.sh: that gate is plain Node, and stays that way. + *--------------------------------------------------------------------------------------------*/ +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const http = require('http'); +const cp = require('child_process'); + +const EXT = path.join(__dirname, '..'); +const REPO = path.join(EXT, '..', '..'); +const argv = process.argv.slice(2); +const opt = (name) => { const i = argv.indexOf(name); return i >= 0 ? argv[i + 1] : undefined; }; +const VSCODE_DIR = path.resolve(opt('--vscode') || process.env.LEVELCODE_VSCODE_DIR || path.join(REPO, 'vscode')); +const OUT = path.resolve(opt('--out') || fs.mkdtempSync(path.join(os.tmpdir(), 'lc-aura-editor-'))); +const KEEP = argv.includes('--keep'); +const THEME = opt('--theme'); +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +// ---- the stand-in model: holds each turn until the check releases it --------------------------------- +// Every streamed request waits on a gate; the check opens it when it has looked at the page. Goals: +// "think" → one held turn, then "Done: thought." +// "run" → held turn, then run_command `echo aura` (an approval card in Default Approvals), then Done. +const requests = []; +let release = null; +const gate = () => new Promise((r) => { release = r; }); +function startProvider() { + return new Promise((resolve) => { + const server = http.createServer((req, res) => { + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', async () => { + let j = {}; try { j = JSON.parse(body || '{}'); } catch (e) { /* not JSON */ } + if (req.method === 'GET') { res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify({ data: [{ id: 'stand-in' }] })); return; } + if (!j.stream) { res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify({ choices: [{ message: { role: 'assistant', content: 'Aura check' }, finish_reason: 'stop' }], usage: { prompt_tokens: 10, completion_tokens: 3 } })); return; } + const msgs = Array.isArray(j.messages) ? j.messages : []; + const text = (m) => (typeof m.content === 'string' ? m.content : JSON.stringify(m.content)); + const users = msgs.filter((m) => m.role === 'user').map(text); + const lastGoal = users.filter((u) => /^(think|run)\b/.test(u)).pop() || ''; + const afterTool = msgs.length && msgs[msgs.length - 1].role === 'tool'; + requests.push({ goal: lastGoal, afterTool }); + res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' }); + const send = (o) => res.write('data: ' + JSON.stringify(o) + '\n\n'); + const chunk = (delta, finish) => send({ id: 'c1', object: 'chat.completion.chunk', model: j.model, choices: [{ index: 0, delta, finish_reason: finish || null }] }); + if (!afterTool) { await gate(); } // held until the check has looked at "working" + if (/^run\b/.test(lastGoal) && !afterTool) { + chunk({ tool_calls: [{ index: 0, id: 'call_1', type: 'function', function: { name: 'run_command', arguments: JSON.stringify({ command: 'echo aura', explanation: 'Print a word' }) } }] }); + chunk({}, 'tool_calls'); + } else { + chunk({ content: 'Done: ' + (afterTool ? 'ran it.' : 'thought.') }); chunk({}, 'stop'); + } + send({ id: 'c1', object: 'chat.completion.chunk', model: j.model, choices: [], usage: { prompt_tokens: 50, completion_tokens: 5 } }); + res.write('data: [DONE]\n\n'); res.end(); + }); + }); + server.listen(0, '127.0.0.1', () => resolve(server)); + }); +} + +// ---- a minimal DevTools-protocol client (Node's own WebSocket and fetch) ---- +function connect(wsUrl) { + return new Promise((resolve, reject) => { + const ws = new WebSocket(wsUrl); + let id = 0; const pending = new Map(); const events = []; + ws.onopen = () => resolve({ + send: (method, params) => new Promise((res, rej) => { const i = ++id; pending.set(i, { res, rej }); ws.send(JSON.stringify({ id: i, method, params: params || {} })); }), + events, close: () => { try { ws.close(); } catch (e) { /* closed */ } } + }); + ws.onerror = () => reject(new Error('could not connect to ' + wsUrl)); + ws.onmessage = (m) => { + const o = JSON.parse(m.data); + if (o.id && pending.has(o.id)) { const p = pending.get(o.id); pending.delete(o.id); if (o.error) { p.rej(new Error(o.error.message)); } else { p.res(o.result); } } + else if (o.method) { events.push(o); } + }; + }); +} +async function evalIn(c, expression) { + // A webview the workbench has replaced never answers: bound every call, so the run fails instead of hanging. + const r = await Promise.race([ + c.send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }), + new Promise((_, reject) => setTimeout(() => reject(new Error('the page did not answer in 15 s (was it replaced?)')), 15000)) + ]); + if (r.exceptionDetails) { throw new Error('the page threw: ' + ((r.exceptionDetails.exception && r.exceptionDetails.exception.description) || r.exceptionDetails.text)); } + return r.result.value; +} +const targets = async (port) => { try { return await (await fetch('http://127.0.0.1:' + port + '/json/list')).json(); } catch (e) { return []; } }; +/** Wait for a RESULT, with a deadline — never for a length of time. */ +async function until(what, fn, ms) { + const end = Date.now() + (ms || 30000); + for (;;) { const v = await fn(); if (v) { return v; } if (Date.now() > end) { throw new Error('timed out waiting for ' + what); } await sleep(200); } +} + +async function main() { + if (typeof WebSocket !== 'function' || typeof fetch !== 'function') { console.error('Needs Node 22 or newer (for its built-in WebSocket).'); return 2; } + let product; + try { product = JSON.parse(fs.readFileSync(path.join(VSCODE_DIR, 'product.json'), 'utf8')); } + catch (e) { console.error('No editor build at ' + VSCODE_DIR + '. Run ./scripts/run-dev.sh once in the checkout that has vscode/, then pass that folder with --vscode.'); return 2; } + const exe = process.platform === 'darwin' + ? path.join(VSCODE_DIR, '.build', 'electron', product.nameLong + '.app', 'Contents', 'MacOS', product.nameShort) + : path.join(VSCODE_DIR, '.build', 'electron', product.applicationName); + if (!fs.existsSync(exe)) { console.error('The editor has not been built yet (' + exe + ' is missing). Run ./scripts/run-dev.sh once.'); return 2; } + + fs.mkdirSync(OUT, { recursive: true }); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'lc-aura-check-')); + const profile = path.join(tmp, 'profile'), exts = path.join(tmp, 'extensions'), workspace = path.join(tmp, 'workspace'), sessions = path.join(tmp, 'sessions'); + for (const d of [path.join(profile, 'User'), exts, workspace, sessions]) { fs.mkdirSync(d, { recursive: true }); } + fs.writeFileSync(path.join(workspace, 'README.md'), '# aura\n'); + const provider = await startProvider(); + const settingsFile = path.join(profile, 'User', 'settings.json'); + const settings = { + 'levelcode.ai.providerMode': 'byok', 'levelcode.ai.provider': 'custom', 'levelcode.ai.baseURL': 'http://127.0.0.1:' + provider.address().port + '/v1', 'levelcode.ai.model': 'stand-in', + 'levelcode.ai.sessions.dir': sessions, 'levelcode.ai.verify.enabled': false, 'workbench.startupEditor': 'none', 'telemetry.telemetryLevel': 'off', 'window.restoreWindows': 'none' + }; + if (THEME) { settings['workbench.colorTheme'] = THEME; } + fs.writeFileSync(settingsFile, JSON.stringify(settings, null, 2)); + const debugPort = 9300 + Math.floor(Math.random() * 600); + const args = ['.', '--user-data-dir=' + profile, '--extensions-dir=' + exts, '--extensionDevelopmentPath=' + EXT, '--remote-debugging-port=' + debugPort, + '--disable-extension=GitHub.copilot-chat', '--disable-extension=vscode.vscode-api-tests', '--new-window', '--skip-welcome', '--skip-release-notes', '--disable-workspace-trust', + '--disable-backgrounding-occluded-windows', '--disable-renderer-backgrounding', '--disable-features=CalculateNativeWinOcclusion', workspace]; + console.log('Starting the editor (a window will open): ' + path.relative(process.cwd(), exe)); + const child = cp.spawn(exe, args, { cwd: VSCODE_DIR, env: Object.assign({}, process.env, { NODE_ENV: 'development', VSCODE_DEV: '1', VSCODE_CLI: '1' }), stdio: 'ignore' }); + + const failures = []; let passed = 0; + const check = (name, ok, detail) => { if (ok) { passed++; console.log(' ✓ ' + name); } else { failures.push(name + (detail ? ' — ' + detail : '')); console.log(' ✗ ' + name + (detail ? ' — ' + detail : '')); } }; + let wb = null, chat = null, chatTarget = null, code = 0; + try { + const page = await until('the editor window', async () => (await targets(debugPort)).find((t) => t.type === 'page' && /workbench/.test(t.url)), 120000); + wb = await connect(page.webSocketDebuggerUrl); + await wb.send('Runtime.enable'); await wb.send('Page.enable'); + await until('the workbench', () => evalIn(wb, '!!document.querySelector(".monaco-workbench .part.editor")'), 60000); + const findChat = async () => { + for (const t of (await targets(debugPort)).filter((x) => /^vscode-webview:/.test(x.url))) { + let c; try { c = await connect(t.webSocketDebuggerUrl); } catch (e) { continue; } + try { await c.send('Runtime.enable'); if (await evalIn(c, '(() => { const f = document.getElementById("active-frame"); const d = f && f.contentDocument; return !!(d && d.getElementById("input") && d.getElementById("log") && d.getElementById("composer")); })()')) { chatTarget = t; return c; } } catch (e) { /* not ready */ } + c.close(); + } + return null; + }; + let asked = 0; + chat = await until('the chat', async () => { + const c = await findChat(); if (c) { return c; } + if (asked++ % 10 === 9) { for (const type of ['keyDown', 'keyUp']) { await wb.send('Input.dispatchKeyEvent', { type, modifiers: process.platform === 'darwin' ? 6 : 3, key: 'i', code: 'KeyI', windowsVirtualKeyCode: 73 }); } } + return null; + }, 90000); + const D = 'document.getElementById("active-frame").contentDocument'; + const W = 'document.getElementById("active-frame").contentWindow'; + const front = async () => { + const tab = await evalIn(wb, `(() => { const t = Array.from(document.querySelectorAll('.tabs-container .tab')).find((e) => /LevelCode AI/.test(e.getAttribute('aria-label') || e.textContent || '')); if (!t || t.classList.contains('active')) { return null; } const r = t.getBoundingClientRect(); return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; })()`); + if (tab) { for (const type of ['mousePressed', 'mouseReleased']) { await wb.send('Input.dispatchMouseEvent', { type, x: tab.x, y: tab.y, button: 'left', clickCount: 1 }); } await sleep(700); } + }; + // The composer's corner, close up: what the eye sees of the ring. + const shot = async (name) => { + await front(); + const s = await wb.send('Page.captureScreenshot', { format: 'png' }); + fs.writeFileSync(path.join(OUT, name + '.png'), Buffer.from(s.data, 'base64')); + }; + const key = async (k, codeName, vk) => { for (const type of ['keyDown', 'keyUp']) { await wb.send('Input.dispatchKeyEvent', { type, key: k, code: codeName, windowsVirtualKeyCode: vk, nativeVirtualKeyCode: vk === 13 ? 36 : undefined, text: type === 'keyDown' && k === 'Enter' ? '\r' : undefined }); } }; + const send = (goal) => evalIn(chat, `(() => { const d = ${D}; const i = d.getElementById('input'); i.focus(); i.value = ${JSON.stringify(goal)}; i.dispatchEvent(new Event('input', { bubbles: true })); d.getElementById('send').click(); return true; })()`); + // The ring as the page draws it: the body's state class, and the line (::after) and glow (::before). + const ring = () => evalIn(chat, `(() => { const d = ${D}, w = ${W}, c = d.getElementById('composer'); + const a = w.getComputedStyle(c, '::after'), b = w.getComputedStyle(c, '::before'); + return { cls: ['aura-working', 'aura-waiting'].filter((x) => d.body.classList.contains(x)), lineOpacity: +a.opacity, glowOpacity: +b.opacity, + anim: a.animationName, angle: a.getPropertyValue('--aura-angle').trim(), turn: c.style.getPropertyValue('--aura-turn'), bg: a.backgroundImage.slice(0, 40) }; })()`); + await front(); + + // ---- working ---- + await send('think about it'); + const first = await until('the key prompt, or the first request', async () => { + if (requests.length) { return 'request'; } + return (await evalIn(wb, '(() => { const q = document.querySelector(".quick-input-widget"); return !!(q && q.style.display !== "none" && q.querySelector("input")); })()')) ? 'prompt' : null; + }, 30000); + if (first === 'prompt') { + const box = '(() => { const q = document.querySelector(".quick-input-widget"); const i = q && q.style.display !== "none" ? q.querySelector("input") : null; if (!i) { return null; } i.focus(); return { typed: i.value.length }; })()'; + await until('the key to be accepted', async () => { + const st = await evalIn(wb, box); + if (!st) { return true; } + if (!st.typed) { await wb.send('Input.insertText', { text: 'stand-in-key-for-a-local-provider' }); await sleep(200); return false; } + await key('Enter', 'Enter', 13); await sleep(500); return false; + }, 30000); + } + await until('the held first turn', () => requests.length >= 1 || null, 30000); + await sleep(700); // the ring fades in over 0.4 s + const w1 = await ring(); await sleep(350); const w2 = await ring(); + check('working: the ring is on, visible and animating', w1.cls.join() === 'aura-working' && w1.lineOpacity === 1 && w1.glowOpacity === 1 && w1.anim === 'aura-turn', JSON.stringify(w1)); + check('…and it is moving: its angle changed in 0.35 s', !!w1.angle && !!w2.angle && w1.angle !== w2.angle, w1.angle + ' → ' + w2.angle); + check('…at a speed set from the box (1.6–3 s a turn)', /^(1\.[6-9]\d|2\.\d\d|3\.00)s$/.test(w1.turn), w1.turn); + await shot('aura-1-working'); + await sleep(800); + await shot('aura-1-working-later'); // the same run 0.8 s on: the light has moved round the box + release(); + await until('the run to end', () => evalIn(chat, `(() => /Done: thought/.test(${D}.getElementById('log').innerText))()`), 30000); + await sleep(700); + const idle = await ring(); + check('idle: when the run is over the ring is gone', idle.cls.length === 0 && idle.lineOpacity === 0 && idle.glowOpacity === 0, JSON.stringify(idle)); + + // ---- waiting ---- + await send('run something'); + await until('the held turn', () => requests.filter((r) => /^run/.test(r.goal)).length >= 1 || null, 30000); + await sleep(600); + release(); + await until('the approval card', () => evalIn(chat, `(() => !!${D}.querySelector('.tl-ask.asking'))()`), 30000); + await sleep(700); + const wt = await ring(); + check('waiting: on an approval card the ring is whole and still', wt.cls.join() === 'aura-waiting' && wt.lineOpacity === 1 && wt.anim === 'none' && !/conic/.test(wt.bg), JSON.stringify(wt)); + await shot('aura-2-waiting'); + await evalIn(chat, `(() => { ${D}.querySelector('.tl-ask.asking .approve').click(); return true; })()`); + await until('the command to run and the run to end', () => evalIn(chat, `(() => /Done: ran it/.test(${D}.getElementById('log').innerText))()`), 30000); + await sleep(700); + const after = await ring(); + check('…and after the approval and the end of the run, gone again', after.cls.length === 0 && after.lineOpacity === 0, JSON.stringify(after)); + + // ---- reduced motion ---- + await chat.send('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-reduced-motion', value: 'reduce' }] }); + await send('think again'); + await until('the held turn', () => requests.filter((r) => /^think again/.test(r.goal)).length >= 1 || null, 30000); + await sleep(700); + const rm = await ring(); + check('reduced motion: working is a still, whole ring', rm.cls.join() === 'aura-working' && rm.lineOpacity === 1 && rm.anim === 'none' && !/conic/.test(rm.bg), JSON.stringify(rm)); + await shot('aura-3-reduced-motion'); + release(); + await until('the run to end', () => evalIn(chat, `(() => (${D}.getElementById('log').innerText.match(/Done: thought/g) || []).length >= 2)()`), 30000); + await chat.send('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-reduced-motion', value: 'no-preference' }] }); + + // ---- off ---- + fs.writeFileSync(settingsFile, JSON.stringify(Object.assign({}, settings, { 'levelcode.ai.chat.workingAura': false }), null, 2)); + await sleep(1500); // the editor picks the file up and the host sends the config message + await send('think once more'); + await until('the held turn', () => requests.filter((r) => /^think once more/.test(r.goal)).length >= 1 || null, 30000); + await sleep(700); + const off = await ring(); + check('setting off: nothing on the box, even while working', off.cls.length === 0 && off.lineOpacity === 0, JSON.stringify(off)); + release(); + await until('the run to end', () => evalIn(chat, `(() => (${D}.getElementById('log').innerText.match(/Done: thought/g) || []).length >= 3)()`), 30000); + + const thrown = chat.events.filter((e) => e.method === 'Runtime.exceptionThrown').map((e) => ((e.params.exceptionDetails.exception && e.params.exceptionDetails.exception.description) || e.params.exceptionDetails.text).slice(0, 200)); + check('nothing threw in the chat page', thrown.length === 0, thrown.join(' | ')); + fs.writeFileSync(path.join(OUT, 'report.json'), JSON.stringify({ w1, w2, idle, wt, after, rm, off, requests }, null, 2)); + } catch (e) { + failures.push('the run did not finish: ' + (e && e.message || e)); + console.log(' ✗ the run did not finish: ' + (e && e.message || e)); + try { if (release) { release(); } } catch (e2) { /* nothing held */ } + try { if (wb) { const s = await wb.send('Page.captureScreenshot', { format: 'png' }); fs.writeFileSync(path.join(OUT, 'editor-failed.png'), Buffer.from(s.data, 'base64')); } } catch (e2) { /* no picture */ } + if (!wb) { code = 2; } + } finally { + try { if (chat) { chat.close(); } if (wb) { wb.close(); } } catch (e) { /* closed */ } + if (KEEP) { console.log('--keep: the editor is left open. Its profile, sessions and workspace are in ' + tmp); } + else { + try { child.kill('SIGTERM'); } catch (e) { /* gone */ } + await sleep(1500); + try { child.kill('SIGKILL'); } catch (e) { /* gone */ } + try { fs.rmSync(tmp, { recursive: true, force: true }); } catch (e) { /* a temp folder */ } + } + provider.close(); + } + console.log(passed + ' checks passed' + (failures.length ? ', ' + failures.length + ' FAILED' : '.')); + console.log('Screenshots and report: ' + OUT); + return failures.length ? (code || 1) : 0; +} + +main().then((c) => process.exit(c), (e) => { console.error(e); process.exit(2); }); diff --git a/extensions/levelcode-ai/test/planProgressUi.test.js b/extensions/levelcode-ai/test/planProgressUi.test.js index 0f98052..c67769d 100644 --- a/extensions/levelcode-ai/test/planProgressUi.test.js +++ b/extensions/levelcode-ai/test/planProgressUi.test.js @@ -292,7 +292,7 @@ test('ENTER WHILE THE AGENT WORKS: queued — ■ with an empty composer still s assert.ok(body.indexOf('/^\\/skills\\b/i') < body.indexOf('enqueue(t, imgs)'), 'the chat\'s own commands still run at once, never queued'); }); test('THE SEND BUTTON: Stop while busy and empty, Queue once you type, Send otherwise', () => { - const btn = (streaming, text) => { const e = { streaming, text, btn: { classList: { on: null, toggle(c, v) { this.on = v; } }, innerHTML: '', title: '' } }; new Function('env', 'let streaming = env.streaming, awaitingStart = false; const pendingImages = []; const input = { value: env.text }; const sendBtn = env.btn;\n' + extract('busy') + '\n' + extract('syncSendBtn') + '\nsyncSendBtn();')(e); return [e.btn.classList.on, e.btn.title]; }; + const btn = (streaming, text) => { const e = { streaming, text, btn: { classList: { on: null, toggle(c, v) { this.on = v; } }, innerHTML: '', title: '' } }; new Function('env', 'let streaming = env.streaming, awaitingStart = false; const pendingImages = []; const input = { value: env.text }; const sendBtn = env.btn; function syncAura(){}\n' + extract('busy') + '\n' + extract('syncSendBtn') + '\nsyncSendBtn();')(e); return [e.btn.classList.on, e.btn.title]; }; assert.deepStrictEqual(btn(true, ''), [true, 'Stop']); assert.deepStrictEqual(btn(true, 'and the README'), [false, 'Queue — it sends when the agent is ready (Enter)']); assert.deepStrictEqual(btn(false, 'hi'), [false, 'Send (Enter)']); diff --git a/extensions/levelcode-ai/test/workingAura.test.js b/extensions/levelcode-ai/test/workingAura.test.js new file mode 100644 index 0000000..599dd44 --- /dev/null +++ b/extensions/levelcode-ai/test/workingAura.test.js @@ -0,0 +1,184 @@ +/*--------------------------------------------------------------------------------------------- + * The working aura — run: node test/workingAura.test.js + * + * A light travels round the message box while LevelCode works, stands still as a whole ring while it + * waits on the person, and is gone when nothing is happening (media/chat.html, "the working aura"). + * The decision, the speed and the switching are lifted out of the shipped chat.html and run here, the way + * planProgressUi.test.js runs the queue; the CSS and the wiring are pinned from source, since a stylesheet + * and a message switch have nothing to execute outside a browser. The look itself is checked in the real + * editor (scripts/working-aura-editor-check.js). + *--------------------------------------------------------------------------------------------*/ +// @ts-check +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); + +const ROOT = path.join(__dirname, '..'); +const html = fs.readFileSync(path.join(ROOT, 'media', 'chat.html'), 'utf8'); +const ext = fs.readFileSync(path.join(ROOT, 'extension.js'), 'utf8'); +const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')); +const scriptOpen = html.lastIndexOf('', scriptOpen) + 1, html.indexOf('', scriptOpen)); +const css = html.slice(html.indexOf('')); + +/** A function written over several lines: from `function name(` to its closing brace at two spaces. */ +function extract(name) { + const start = script.indexOf(' function ' + name + '('); + assert.ok(start >= 0, 'chat.html no longer defines ' + name + '()'); + const first = script.slice(start, script.indexOf('\n', start)); + if (/\}\s*$/.test(first) && !/\{\s*$/.test(first)) { return first; } + const end = script.indexOf('\n }', start); + assert.ok(end >= 0, 'no closing brace found for ' + name + '()'); + return script.slice(start, end + 4); +} + +let n = 0; +function test(name, fn) { fn(); n++; console.log(' ok - ' + name); } + +// ---- the decision --------------------------------------------------------------------------------- +// eslint-disable-next-line no-new-func +const { auraState, auraDuration } = new Function(extract('auraState') + '\n' + extract('auraDuration') + '\nreturn { auraState, auraDuration };')(); + +test('STATE: off when idle or turned off; working while busy; waiting while busy but on the person', () => { + assert.strictEqual(auraState(false, false, true), 'off', 'idle'); + assert.strictEqual(auraState(false, true, true), 'off', 'a leftover card with no run is not "waiting"'); + assert.strictEqual(auraState(true, false, true), 'working'); + assert.strictEqual(auraState(true, true, true), 'waiting'); + assert.strictEqual(auraState(true, false, false), 'off', 'turned off in settings'); + assert.strictEqual(auraState(true, true, false), 'off'); + assert.strictEqual(auraState(true, false, undefined), 'off', 'before the config arrives (a hoisted var) it stays off'); +}); + +test('SPEED: one turn takes 1.6–3 s, longer for a bigger box but slower than the box grows', () => { + assert.strictEqual(auraDuration(0, 0), 1.6); + assert.strictEqual(auraDuration(100000, 100000), 3); + assert.strictEqual(auraDuration(NaN, undefined), 1.6, 'an unmeasured box gets the floor, not NaN'); + const narrow = auraDuration(320, 90), wide = auraDuration(1200, 110); + assert.ok(narrow < wide, 'a wider box takes longer per turn'); + assert.ok(wide / narrow < 1200 / 320, 'but not in proportion: the light moves faster round a wide box'); + assert.ok(narrow >= 1.6 && wide <= 3); +}); + +// ---- the switching ------------------------------------------------------------------------------------ +/** syncAura with a stand-in page: the body's classes, the box's style, and the busy / parked state. */ +function page(state) { + const classes = new Set(), style = {}, env = Object.assign({ busy: false, parked: false, width: 700, height: 100, throws: false }, state); + const document = { body: { classList: { toggle: (c, on) => { if (on) { classes.add(c); } else { classes.delete(c); } } } } }; + const composer = { + getBoundingClientRect: () => { if (env.throws) { throw new Error('not laid out'); } return { width: env.width, height: env.height }; }, + style: { setProperty: (k, v) => { style[k] = v; } } + }; + // eslint-disable-next-line no-new-func + const sync = new Function('env', 'document', 'composer', + 'var auraEnabled = true; var auraShown = "off";\n' + + 'function busy(){ return env.busy; } function agentParked(){ return env.parked; }\n' + + extract('auraState') + '\n' + extract('auraDuration') + '\n' + extract('syncAura') + + '\nreturn { sync: syncAura, enable: (v) => { auraEnabled = v; }, shown: () => auraShown };')(env, document, composer); + return Object.assign(sync, { env, classes, style }); +} + +test('SWITCH: busy turns the light on, a card stops it, the answer starts it again, the end turns it off', () => { + const p = page({}); + p.sync(); + assert.deepStrictEqual([...p.classes], []); + p.env.busy = true; p.sync(); + assert.deepStrictEqual([...p.classes], ['aura-working']); + p.env.parked = true; p.sync(); + assert.deepStrictEqual([...p.classes], ['aura-waiting'], 'an approval card: still, not spinning'); + p.env.parked = false; p.sync(); + assert.deepStrictEqual([...p.classes], ['aura-working']); + p.env.busy = false; p.sync(); + assert.deepStrictEqual([...p.classes], [], 'the run is over: nothing left on the box'); +}); + +test('SWITCH: the speed is set from the box when the light starts, and not touched while it turns', () => { + const p = page({ busy: true, width: 320, height: 90 }); + p.sync(); + assert.strictEqual(p.style['--aura-turn'], auraDuration(320, 90).toFixed(2) + 's'); + p.env.width = 1400; p.sync(); + assert.strictEqual(p.style['--aura-turn'], auraDuration(320, 90).toFixed(2) + 's', 'a change mid-turn would only apply on a restart: it is not made'); + p.env.parked = true; p.sync(); p.env.parked = false; p.sync(); + assert.strictEqual(p.style['--aura-turn'], auraDuration(1400, 90).toFixed(2) + 's', 'the next start measures the box again'); +}); + +test('SWITCH: turned off in settings, nothing shows — and turning it back on mid-run shows it at once', () => { + const p = page({ busy: true }); + p.enable(false); p.sync(); + assert.deepStrictEqual([...p.classes], []); + p.enable(true); p.sync(); + assert.deepStrictEqual([...p.classes], ['aura-working']); +}); + +test('SWITCH: a page not laid out yet does not throw — a decoration must never stop the chat', () => { + const p = page({ busy: true, throws: true }); + assert.doesNotThrow(() => p.sync()); + assert.strictEqual(p.shown(), 'off', 'nothing was half-applied'); + p.env.throws = false; p.sync(); + assert.deepStrictEqual([...p.classes], ['aura-working'], 'the next change draws it'); +}); + +// ---- the stylesheet -------------------------------------------------------------------------------- +/** The declarations of one rule, by its exact selector. */ +// The aura's own block of the stylesheet: #composer has an earlier rule (its layout) that is not the aura's. +const auraCss = css.slice(css.indexOf('/* ---- the working aura'), css.indexOf('@media (forced-colors: active) { #composer') + 80); +function rule(selector) { + const i = auraCss.indexOf('\n ' + selector.trim() + ' {'); // at the start of a line, so "a, b {" is not "b {" + assert.ok(i >= 0, 'no rule for ' + selector); + return auraCss.slice(i, auraCss.indexOf('}', i)); +} +test('CSS: the angle is a typed property, so it can be animated; the two rings turn on it together', () => { + assert.match(css, /@property --aura-angle \{ syntax: ''; inherits: false; initial-value: 0deg; \}/); + assert.match(css, /@keyframes aura-turn \{ to \{ --aura-angle: 360deg; \} \}/); + assert.match(rule('#composer::before'), /conic-gradient\(from var\(--aura-angle\)/); + assert.match(rule('#composer::after'), /conic-gradient\(from var\(--aura-angle\)/); + assert.match(rule('body.aura-working #composer::before, body.aura-working #composer::after'), /animation: aura-turn var\(--aura-turn, 2\.4s\) linear infinite/); +}); +test('CSS: the rings never take a click, and are cut to the edge so the box\'s inside is untouched', () => { + const both = rule('#composer::before, #composer::after'); + assert.match(both, /pointer-events: none/); + assert.match(both, /opacity: 0/, 'hidden unless a state class is on'); + assert.match(both, /mask: linear-gradient\(#000 0 0\) content-box, linear-gradient\(#000 0 0\); mask-composite: exclude/); + assert.match(both, /-webkit-mask-composite: xor/); + assert.match(rule(' #composer'), /position: relative/); + assert.ok(!/#composer \{[^}]*(isolation|z-index)/.test(css), 'a stacking context on the box would trap the mode menu under the transcript'); +}); +test('CSS: waiting does not move; reduced motion never moves; high contrast draws no glow', () => { + assert.ok(!/body\.aura-waiting[^{]*\{[^}]*animation/.test(css), 'the waiting ring animates'); + const reduced = css.slice(css.indexOf('@media (prefers-reduced-motion: reduce) {\n /* No motion')); + assert.match(reduced.slice(0, 600), /body\.aura-working #composer::before, body\.aura-working #composer::after \{ animation: none; \}/); + assert.match(css, /@media \(forced-colors: active\) \{ #composer::before, #composer::after \{ display: none; \} \}/); +}); +test('CSS: colours come from the theme — progress while working, focus while waiting — never a fixed hue', () => { + assert.match(rule(' #composer'), /--aura-color: var\(--vscode-progressBar-background, var\(--accent\)\)/); + assert.match(rule('body.aura-waiting #composer::after'), /var\(--accent\)/); + assert.ok(!/body\.aura-waiting #composer::before/.test(auraCss), 'waiting has a glow: it should read as paused, not busy'); + const colours = auraCss.replace(/linear-gradient\(#000 0 0\)/g, '').replace(/var\(--vscode-foreground, #fff\)/g, ''); + assert.ok(!/#[0-9a-f]{3,8}\b|rgba?\(/i.test(colours), 'a fixed colour in the aura'); +}); + +// ---- the wiring ------------------------------------------------------------------------------------- +test('WIRING: every change of "busy" reaches the aura — the run starting and ending, a message on its way', () => { + assert.match(extract('setStreaming'), /syncAura\(\);/); + assert.match(extract('syncSendBtn'), /syncAura\(\);/); + assert.match(script, /new MutationObserver\(function\(\)\{ if \(auraShown !== 'off' \|\| busy\(\)\) \{ syncAura\(\); \} \}\)\n\s*\.observe\(log, \{ childList: true, subtree: true, attributes: true, attributeFilter: \['class'\] \}\);/, + 'an approval card or a question appearing / answered must reach the aura without each card calling it'); + assert.match(script, /var auraEnabled = true;/, 'var, not let: syncSendBtn can reach syncAura before its line has run'); +}); +test('WIRING: the setting reaches the page on every config message, and the page obeys it', () => { + assert.match(ext, /const workingAura = cfg\.get\('chat\.workingAura', true\) !== false;/); + const posts = ext.match(/type: 'config'[\s\S]*?\}\);/g) || []; + assert.ok(posts.length >= 2, 'both config messages found'); + for (const p of posts) { assert.match(p, /workingAura/, 'a config message without workingAura'); } + assert.match(script, /if \(typeof m\.workingAura === 'boolean'\)\{ auraEnabled = m\.workingAura; syncAura\(\); \}/); +}); +test('SETTING: on by default, and the user\'s own (a folder cannot switch it)', () => { + const sections = Array.isArray(pkg.contributes.configuration) ? pkg.contributes.configuration : [pkg.contributes.configuration]; + const props = Object.assign({}, ...sections.map((s) => s.properties || {})); + const s = props['levelcode.ai.chat.workingAura']; + assert.ok(s, 'the setting is declared'); + assert.deepStrictEqual([s.type, s.default, s.scope], ['boolean', true, 'application']); +}); + +console.log('\nworkingAura: ' + n + ' tests passed.');