Skip to content

Commit 35e4041

Browse files
committed
feat(chat): a light travels round the message box while LevelCode works
Like the border beam in VS Code's own chat: while a run is busy (thinking, writing, running a command, checking its edits, or a message on its way) a short bright arc circles the message box over a faint whole ring, in the theme's progress colour. While the run waits on the person (a command to approve, a question to answer) it stops and dims to a thin ring with no glow, so a paused run does not look busy. Idle, there is nothing. Reduced motion draws a still ring; high contrast draws none. How: two pseudo-elements on #composer, masked to a band on its edge so the box's inside and its clicks are untouched, turned by one typed @Property angle. syncAura() picks off / working / waiting from busy() and agentParked(); setStreaming and syncSendBtn call it, and a MutationObserver on the transcript catches an approval card or a question appearing or being answered. One turn takes 1.6-3 s, set from the box's size when the light starts and growing slower than the box, so a wide panel stays calm. Setting: levelcode.ai.chat.workingAura (boolean, application scope, on by default), sent with both config messages. The browser edition shows it too: it uses the same chat page, and the rings inherit the skin's 24px corners. Tested: test/workingAura.test.js lifts auraState, auraDuration and syncAura from chat.html and runs them (the states, the speed's bounds, the switching, the setting turned on mid-run, no throw before layout), and pins the CSS and the wiring from source; each of 30 one-edit mutations fails it. planProgressUi.test.js's lifted syncSendBtn gets a syncAura stub.
1 parent ed77319 commit 35e4041

5 files changed

Lines changed: 269 additions & 3 deletions

File tree

‎extensions/levelcode-ai/extension.js‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3291,13 +3291,15 @@ function sendConfigToWebview() {
32913291
// "unconstrained": the way to widen the measure is a large number, not 0.
32923292
const proseSize = clampSetting(cfg.get('chat.fontSize', 0), 8, 24);
32933293
const proseWidth = clampSetting(cfg.get('chat.proseWidth', 0), 320, 2000);
3294+
// The light round the message box while LevelCode works (media/chat.html, "the working aura").
3295+
const workingAura = cfg.get('chat.workingAura', true) !== false;
32943296
if (providerMode() === 'gateway' && cloudSignedIn) {
32953297
const model = gatewayModel();
32963298
post({
32973299
type: 'config', provider: 'gateway', proseSize, proseWidth, model: gatewayModelLabel(model), modelId: model,
32983300
providerLabel: 'LevelCode Cloud', contextLimit: contextLimitFor('openai', capsModel(model)),
32993301
gateway: true, plan: cloudPlanName() || 'Free', paid: isPaidCloudPlan(cloudPlanName()),
3300-
groupActivity: groupActivity, canSeeImages: supportsVisionForModel('openai', capsModel(model)), maxImages: maxImagesPerMessage()
3302+
groupActivity: groupActivity, workingAura, canSeeImages: supportsVisionForModel('openai', capsModel(model)), maxImages: maxImagesPerMessage()
33013303
});
33023304
return;
33033305
}
@@ -3306,7 +3308,7 @@ function sendConfigToWebview() {
33063308
// Carry the model's context window so the footer meter updates the moment the model changes.
33073309
// canSeeImages travels with the model so the composer can refuse an attachment BEFORE anything is
33083310
// typed and lost, rather than after a send that the provider would reject.
3309-
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() });
3311+
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() });
33103312
}
33113313

33123314
/**

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

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -984,6 +984,50 @@
984984
}
985985
#composer.focused { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
986986

987+
/* ---- the working aura: a light that travels round the message box while LevelCode works ----
988+
Thinking, streaming, running a command, verifying: a short bright arc circles the box, trailing a soft
989+
glow over a faint whole ring. Waiting on you (a command to approve, a question to answer) it stops and
990+
dims to a thin ring with no glow — paused, because nothing happens until you act. Off: nothing at all.
991+
Two rings on the box's own edge, cut to a band by a mask so the box's inside is untouched: ::after is
992+
the sharp line, ::before the blurred glow under it. Both turn on one angle, so the glow never lags.
993+
Colours come from the theme: the progress colour while working (the theme's own "busy"), the focus
994+
colour while waiting. The speed is set per box (syncAura) so a wide panel does not look frantic. */
995+
@property --aura-angle { syntax: '<angle>'; inherits: false; initial-value: 0deg; }
996+
@keyframes aura-turn { to { --aura-angle: 360deg; } }
997+
#composer {
998+
position: relative;
999+
--aura-color: var(--vscode-progressBar-background, var(--accent));
1000+
--aura-head: color-mix(in srgb, var(--aura-color) 62%, var(--vscode-foreground, #fff));
1001+
}
1002+
#composer::before, #composer::after {
1003+
content: ''; position: absolute; border-radius: inherit; pointer-events: none; opacity: 0;
1004+
transition: opacity .4s ease;
1005+
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); -webkit-mask-composite: xor;
1006+
mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); mask-composite: exclude;
1007+
}
1008+
#composer::before {
1009+
inset: -6px; padding: 6px; filter: blur(5px);
1010+
background: conic-gradient(from var(--aura-angle), transparent 0deg 255deg,
1011+
color-mix(in srgb, var(--aura-color) 38%, transparent) 318deg, color-mix(in srgb, var(--aura-head) 80%, transparent) 354deg, transparent 360deg);
1012+
}
1013+
#composer::after {
1014+
inset: -1px; padding: 2px;
1015+
/* A faint whole ring (the box is busy) with the bright arc travelling over it. */
1016+
background: conic-gradient(from var(--aura-angle), color-mix(in srgb, var(--aura-color) 22%, transparent) 0deg 262deg,
1017+
color-mix(in srgb, var(--aura-color) 60%, transparent) 320deg, var(--aura-head) 355deg, color-mix(in srgb, var(--aura-color) 22%, transparent) 360deg);
1018+
}
1019+
body.aura-working #composer::before, body.aura-working #composer::after { opacity: 1; animation: aura-turn var(--aura-turn, 2.4s) linear infinite; }
1020+
/* Waiting on you: paused — a thin, dim ring with no glow, weaker than working and than the box's own focus. */
1021+
body.aura-waiting #composer::after { opacity: 1; padding: 1px; background: color-mix(in srgb, var(--accent) 45%, transparent); }
1022+
@media (prefers-reduced-motion: reduce) {
1023+
/* No motion: working is a whole, bright ring with its glow — still distinct from waiting's dim one. */
1024+
body.aura-working #composer::before, body.aura-working #composer::after { animation: none; }
1025+
body.aura-working #composer::after { background: color-mix(in srgb, var(--aura-color) 75%, transparent); }
1026+
body.aura-working #composer::before { background: color-mix(in srgb, var(--aura-color) 35%, transparent); }
1027+
}
1028+
/* High contrast draws its own borders; a soft glow there is noise. The working bar still says it. */
1029+
@media (forced-colors: active) { #composer::before, #composer::after { display: none; } }
1030+
9871031
#chips { display: flex; flex-wrap: wrap; gap: 5px; align-items: center; }
9881032
.chip {
9891033
display: inline-flex; align-items: center; gap: 5px;
@@ -3444,6 +3488,33 @@
34443488
}
34453489
// Waiting on you (a command to approve, a question to answer): the step in progress stops spinning.
34463490
function agentParked(){ return !!pendingApproval || !!log.querySelector('.questions:not(.answered)'); }
3491+
// ── the working aura (the light round the message box; its look is in the CSS) ──
3492+
// `var`, not `let`: syncAura is reached from syncSendBtn, which can run before this line does, and a
3493+
// decoration must never stop the chat with a temporal-dead-zone error.
3494+
var auraEnabled = true; // levelcode.ai.chat.workingAura, from the config message
3495+
var auraShown = 'off';
3496+
/** What the aura shows: off (idle, or turned off), working (LevelCode is busy), waiting (busy, but on you). */
3497+
function auraState(isBusy, parked, enabled){ return (!enabled || !isBusy) ? 'off' : parked ? 'waiting' : 'working'; }
3498+
/** Seconds per turn: grows with the box's perimeter, but slower than it, so a wide panel stays calm. */
3499+
function auraDuration(w, h){
3500+
const p = 2 * (Math.max(0, w || 0) + Math.max(0, h || 0));
3501+
return Math.min(3, Math.max(1.6, 0.55 + 0.06 * Math.sqrt(p)));
3502+
}
3503+
function syncAura(){
3504+
try {
3505+
const s = auraState(busy(), agentParked(), auraEnabled);
3506+
if (s === auraShown) { return; }
3507+
// The speed is fixed when the light starts: a duration changed mid-turn would only apply on a restart.
3508+
if (s === 'working') { const r = composer.getBoundingClientRect(); composer.style.setProperty('--aura-turn', auraDuration(r.width, r.height).toFixed(2) + 's'); }
3509+
auraShown = s;
3510+
document.body.classList.toggle('aura-working', s === 'working');
3511+
document.body.classList.toggle('aura-waiting', s === 'waiting');
3512+
} catch (e) { /* before the page is wired up: the next change draws it */ }
3513+
}
3514+
// An approval card or a question appearing, or being answered, is a class or a node in the transcript:
3515+
// watched here, so no card has to remember to tell the aura.
3516+
new MutationObserver(function(){ if (auraShown !== 'off' || busy()) { syncAura(); } })
3517+
.observe(log, { childList: true, subtree: true, attributes: true, attributeFilter: ['class'] });
34473518
function renderTurnBox(){
34483519
if (turnBox.parentNode !== log) { log.appendChild(turnBox); } // the log is rebuilt on reset / resume
34493520
const v = turnView(turn, agentParked());
@@ -3670,6 +3741,7 @@
36703741
sendBtn.classList.toggle('stop', stop);
36713742
sendBtn.innerHTML = stop ? '&#9632;' : '&#8629;'; // ■ stops the run, ↵ sends (or queues)
36723743
sendBtn.title = stop ? 'Stop' : queueing ? 'Queue — it sends when the agent is ready (Enter)' : 'Send (Enter)';
3744+
syncAura(); // busy() changes with the button: a message on its way already counts as LevelCode working
36733745
}
36743746
function updateReviewBar(count, add, del){
36753747
if (count > 0){
@@ -3811,6 +3883,7 @@
38113883
if (workbarEl){ workbarEl.hidden = !on; } // the working bar is visible for the ENTIRE run
38123884
if (on){ setWork('Working…'); }
38133885
else { setTimeout(drainQueue, 60); } // the run is over: a queued message goes next
3886+
syncAura();
38143887
}
38153888
// Shell-style history recall in the composer: ↑ = previous sent message, ↓ = newer (back to your draft).
38163889
// Session-only. In multiline text, ↑/↓ recall only when the caret is on the first/last line (arrows still move otherwise).
@@ -5069,6 +5142,7 @@
50695142
shell.style.setProperty('--prose-size', m.proseSize ? m.proseSize + 'px' : '');
50705143
shell.style.setProperty('--prose-max', m.proseWidth ? m.proseWidth + 'px' : '');
50715144
}
5145+
if (typeof m.workingAura === 'boolean'){ auraEnabled = m.workingAura; syncAura(); }
50725146
const label = m.provider === 'claude' ? prettyModel(m.model) : m.model;
50735147
const mEl = document.getElementById('model');
50745148
mEl.innerHTML = esc(label) + ' <span class="caret">▾</span>';

‎extensions/levelcode-ai/package.json‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,12 @@
452452
"default": true,
453453
"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."
454454
},
455+
"levelcode.ai.chat.workingAura": {
456+
"type": "boolean",
457+
"scope": "application",
458+
"default": true,
459+
"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."
460+
},
455461
"levelcode.cloud.endpoint": {
456462
"type": "string",
457463
"default": "https://levelcode.ai",

‎extensions/levelcode-ai/test/planProgressUi.test.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -292,7 +292,7 @@ test('ENTER WHILE THE AGENT WORKS: queued — ■ with an empty composer still s
292292
assert.ok(body.indexOf('/^\\/skills\\b/i') < body.indexOf('enqueue(t, imgs)'), 'the chat\'s own commands still run at once, never queued');
293293
});
294294
test('THE SEND BUTTON: Stop while busy and empty, Queue once you type, Send otherwise', () => {
295-
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]; };
295+
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]; };
296296
assert.deepStrictEqual(btn(true, ''), [true, 'Stop']);
297297
assert.deepStrictEqual(btn(true, 'and the README'), [false, 'Queue — it sends when the agent is ready (Enter)']);
298298
assert.deepStrictEqual(btn(false, 'hi'), [false, 'Send (Enter)']);

0 commit comments

Comments
 (0)