Spaces:
Running
Running
File size: 14,325 Bytes
518343a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 | // Live editor + terminal panel that drives the same plan/tool/reflection loop
// the @szl/a11oy-code CLI runs. The scripted demo block was previously a
// marketing surface; this panel is real — every keystroke flows through
// Ouroboros, Lutar, MirrorEval and writes to the in-browser proof ledger.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Link } from 'wouter';
import {
proof, runTurn, startSession,
type ProofEntry, type Session, type Turn, type VirtualFS,
} from '../lib/a11oy-code-engine';
const T = {
bg: '#0a0a0a', surface: 'rgba(255,255,255,0.025)', border: 'rgba(255,255,255,0.08)',
borderStrong: 'rgba(255,255,255,0.12)', text: '#f5f5f5', dim: '#8a8a8a',
muted: '#5e5e5e', accent: '#c9b787', good: '#28c840', bad: '#ef4444',
mono: "var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)",
};
const SEED_FILES: Record<string, string> = {
'README.md': '# scratch workspace\n\nThis is the in-browser sandbox the /code panel hands to a11oy-code.\nTry: "read README.md", "edit README.md", "lookup formula lutar", "show proof ledger".\n',
'src/eta-calculator.ts': "// ETA calculator — refactor target.\nexport function eta(distanceKm: number, knots: number) {\n return distanceKm / (knots * 1.852);\n}\n",
'package.json': '{\n "name": "scratch",\n "version": "0.0.0"\n}\n',
};
interface LineOut {
kind: 'sys' | 'usr' | 'agt' | 'gate' | 'div' | 'err';
text: string;
}
function turnToLines(turn: Turn): LineOut[] {
const lines: LineOut[] = [];
lines.push({ kind: 'usr', text: `→ ${turn.user}` });
const planSteps = turn.plan.steps.map(s => s.tool).join(' › ');
lines.push({ kind: 'agt', text: ` ▶ Plan (${turn.plan.steps.length} step${turn.plan.steps.length === 1 ? '' : 's'}): ${planSteps}` });
if (turn.plan.revised_by) {
lines.push({ kind: 'agt', text: ` ▶ Ouroboros revised plan @ ${turn.plan.revised_at?.slice(11, 19)}` });
}
lines.push({
kind: 'agt',
text: ` ▶ Lutar pick: ${turn.tool.name} score=${turn.tool.score.toFixed(3)}${turn.tool.why ? ` · ${turn.tool.why}` : ''}`,
});
if (turn.result.ok === false) {
lines.push({ kind: 'err', text: ` ✗ ${turn.tool.name} failed: ${String(turn.result.error ?? 'error')}` });
} else {
const r = turn.result;
if (r.kind === 'file' && typeof r.content === 'string') {
const preview = (r.content as string).split('\n').slice(0, 6).join('\n');
lines.push({ kind: 'agt', text: ` ✓ ${turn.tool.name} → file (${(r.content as string).length} bytes)` });
for (const ln of preview.split('\n')) lines.push({ kind: 'agt', text: ` │ ${ln}` });
} else if (r.kind === 'dir' && Array.isArray(r.entries)) {
lines.push({ kind: 'agt', text: ` ✓ ${turn.tool.name} → dir (${(r.entries as string[]).length} entries)` });
for (const e of (r.entries as string[]).slice(0, 8)) lines.push({ kind: 'agt', text: ` · ${e}` });
} else if (typeof r.stdout === 'string') {
lines.push({ kind: 'agt', text: ` ✓ ${turn.tool.name} → ${(r.stdout as string).slice(0, 200)}` });
} else if (Array.isArray(r.hits)) {
lines.push({ kind: 'agt', text: ` ✓ ${turn.tool.name} → ${(r.hits as unknown[]).map(h => typeof h === 'string' ? h : JSON.stringify(h)).join(', ')}` });
} else if (Array.isArray(r.entries)) {
lines.push({ kind: 'agt', text: ` ✓ ${turn.tool.name} → ${(r.entries as unknown[]).length} ledger entries` });
} else {
lines.push({ kind: 'agt', text: ` ✓ ${turn.tool.name} → ok` });
}
}
lines.push({ kind: 'gate', text: ` ⨡ MirrorEval score=${turn.score.toFixed(3)} · proof appended` });
lines.push({ kind: 'div', text: '─'.repeat(72) });
return lines;
}
const KIND_COLOR: Record<LineOut['kind'], string> = {
sys: T.muted, usr: T.text, agt: T.dim, gate: T.accent, div: 'rgba(255,255,255,0.06)', err: T.bad,
};
interface Props { chatPath: string }
export function A11oyCodeLivePanel({ chatPath }: Props) {
const [session, setSession] = useState<Session | null>(null);
const [activePath, setActivePath] = useState<string>('README.md');
const [editorBuf, setEditorBuf] = useState<string>(SEED_FILES['README.md']);
const [input, setInput] = useState<string>('');
const [lines, setLines] = useState<LineOut[]>([]);
const [busy, setBusy] = useState(false);
const [ledger, setLedger] = useState<ProofEntry[]>([]);
const fsRef = useRef<VirtualFS>({ files: { ...SEED_FILES } });
const termRef = useRef<HTMLDivElement>(null);
useEffect(() => {
let cancelled = false;
void (async () => {
const s = await startSession({ provider: 'local-stub', model: 'a11oy-code-web', autonomy: false });
if (cancelled) return;
setSession(s);
setLines([
{ kind: 'sys', text: `a11oy-code web v1.0 — same engine as @szl/a11oy-code CLI` },
{ kind: 'sys', text: `session ${s.id} · provider: local-stub · model: a11oy-code-web` },
{ kind: 'sys', text: `tools: read, write, edit, shell, git, web_search, hf_search, thesis_lookup, formula_lookup, proof_query, subagent, finish` },
{ kind: 'div', text: '─'.repeat(72) },
]);
})();
return () => { cancelled = true; };
}, []);
useEffect(() => proof.subscribe(setLedger), []);
useEffect(() => {
if (termRef.current) termRef.current.scrollTop = termRef.current.scrollHeight;
}, [lines]);
const sessionLedger = useMemo(
() => session ? ledger.filter(e => !e.session || e.session === session.id) : ledger,
[ledger, session],
);
const onSelectFile = useCallback((p: string) => {
fsRef.current.files[activePath] = editorBuf;
setActivePath(p);
setEditorBuf(fsRef.current.files[p] ?? '');
}, [activePath, editorBuf]);
const onSubmit = useCallback(async (e?: React.FormEvent) => {
e?.preventDefault();
if (!session || !input.trim() || busy) return;
fsRef.current.files[activePath] = editorBuf;
const text = input.trim();
setInput('');
setBusy(true);
try {
const turn = await runTurn(session, text, { fs: fsRef.current });
setLines(prev => [...prev, ...turnToLines(turn)]);
// If the engine edited the active file, reflect changes in the editor.
const refreshed = fsRef.current.files[activePath];
if (refreshed !== undefined && refreshed !== editorBuf) setEditorBuf(refreshed);
// Surface unknown new files in the picker
setSession({ ...session });
} catch (err) {
setLines(prev => [...prev, { kind: 'err', text: `[turn-error] ${(err as Error).message}` }]);
} finally {
setBusy(false);
}
}, [session, input, busy, activePath, editorBuf]);
const handoffHref = session
? `${chatPath}?session=${encodeURIComponent(session.id)}&from=code`
: chatPath;
const fileNames = useMemo(() => Object.keys(fsRef.current.files).sort(), [session, lines]);
return (
<div style={{
borderRadius: 10, overflow: 'hidden', border: `1px solid ${T.border}`,
background: '#050505', display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1.1fr)', gap: 1, backgroundColor: T.border,
}}>
{/* Editor pane */}
<div style={{ background: '#050505', display: 'flex', flexDirection: 'column', minHeight: 480 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.625rem 0.875rem', borderBottom: `1px solid ${T.border}`, background: 'rgba(255,255,255,0.02)' }}>
<span style={{ fontSize: '0.625rem', fontFamily: T.mono, color: T.muted, textTransform: 'uppercase', letterSpacing: '0.14em' }}>Editor</span>
<select
value={activePath}
onChange={(ev) => onSelectFile(ev.target.value)}
style={{
marginLeft: 'auto', background: 'transparent', color: T.text, fontFamily: T.mono,
fontSize: '0.6875rem', border: `1px solid ${T.border}`, borderRadius: 4, padding: '0.2rem 0.4rem',
}}
>
{fileNames.map(f => <option key={f} value={f} style={{ background: T.bg }}>{f}</option>)}
</select>
</div>
<textarea
value={editorBuf}
onChange={(ev) => setEditorBuf(ev.target.value)}
spellCheck={false}
style={{
flex: 1, background: '#050505', color: T.text, border: 'none', outline: 'none',
resize: 'none', padding: '1rem', fontFamily: T.mono, fontSize: '0.75rem',
lineHeight: 1.6, minHeight: 360,
}}
/>
</div>
{/* Terminal pane */}
<div style={{ background: '#050505', display: 'flex', flexDirection: 'column', minHeight: 480 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.625rem 0.875rem', borderBottom: `1px solid ${T.border}`, background: 'rgba(255,255,255,0.02)' }}>
<div style={{ display: 'flex', gap: '0.375rem' }}>
<div style={{ width: 9, height: 9, borderRadius: '50%', background: '#ff5f57' }} />
<div style={{ width: 9, height: 9, borderRadius: '50%', background: '#ffbd2e' }} />
<div style={{ width: 9, height: 9, borderRadius: '50%', background: '#28c840' }} />
</div>
<span style={{ fontSize: '0.6875rem', fontFamily: T.mono, color: T.dim, marginLeft: '0.5rem' }}>
a11oy-code · live session
</span>
<span style={{ marginLeft: 'auto', fontSize: '0.5625rem', fontFamily: T.mono, padding: '0.15rem 0.5rem', borderRadius: 3, background: 'rgba(201,183,135,0.1)', color: T.accent, border: '1px solid rgba(201,183,135,0.15)' }}>GOVERNED</span>
<Link
href={handoffHref}
style={{
fontSize: '0.5625rem', fontFamily: T.mono, padding: '0.2rem 0.55rem', borderRadius: 3,
background: 'rgba(127,179,255,0.08)', color: '#7fb3ff',
border: '1px solid rgba(127,179,255,0.18)', textDecoration: 'none',
}}
title="Hand this session off to /chat"
>Hand off → /chat</Link>
</div>
<div ref={termRef} style={{
flex: 1, padding: '1rem 1rem 0.5rem', fontFamily: T.mono, fontSize: '0.6875rem',
lineHeight: 1.7, overflowY: 'auto', maxHeight: 420,
}}>
{lines.map((line, i) => (
<div key={i} style={{ color: KIND_COLOR[line.kind], fontWeight: line.kind === 'usr' ? 600 : 400, whiteSpace: 'pre-wrap' }}>
{line.text}
</div>
))}
{busy && (
<div style={{ color: T.accent, marginTop: '0.25rem' }}>… running turn through Ouroboros · Lutar · MirrorEval</div>
)}
</div>
<form onSubmit={onSubmit} style={{ display: 'flex', gap: '0.5rem', padding: '0.625rem 0.875rem', borderTop: `1px solid ${T.border}`, background: 'rgba(255,255,255,0.02)' }}>
<span style={{ color: T.accent, fontFamily: T.mono, fontSize: '0.75rem' }}>▸</span>
<input
value={input}
onChange={(ev) => setInput(ev.target.value)}
disabled={!session || busy}
placeholder='try: "read README.md", "edit src/eta-calculator.ts", "lookup formula lutar"'
style={{
flex: 1, background: 'transparent', border: 'none', outline: 'none',
color: T.text, fontFamily: T.mono, fontSize: '0.75rem',
}}
/>
<button type="submit" disabled={!session || busy || !input.trim()} style={{
padding: '0.3rem 0.85rem', borderRadius: 4, fontFamily: T.mono, fontSize: '0.6875rem',
background: busy ? 'rgba(255,255,255,0.04)' : T.text, color: busy ? T.dim : T.bg,
border: `1px solid ${T.borderStrong}`, cursor: busy || !input.trim() ? 'not-allowed' : 'pointer',
}}>{busy ? '…' : 'run'}</button>
</form>
</div>
{/* Proof ledger */}
<div style={{ gridColumn: '1 / -1', background: '#050505', borderTop: `1px solid ${T.border}` }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.625rem 0.875rem', borderBottom: `1px solid ${T.border}`, background: 'rgba(255,255,255,0.02)' }}>
<span style={{ fontSize: '0.625rem', fontFamily: T.mono, color: T.muted, textTransform: 'uppercase', letterSpacing: '0.14em' }}>Proof Ledger</span>
<span style={{ fontSize: '0.5625rem', fontFamily: T.mono, color: T.dim, marginLeft: '0.5rem' }}>
{sessionLedger.length} entr{sessionLedger.length === 1 ? 'y' : 'ies'} · same chain the CLI writes
</span>
</div>
<div style={{ maxHeight: 220, overflowY: 'auto', padding: '0.5rem 0.875rem' }}>
{sessionLedger.length === 0 && (
<div style={{ color: T.muted, fontFamily: T.mono, fontSize: '0.6875rem', padding: '0.5rem 0' }}>
No entries yet — issue a command to populate the chain.
</div>
)}
{sessionLedger.slice(-30).reverse().map(e => (
<div key={e.hash} style={{ display: 'grid', gridTemplateColumns: '88px 130px 1fr 130px', gap: '0.75rem', padding: '0.25rem 0', fontFamily: T.mono, fontSize: '0.625rem', color: T.dim, borderBottom: '1px solid rgba(255,255,255,0.04)' }}>
<span style={{ color: T.muted }}>{e.ts.slice(11, 19)}</span>
<span style={{ color: T.accent }}>{e.kind}</span>
<span style={{ color: T.dim, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{summarize(e)}
</span>
<span style={{ color: T.muted, textAlign: 'right' }}>0x{e.hash}</span>
</div>
))}
</div>
</div>
</div>
);
}
function summarize(e: ProofEntry): string {
const parts: string[] = [];
if (typeof e.tool === 'string') parts.push(`tool=${e.tool}`);
if (typeof e.score === 'number') parts.push(`score=${(e.score as number).toFixed(3)}`);
if (typeof e.ok === 'boolean') parts.push(`ok=${e.ok}`);
if (e.kind === 'plan' || e.kind === 'plan_revised') {
const steps = (e.plan as { steps?: { tool: string }[] } | undefined)?.steps;
if (steps) parts.push(`steps=${steps.map(s => s.tool).join('›')}`);
}
if (e.kind === 'session_start') parts.push('opened');
return parts.join(' ');
}
|