feat: 2.14.0 — Eithan terminal toolbar, voice UX, Dross improvements framework

- Terminal renamed Eithan: mobile font A−/A+ (per-URL ttyd opts), same-origin
  xterm Copy/Paste buttons, scroll-to-live, touch-default 17px
- Dross voice: no keyboard pop after transcribe (fine-pointer only focus),
  autogrow textarea to ~5 lines, live amplitude meter on the mic while recording
- Dross improvements: propose_improvement tool (CSS layer, exfil-sanitized,
  owner-approved, per-improvement rollback/restore), public /improvements.css,
  Settings panel. External MCP registry unchanged (no tool leak).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
root
2026-06-11 23:35:32 +10:00
parent 859dedb668
commit 3bd8ea399c
18 changed files with 338 additions and 23 deletions

View File

@@ -243,10 +243,40 @@ export async function render(main) {
});
iconSetsWrap.appendChild(isToggle);
// ---- Dross improvements: versioned CSS changes, approve / rollback / restore ----
const improvementsBody = el('div', {});
const STATUS_BADGE = { pending: '⏳ pending', active: '✅ active', rolled_back: '↩ rolled back', rejected: '✕ rejected' };
async function renderImprovements() {
let rows = [];
try { rows = await api.get('/api/improvements'); } catch { /* fresh DB */ }
const act = (id, verb) => async () => {
try { await api.post(`/api/improvements/${id}/${verb}`, {}); } catch { /* surfaced below */ }
renderImprovements();
// re-pull the live stylesheet so the change lands without a page reload
const link = document.getElementById('dross-improvements');
if (link) link.href = '/improvements.css?v=' + Date.now();
};
mount(improvementsBody,
rows.length === 0 ? el('div', { class: 'muted' }, 'Nothing yet. Ask Dross to improve something — each approved change lands here, individually reversible.') : null,
...rows.map((r) => el('div', { class: 'imp-row' },
el('span', { class: 'imp-status s-' + r.status }, STATUS_BADGE[r.status] ?? r.status),
el('div', { class: 'imp-main' },
el('div', {}, r.summary),
el('small', { class: 'muted' }, `${new Date(r.created_at).toLocaleString()} · ${r.css_len} chars of css`)),
el('span', { class: 'imp-actions' },
r.status === 'pending' ? el('button', { class: 'primary sm', onclick: act(r.id, 'approve') }, 'Approve') : null,
r.status === 'pending' ? el('button', { class: 'ghost sm', onclick: act(r.id, 'reject') }, 'Reject') : null,
r.status === 'active' ? el('button', { class: 'ghost sm danger', onclick: act(r.id, 'rollback') }, 'Roll back') : null,
r.status === 'rolled_back' ? el('button', { class: 'ghost sm', onclick: act(r.id, 'restore') }, 'Restore') : null)))
);
}
renderImprovements();
mount(main,
el('h1', { class: 'view-h1' }, '◆ Settings'),
section('Theming', 'Recolour the interface. Pick a colour to preview it live, choose a preset, then Save to persist. Reset returns to the default Blackflame palette.', themingBody()),
section('Dross', "Your companion's look and voice. Avatar, accent colour, his personality (system prompt), and how voice clips behave.", drossBody()),
section('Dross improvements', 'Changes Dross has made to the Void itself — each one versioned, owner-approved, and instantly reversible.', improvementsBody),
section('API Tokens', 'Bearer tokens for agents (e.g. the MCP external-research agent). The secret is shown once at creation.', tokensBody),
section('Agents', 'Seeded Cradle agents and what each is allowed to do.', agentsBody),
section('Icon Sets', 'Upload or delete custom icon packs for device and service icons.', iconSetsWrap),

View File

@@ -1,21 +1,61 @@
// #/terminal — embeds the CT 300 web terminal (ttyd → persistent tmux/claude),
// same-origin under /terminal so it shares the Void's CF Access session.
// #/terminal — "Eithan": the CT 300 web terminal (ttyd → persistent tmux/claude),
// same-origin under /terminal so it shares the Void's CF Access session AND lets
// us reach the xterm instance for mobile copy/paste.
import { el, mount } from '../dom.js';
const FS_KEY = 'void_term_fontsize';
export async function render(main) {
// bigger default on touch screens; user-adjustable, remembered
let fontSize = Number(localStorage.getItem(FS_KEY))
|| (matchMedia('(pointer: coarse)').matches ? 17 : 14);
const frame = el('iframe', {
id: 'term-frame',
class: 'term-frame',
allow: 'clipboard-read; clipboard-write'
});
const setSrc = () => { frame.src = `/terminal/?fontSize=${fontSize}`; };
setSrc();
// ttyd exposes its xterm as window.term; the same-origin proxy makes it reachable.
const term = () => { try { return frame.contentWindow?.term ?? null; } catch { return null; } };
const note = el('span', { class: 'muted', style: { fontSize: '11px' } },
'eithan @ ct300 · persistent tmux · swipe to scroll');
const flash = (msg) => { const old = note.textContent; note.textContent = msg;
setTimeout(() => { note.textContent = old; }, 1600); };
const bump = (d) => {
fontSize = Math.max(10, Math.min(24, fontSize + d));
localStorage.setItem(FS_KEY, String(fontSize));
setSrc(); // reload reattaches tmux; the session itself persists
};
mount(main,
el('div', { class: 'term-bar' },
el('span', { class: 'term-title' }, '◆ Terminal'),
el('span', { class: 'muted', style: { fontSize: '11px' } }, 'claude @ ct300 · persistent tmux'),
el('button', { class: 'ghost', style: { marginLeft: 'auto' }, onclick: () => {
const f = document.getElementById('term-frame'); if (f) f.src = f.src;
} }, '⟳ Reconnect')
el('span', { class: 'term-title' }, '◆ Eithan'),
note,
el('span', { style: { marginLeft: 'auto', display: 'flex', gap: '6px' } },
el('button', { class: 'ghost', title: 'smaller text', onclick: () => bump(-2) }, 'A'),
el('button', { class: 'ghost', title: 'larger text', onclick: () => bump(+2) }, 'A+'),
el('button', { class: 'ghost', title: 'copy terminal selection', onclick: async () => {
const sel = term()?.getSelection?.();
if (!sel) return flash('select text first (touch: long-press, then drag)');
try { await navigator.clipboard.writeText(sel); flash('copied ✓'); }
catch { flash('clipboard needs the https domain'); }
} }, '⧉ Copy'),
el('button', { class: 'ghost', title: 'paste clipboard into terminal', onclick: async () => {
const t = term();
if (!t) return flash('terminal not ready');
try { t.paste(await navigator.clipboard.readText()); }
catch { flash('clipboard needs the https domain'); }
} }, '⇩ Paste'),
el('button', { class: 'ghost', title: 'jump to live output', onclick: () => {
term()?.scrollToBottom?.(); frame.contentWindow?.focus();
} }, '↓ Live'),
el('button', { class: 'ghost', title: 'reconnect', onclick: setSrc }, '⟳')
)
),
el('iframe', {
id: 'term-frame',
src: '/terminal/',
class: 'term-frame',
allow: 'clipboard-read; clipboard-write'
})
frame
);
}