Files
zero/docs/design/zero_tui_lime.html
6fb0231df8 TUI scrollback re-architecture, Lime chat surface, and deep-audit fixes (#158)
* docs(design): add Lime TUI prototype and rebuild spec

* tui: remove the zeroline skin system

One design remains in the tree ahead of the Lime rebuild: the zeroline
package, its TUI view/skin plumbing (Options.Skin/ThemeVariant/ThemeDark,
model skin branches, theme picker), and the zeroline CLI subcommand are
gone. /theme keeps its shell-only message.

* tui: replace the cyan palette with the Lime token table

theme.go is rebuilt around the Lime design tokens (docs/design/
zero_tui_lime.html): near-black panels, one lime accent, solid tint
stand-ins for the prototype's rgba overlays. Existing render sites map
onto the new role styles (ink/line/userPrompt/toolName/toolArg) without
layout changes; no hex exists outside theme.go.

* tui: build the Lime chat-surface components

Title bar with brand badge + context window, empty state replacing the
deleted splash (block-art 0, tagline, 1-3 starter suggestions), stream
blocks (user prompt gutter, muted interim with cursor, final-answer
accent rail, done line, bordered notes), tool cards with diff/read/
bash/grep bodies and MiniDot run spinner, borderless composer with
run-state hints, and the grouped status line. Final answers are marked
at append time; resolved tool calls collapse into their result cards.

* tui: fix Stage 2 review findings

Adversarial review of the chat-surface commit confirmed 15 issues, all
fixed here:
- wrapPlainText sliced wide runes by rune count: CJK/emoji prose could
  panic the TUI or emit lines ~2x the measure; split at width instead
- a cancelled run's streamingText leaked into the next turn's interim
  block; cancelRun now clears it
- orphaned tool-call cards (cancelled/errored turns) re-animated with
  the spinner on any later run; the glyph is now scoped to the call's
  own runID
- starter-suggestion digit capture was active while the chips were off
  screen (/clear mid-run); the guard now mirrors the render condition
- prompt submits while a deferred Ctrl+C quit is draining could start a
  run the quit would orphan; submission is blocked while exiting
- the running placeholder advertised ctrl+c (which quits) as the
  interrupt key; it now says esc
- read-card parser expected 'N: text' but read_file emits 'N | text',
  so the gutter and L-range never rendered on real output
- toolCard borders were one column short of the body rows
- diff tint bands now span the full row; NEW FILE renders green on
  addBg; preamble/no-newline lines are unnumbered meta and no longer
  shift hunk numbering
- card bodies and system notes paint the panel surface; the head gains
  the separate faintest arg column (grep target + pattern)
- title-bar overflow now truncates the minimal candidate instead of
  rendering the deleted design's 'ZERO … READY' line
- untracked the stray snake-game.html (left on disk, ignored)

* tui: restyle the interactive surfaces for Lime

Permission prompts render as the amber permission card (PERMISSION
badge, risk readout, key chips); resolved prompts collapse to one line
(allowed once/always/denied · tool) and unprompted allows fold into the
tool card's [auto] tag. Ask-user and spec-review prompts share the card
language with line borders. Autocomplete and picker overlays move onto
the panel/selection tints with the accent ❯ marker; model-picker rows
gain the provider dot and ctx · KEY_ENV metadata from the registry and
provider catalog. /resume renders its list as stacked session cards
(id + age, title, meta). Key handling is untouched everywhere.

* tui: adaptive width tiers, doc updates, final polish

Width tiers re-evaluate from the live width: ≥100 renders the full
spec; 80–99 drops the tool-arg column, header ctx, and the status
'interactive' group; 58–79 drops the diff/read gutters, bares the
badge, and keeps provider+tokens+mode; <58 renders a single-segment
header, rail-less cards, and a mode-only status line. Table-driven
tests cover {58,70,80,100,120} plus a frame-wide invariant that no
emitted line exceeds the terminal at any tier. Card bodies expand tabs
so width math holds. Dead splash-chip machinery (startupCommandNames,
startupOrder) is gone; README's TUI section now describes Lime.

* tui: fix final-review findings across Stages 3-4

A second adversarial review confirmed 14 issues, all addressed:
- permission collapse and tool-card pairing are now run-scoped (rcKey):
  providers with repeating synthetic ToolCallIDs (Gemini gemini_tool_N)
  could attribute a decision or result to a different run's call
- rehydrated always-grants no longer mislabel as 'allowed once'
  (GrantMatched fallback)
- the width invariant now actually holds everywhere: empty-state
  tagline/hint, ask-user rows, permission detail blobs, pending image
  chips, and the sessions trailing hint are wrapped or fitted; the
  invariant test renders the empty state and all of those rows at
  widths 24-120
- every card (permission, ask-user, spec-review, sessions, notes)
  drops side borders on tiny terminals, not just tool cards
- /resume card fields are sanitized against separator bytes from
  user-controlled titles
- the picker's selected row paints its gap so the selBg band is solid
- dead splash helpers (normalizedStartupWidth, countLines, nonEmpty,
  unreachable rowWelcome branch) and stale /theme comments are gone
- README no longer claims transcript scrolling keys or Tab-accept
  behavior that never existed; chatWidth documents its 24-col floor

Tag tui-lime-s4 moves to this commit.

* audit: deep-audit report, TUI scrollback re-architecture, and cross-module fixes

TUI: settled-row flush frontier prints history to native terminal
scrollback (tea.Println, ordered + ack-serialized); View() renders only
the live tail. Full diffs (400-line flush cap) and OSC 8 file:// links
land in history; streamed interim text, cancellation markers, ask_user
exchanges, and composer input history are preserved; run-scoped dedup
keys fix Gemini's repeating tool-call ids; /exit, Ctrl+C-after-Esc, and
cross-session late flushes no longer orphan or contaminate checkpoints.

Cross-module: zero search panic + offset mapping, usage report
robustness, 529 retry, SSE keep-alive watchdog, registry corrections,
update --check on dev builds, MCP ping + mcp list --json secret
redaction, sandbox.network config knob, doctor apiKey presence, atomic
config writes, C1-safe notifications, rune-safe truncation everywhere,
gofmt + vet CI gates, dead glamour dependency dropped.

Full audit report: docs/audit/2026-06-10-deep-audit.md (175 findings).

Co-Authored-By: Claude Fable 5 <[email protected]>

* tui: hide malformed ask_user tool errors

* fix tui malformed tool retries

* fix max-turn finalization

* upgrade tui agent guardrails

* fix coderabbit guardrail findings

---------

Co-authored-by: Claude Fable 5 <[email protected]>
Co-authored-by: Vasanthdev2004 <[email protected]>
2026-06-10 17:18:30 +05:30

1775 lines
82 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>ZERO — CLI coding agent</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=IBM+Plex+Mono:wght@400;500;600&family=Space+Mono:wght@400;700&family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet" />
<style>
:root{
--bg:#070708; --panel:#0e0e10; --panel-2:#121215; --panel-3:#17171b;
--line:#242429; --line-2:#2e2e34;
--ink:#ececee; --muted:#8b8b93; --faint:#5b5b63; --faintest:#3a3a40;
--accent:#caff3f;
--green:#5dd1a4; --green-d:#1f6b4d; --green-bg:rgba(93,209,164,.09);
--red:#ff7a7a; --red-d:#7a2a2a; --red-bg:rgba(255,122,122,.09);
--amber:#ffc25c; --blue:#7db4ff;
--term-font:'JetBrains Mono', ui-monospace, monospace;
--ui:'Space Grotesk', system-ui, sans-serif;
}
*{box-sizing:border-box;}
html,body{margin:0;height:100%;}
body{
background:#000;
font-family:var(--ui);
color:var(--ink);
-webkit-font-smoothing:antialiased;
overflow:hidden;
}
::selection{background:color-mix(in oklab, var(--accent) 35%, transparent); color:#fff;}
/* ---------------- stage / window ---------------- */
.stage{
height:100vh; width:100vw; display:grid; place-items:center;
padding:clamp(0px, 3vh, 40px) clamp(0px, 3vw, 60px);
background:
radial-gradient(120% 90% at 50% -10%, #131318 0%, #070708 55%, #030303 100%);
}
.win{
width:min(1120px, 100%); height:min(880px, 100%);
display:flex; flex-direction:column;
background:var(--bg);
border:1px solid var(--line);
border-radius:14px;
overflow:hidden;
box-shadow:0 1px 0 rgba(255,255,255,.04) inset, 0 40px 120px -30px rgba(0,0,0,.9), 0 0 0 1px rgba(0,0,0,.6);
position:relative;
}
.win.scan::after{
content:""; position:absolute; inset:0; pointer-events:none; z-index:30;
background:repeating-linear-gradient(0deg, rgba(0,0,0,0) 0 2px, rgba(0,0,0,.16) 2px 3px);
mix-blend-mode:multiply; opacity:.5;
}
/* ---------------- title bar ---------------- */
.bar{
display:flex; align-items:center; justify-content:space-between; gap:14px;
height:46px; padding:0 12px 0 16px; flex:0 0 auto;
background:linear-gradient(180deg, #131316, #0d0d0f);
border-bottom:1px solid var(--line);
}
.bar-l{display:flex; align-items:center; gap:9px; font-family:var(--term-font); font-size:12.5px;}
.zmark{
width:20px; height:20px; border-radius:50%;
display:grid; place-items:center;
color:#000; background:var(--accent);
font-family:var(--term-font); font-weight:700; font-size:13px; line-height:1;
box-shadow:0 0 14px -2px color-mix(in oklab, var(--accent) 70%, transparent);
}
.zname{font-weight:700; letter-spacing:.06em; color:var(--ink);}
.bar-sep{color:var(--faintest);}
.bar-cwd{color:var(--muted);}
.bar-r{display:flex; align-items:center; gap:8px;}
/* model menu */
.mm{position:relative;}
.mm-btn{
display:flex; align-items:center; gap:8px; height:28px; padding:0 9px;
background:var(--panel-2); border:1px solid var(--line); border-radius:7px;
color:var(--ink); font-family:var(--term-font); font-size:11.5px; cursor:pointer;
transition:border-color .15s, background .15s;
}
.mm-btn:hover, .mm-btn.on{border-color:var(--line-2); background:var(--panel-3);}
.mm-dot{width:6px; height:6px; border-radius:50%; background:var(--accent); box-shadow:0 0 8px var(--accent);}
.mm-dot[data-local="true"]{background:var(--blue); box-shadow:0 0 8px var(--blue);}
.mm-id{color:var(--ink); white-space:nowrap;}
.mm-btn{white-space:nowrap;}
.mm-ctx{color:var(--faint); font-size:10.5px;}
.mm-car{color:var(--faint); font-size:9px;}
.mm-pop{
position:absolute; top:34px; right:0; z-index:40; width:380px;
background:var(--panel); border:1px solid var(--line-2); border-radius:10px;
box-shadow:0 24px 60px -20px rgba(0,0,0,.85); padding:6px; overflow:hidden;
}
.mm-pop-h{font-family:var(--ui); font-size:10.5px; color:var(--faint); padding:7px 9px 9px; line-height:1.5; letter-spacing:.01em;}
.mm-row{
display:flex; align-items:center; gap:9px; width:100%; padding:8px 9px; border-radius:7px;
background:none; border:0; cursor:pointer; text-align:left; color:var(--ink);
font-family:var(--term-font);
}
.mm-row:hover{background:var(--panel-3);}
.mm-row.sel{background:color-mix(in oklab, var(--accent) 8%, transparent);}
.mm-row-id{font-size:12px; flex:0 0 auto;}
.mm-row-meta{margin-left:auto; display:flex; align-items:center; gap:10px; font-size:10px; color:var(--faint);}
.mm-row-ctx{color:var(--muted);}
.mm-row-key{color:var(--faintest);}
.mm-check{color:var(--accent); font-size:9px;}
/* segmented toggle */
.seg{display:flex; background:var(--panel-2); border:1px solid var(--line); border-radius:7px; padding:2px; height:28px;}
.seg button{
border:0; background:none; color:var(--muted); cursor:pointer; padding:0 11px; border-radius:5px;
font-family:var(--term-font); font-size:11px; transition:.12s;
}
.seg button.on{background:var(--panel-3); color:var(--ink); box-shadow:0 1px 0 rgba(255,255,255,.04);}
.seg button:hover:not(.on){color:var(--ink);}
.tgl{
height:28px; padding:0 10px; border-radius:7px; cursor:pointer;
background:var(--panel-2); border:1px solid var(--line); color:var(--muted);
font-family:var(--term-font); font-size:11px; transition:.15s;
}
.tgl:hover{color:var(--ink);}
.tgl.on{background:color-mix(in oklab, var(--amber) 14%, transparent); border-color:color-mix(in oklab, var(--amber) 45%, transparent); color:var(--amber);}
.tgl-k{letter-spacing:.02em;}
.iconbtn{
height:28px; padding:0 11px; border-radius:7px; cursor:pointer;
background:var(--panel-2); border:1px solid var(--line); color:var(--muted);
font-family:var(--term-font); font-size:11px; transition:.15s;
}
.iconbtn:hover{color:var(--ink); border-color:var(--line-2);}
/* ---------------- stream ---------------- */
.stream{
flex:1 1 auto; overflow-y:auto; padding:22px 22px 28px;
font-family:var(--term-font); font-size:13px; line-height:1.65;
scrollbar-width:thin; scrollbar-color:var(--line-2) transparent;
}
.stream::-webkit-scrollbar{width:9px;}
.stream::-webkit-scrollbar-thumb{background:var(--line-2); border-radius:9px; border:2px solid var(--bg);}
/* empty state */
.empty{height:100%; display:flex; flex-direction:column; align-items:center; justify-content:center; text-align:center; gap:0;}
.empty-mark{
font-family:var(--term-font); font-weight:700; font-size:84px; line-height:1;
color:var(--accent); text-shadow:0 0 40px color-mix(in oklab, var(--accent) 50%, transparent);
margin-bottom:18px;
}
.empty-tag{font-family:var(--term-font); font-size:12.5px; color:var(--muted); letter-spacing:.02em; white-space:nowrap;}
.empty-hint{font-family:var(--term-font); font-size:11.5px; color:var(--faint); margin-top:7px;}
.empty-hint span{color:var(--ink);}
.chips{display:flex; flex-direction:column; gap:8px; margin-top:30px; width:min(440px, 80%);}
.chip{
display:flex; align-items:center; gap:11px; text-align:left; width:100%;
padding:12px 14px; border-radius:9px; cursor:pointer;
background:var(--panel); border:1px solid var(--line); color:var(--ink);
font-family:var(--term-font); font-size:12.5px; transition:.15s;
}
.chip:hover{border-color:color-mix(in oklab, var(--accent) 40%, var(--line)); background:var(--panel-2); transform:translateX(2px);}
.chip-arrow{color:var(--accent); font-weight:700;}
/* user line */
.blk-user{display:flex; gap:10px; margin:18px 0 12px; align-items:baseline;}
.blk-user:first-child{margin-top:0;}
.bu-p{color:var(--accent); font-weight:700;}
.bu-t{color:var(--ink); font-weight:500;}
/* assistant text */
.blk-say{color:var(--muted); margin:2px 0 12px; max-width:74ch; text-wrap:pretty;}
.blk-say .tcur, .blk-final .tcur{display:inline-block; width:.5ch; color:var(--accent); animation:blink 1s steps(2) infinite; margin-left:1px;}
@keyframes blink{50%{opacity:0;}}
/* final answer */
.blk-final{display:flex; gap:12px; margin:6px 0 14px; padding:2px 0;}
.bf-rail{flex:0 0 2px; background:linear-gradient(var(--accent), transparent); border-radius:2px;}
.bf-tx{color:var(--ink); max-width:74ch; text-wrap:pretty;}
/* done line */
.blk-done{display:flex; align-items:center; gap:9px; margin:10px 0 6px; color:var(--faint); font-size:11.5px; white-space:nowrap; flex-wrap:wrap;}
.blk-done .bd-dot{color:var(--green); font-size:9px;}
.blk-done.bad .bd-dot{color:var(--red);}
.bd-sep{color:var(--faintest);}
/* note */
.blk-note{margin:8px 0; font-size:11.5px; padding:8px 11px; border-radius:7px; border:1px dashed var(--line-2);}
.note-sys{color:var(--faint); border-style:solid; border-color:var(--line); background:var(--panel);}
.note-deny{color:var(--red); border-color:color-mix(in oklab, var(--red) 40%, transparent); background:var(--red-bg);}
/* ---------------- tool card ---------------- */
.tool{margin:7px 0; border:1px solid var(--line); border-radius:9px; overflow:hidden; background:var(--panel);}
.tool-running{border-color:color-mix(in oklab, var(--accent) 30%, var(--line));}
.tool-error{border-color:color-mix(in oklab, var(--red) 35%, var(--line));}
.tool-head{display:flex; align-items:center; gap:9px; padding:8px 11px; background:var(--panel-2);}
.tool-ico{color:var(--accent); display:flex;}
.tool-error .tool-ico{color:var(--red);}
.tool-name{font-weight:700; color:var(--ink); font-size:12px;}
.tool-target{color:var(--muted); font-size:12px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;}
.tool-arg{color:var(--faintest); font-size:11px; margin-left:auto; flex:0 0 auto; white-space:nowrap;}
.tool-auto{font-size:9.5px; color:var(--amber); border:1px solid color-mix(in oklab,var(--amber) 35%,transparent); padding:1px 6px; border-radius:4px; flex:0 0 auto; letter-spacing:.03em;}
.tool-arg + .tool-auto{margin-left:8px;}
.tool-status{margin-left:8px; flex:0 0 auto; display:flex; align-items:center;}
.tool-arg + .tool-status{margin-left:8px;}
.tool-body{border-top:1px solid var(--line);}
.ti{flex:0 0 auto;}
/* spinner */
.spin{width:12px; height:12px; display:inline-block; position:relative;}
.spin span{position:absolute; inset:0; border:1.5px solid var(--faintest); border-top-color:var(--accent); border-radius:50%; animation:sp .7s linear infinite;}
@keyframes sp{to{transform:rotate(360deg);}}
.sg{font-size:11px; font-weight:700;}
.sg-ok{color:var(--green);}
.sg-err{color:var(--red);}
/* diff */
.diff-head{display:flex; align-items:center; gap:10px; padding:7px 12px; font-size:11px; background:var(--panel);}
.diff-path{color:var(--ink); font-weight:500;}
.tag{font-size:9.5px; padding:1px 6px; border-radius:4px; letter-spacing:.04em; text-transform:uppercase;}
.tag-new{background:var(--green-bg); color:var(--green); border:1px solid color-mix(in oklab,var(--green) 30%,transparent);}
.diff-stat{margin-left:auto; display:flex; gap:8px; font-size:11px;}
.ds-add{color:var(--green);} .ds-del{color:var(--red);}
.diff-body{font-size:12px; line-height:1.55; padding:4px 0; background:var(--panel);}
.dl{display:flex; padding:0 0; white-space:pre;}
.dl-no{flex:0 0 44px; text-align:right; padding-right:12px; color:var(--faintest); user-select:none; font-size:11px;}
.dl-sign{flex:0 0 16px; text-align:center; user-select:none;}
.dl-tx{flex:1 1 auto; padding-right:12px; overflow-x:auto;}
.dl-add{background:var(--green-bg);} .dl-add .dl-sign{color:var(--green);} .dl-add .dl-tx{color:#bdeed7;}
.dl-del{background:var(--red-bg);} .dl-del .dl-sign{color:var(--red);} .dl-del .dl-tx{color:#f2c4c4;}
.dl-ctx .dl-tx{color:var(--muted);}
/* read */
.read-head{display:flex; align-items:center; gap:10px; padding:7px 12px; font-size:11px; background:var(--panel);}
.read-range{margin-left:auto; color:var(--faint);}
.read-body{font-size:12px; line-height:1.55; padding:4px 0; background:var(--panel);}
.rl{display:flex; white-space:pre;}
.rl-no{flex:0 0 44px; text-align:right; padding-right:12px; color:var(--faintest); user-select:none; font-size:11px;}
.rl-tx{flex:1 1 auto; padding-right:12px; color:var(--muted); overflow-x:auto;}
/* bash */
.bash{background:#0a0a0b; font-size:12px;}
.bash-cmd{display:flex; gap:9px; padding:9px 12px; color:var(--ink); border-bottom:1px solid var(--line);}
.bash-prompt{color:var(--accent); font-weight:700;}
.bash-out{padding:8px 12px 8px 30px; line-height:1.55; white-space:pre-wrap;}
.bo{color:var(--muted);}
.bash-err .bo{color:#e8b3b3;}
.bash-foot{padding:5px 12px 9px 30px; font-size:10.5px;}
.exit-ok{color:var(--green);} .exit-bad{color:var(--red);}
/* grep */
.grep{background:var(--panel); font-size:12px; padding:4px 0;}
.gl{display:flex; gap:14px; padding:3px 12px;}
.gl-loc{color:var(--blue); flex:0 0 auto;}
.gl-no{color:var(--faint);}
.gl-tx{color:var(--muted); white-space:pre; overflow:hidden; text-overflow:ellipsis;}
.grep-foot{padding:5px 12px; color:var(--faint); font-size:10.5px;}
/* ---------------- permission ---------------- */
.perm{
margin:9px 0; border-radius:10px; overflow:hidden;
border:1px solid color-mix(in oklab, var(--amber) 45%, var(--line));
background:color-mix(in oklab, var(--amber) 6%, var(--panel));
animation:pop .2s ease;
}
@keyframes pop{from{opacity:0; transform:translateY(4px);}}
.perm-top{display:flex; align-items:center; gap:9px; padding:8px 12px; border-bottom:1px solid color-mix(in oklab, var(--amber) 22%, transparent);}
.perm-badge{font-size:9.5px; text-transform:uppercase; letter-spacing:.08em; font-weight:700; color:#000; background:var(--amber); padding:2px 7px; border-radius:4px;}
.perm-risk{font-size:11px; color:var(--amber);}
.perm-msg{padding:12px 12px 13px; font-size:13px; color:var(--ink);}
.perm-tool{color:var(--amber); font-weight:700;}
.perm-target{color:var(--ink);}
.perm-actions{display:flex; gap:8px; padding:0 12px 12px;}
.pbtn{font-family:var(--term-font); font-size:11.5px; padding:7px 12px; border-radius:7px; cursor:pointer; border:1px solid var(--line-2); background:var(--panel-2); color:var(--ink); transition:.13s; display:flex; align-items:center; gap:7px;}
.pbtn kbd{font-family:var(--term-font); font-size:9.5px; padding:1px 5px; border-radius:3px; background:rgba(255,255,255,.07); color:var(--muted); border:1px solid var(--line-2);}
.pbtn-allow{background:var(--accent); color:#000; border-color:var(--accent); font-weight:600;}
.pbtn-allow kbd{background:rgba(0,0,0,.18); color:#000; border-color:transparent;}
.pbtn-allow:hover{filter:brightness(1.08);}
.pbtn-always:hover{border-color:var(--accent); color:var(--accent);}
.pbtn-deny:hover{border-color:var(--red); color:var(--red);}
.perm-resolved{margin:7px 0; font-size:11px; padding:6px 11px; border-radius:7px; color:var(--faint); background:var(--panel); border:1px solid var(--line);}
.perm-resolved.always, .perm-resolved.once{color:var(--green);}
.perm-resolved.deny{color:var(--red);}
/* auto tag on tool */
.tool .tool-arg::after{}
/* ---------------- json mode ---------------- */
.jsonwrap{font-size:12px; line-height:1.7;}
.json-cmd{color:var(--faint); margin-bottom:12px; padding-bottom:10px; border-bottom:1px solid var(--line); white-space:pre-wrap;}
.json-cmd span{color:var(--accent); font-weight:700;}
.jl{white-space:pre-wrap; word-break:break-word;}
.jp{color:var(--faint);}
.jk{color:var(--blue);}
.jc{color:var(--faint);}
.jstr{color:var(--ink);}
.js-type{color:var(--accent);}
.jnum{color:var(--amber);}
.jl-cursor{color:var(--accent); animation:blink 1s steps(2) infinite;}
.cursor-line{height:1px;}
/* ---------------- composer ---------------- */
.composer{
flex:0 0 auto; display:flex; align-items:center; gap:11px;
padding:13px 16px; border-top:1px solid var(--line);
background:linear-gradient(180deg, #0c0c0e, #090909);
}
.cmp-prompt{color:var(--accent); font-family:var(--term-font); font-weight:700; font-size:15px;}
.cmp-in{
flex:1 1 auto; background:none; border:0; outline:none;
color:var(--ink); font-family:var(--term-font); font-size:13.5px;
caret-color:var(--accent);
}
.cmp-in::placeholder{color:var(--faint);}
.cmp-btn{
font-family:var(--term-font); font-size:12px; padding:8px 15px; border-radius:8px; cursor:pointer;
background:var(--accent); color:#000; border:0; font-weight:600; transition:.13s;
}
.cmp-btn:disabled{background:var(--panel-3); color:var(--faint); cursor:default;}
.cmp-btn:not(:disabled):hover{filter:brightness(1.08);}
.cmp-btn.stop{background:var(--panel-3); color:var(--red); border:1px solid color-mix(in oklab,var(--red) 40%,transparent);}
/* ---------------- status bar ---------------- */
.status{
flex:0 0 auto; display:flex; align-items:center; gap:0;
height:30px; padding:0 14px; border-top:1px solid var(--line);
background:#0a0a0b; font-family:var(--term-font); font-size:11px; color:var(--faint);
}
.st-grp{display:flex; align-items:center; gap:6px; padding:0 11px; height:100%; border-right:1px solid var(--line); white-space:nowrap;}
.st-grp:first-child{padding-left:0;}
.st-grp b{color:var(--ink); font-weight:500;}
.st-acc{color:var(--accent); font-size:8px;}
.st-sp{flex:1 1 auto;}
.st-grp.allow{color:var(--green); border-right:0; border-left:1px solid var(--line);}
.st-grp.warn{color:var(--amber); border-right:0; border-left:1px solid var(--line);}
/* ---------------- sessions panel ---------------- */
.sx-wrap{position:fixed; inset:0; z-index:60; pointer-events:none;}
.sx-wrap.open{pointer-events:auto;}
.sx-scrim{position:absolute; inset:0; background:rgba(0,0,0,.5); opacity:0; transition:.25s; backdrop-filter:blur(1px);}
.sx-wrap.open .sx-scrim{opacity:1;}
.sx{
position:absolute; top:0; right:0; height:100%; width:min(440px, 92vw);
background:var(--panel); border-left:1px solid var(--line-2);
transform:translateX(100%); transition:transform .28s cubic-bezier(.4,0,.1,1);
display:flex; flex-direction:column;
}
.sx-wrap.open .sx{transform:translateX(0);}
.sx-head{padding:18px 18px 14px; border-bottom:1px solid var(--line); position:relative;}
.sx-title{font-family:var(--term-font); font-weight:700; font-size:15px; letter-spacing:.04em;}
.sx-sub{display:block; font-family:var(--term-font); font-size:10.5px; color:var(--faint); margin-top:4px;}
.sx-x{position:absolute; top:16px; right:16px; background:var(--panel-2); border:1px solid var(--line); color:var(--muted); font-family:var(--term-font); font-size:10.5px; padding:4px 8px; border-radius:6px; cursor:pointer;}
.sx-x:hover{color:var(--ink);}
.sx-list{flex:1 1 auto; overflow-y:auto; padding:10px;}
.sxr{padding:13px; border-radius:9px; border:1px solid var(--line); background:var(--panel-2); margin-bottom:9px;}
.sxr:hover{border-color:var(--line-2);}
.sxr-top{display:flex; justify-content:space-between; font-family:var(--term-font); font-size:10.5px;}
.sxr-id{color:var(--accent);}
.sxr-when{color:var(--faint);}
.sxr-title{font-family:var(--ui); font-size:13px; color:var(--ink); margin:7px 0 6px; line-height:1.4;}
.sxr-meta{display:flex; align-items:center; gap:7px; font-family:var(--term-font); font-size:10.5px; color:var(--faint);}
.sxr-dot{color:var(--faintest);}
.sxr-actions{display:flex; gap:7px; margin-top:11px;}
.sxr-actions button{flex:1; font-family:var(--term-font); font-size:11px; padding:6px; border-radius:6px; cursor:pointer; background:var(--panel-3); border:1px solid var(--line); color:var(--muted); transition:.13s;}
.sxr-actions button:hover{color:var(--ink); border-color:var(--line-2);}
.sxr-actions button:first-child:hover{color:var(--accent); border-color:color-mix(in oklab,var(--accent) 40%,transparent);}
@media (max-width:640px){
.stage{padding:0;}
.win{border-radius:0; height:100%;}
.bar-cwd, .mm-ctx{display:none;}
.mm-pop{width:300px;}
}
</style>
</head>
<body>
<div id="root"></div>
<script src="https://unpkg.com/[email protected]/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/[email protected]/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/[email protected]/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
<!-- ===== inlined: data ===== -->
<script>
/* ZERO prototype — scripted scenarios, model registry, sessions.
Plain JS: attaches window.ZERO. No JSX here. */
(function () {
// ---- Model registry (BYOK / model-agnostic) -------------------------------
const MODELS = [
{ id: 'anthropic/claude-sonnet-4-5', short: 'claude-sonnet-4-5', provider: 'anthropic', endpoint: '/v1/messages', ctx: '200K', key: 'ANTHROPIC_API_KEY', local: false },
{ id: 'openai/gpt-4o', short: 'gpt-4o', provider: 'openai', endpoint: '/v1/chat/completions', ctx: '128K', key: 'OPENAI_API_KEY', local: false },
{ id: 'openrouter/qwen3-coder', short: 'qwen3-coder', provider: 'openrouter', endpoint: '/v1/chat/completions', ctx: '256K', key: 'OPENROUTER_API_KEY', local: false },
{ id: 'groq/llama-3.3-70b', short: 'llama-3.3-70b', provider: 'groq', endpoint: '/v1/chat/completions', ctx: '128K', key: 'GROQ_API_KEY', local: false },
{ id: 'ollama/qwen3-coder', short: 'qwen3-coder', provider: 'ollama', endpoint: 'localhost:11434', ctx: '32K', key: '— (local)', local: true },
];
// risk: read=low, write/edit/patch=medium, bash=high
const RISK = { read_file: 'low', grep: 'low', glob: 'low', write_file: 'medium', edit_file: 'medium', apply_patch: 'medium', bash: 'high' };
// ---- Helpers to build tool results ----------------------------------------
const D = (s) => s.split('\n').map((line) => {
const t = line[0]; // '+', '-', or ' '
return { t: t === '+' ? '+' : t === '-' ? '-' : ' ', text: line.slice(1) };
});
// ---- Scenarios ------------------------------------------------------------
// Each step: {kind:'say', text} | {kind:'tool', tool:{...}} | {kind:'final', text}
// tool fields: name, target, arg (one-line summary), result {type, ...}
const SCN = {};
// A — add a --version flag
SCN.version = {
match: /version|--version|-v\b/i,
title: 'Add a --version flag to the CLI',
steps: [
{ kind: 'say', text: "I'll add a `--version` flag to ZERO's CLI surface. First, let me see how flags and subcommands are wired up in the cli package." },
{ kind: 'tool', name: 'grep', target: 'internal/cli', arg: "flag\\.|RegisterFlag|AddCommand", result: {
type: 'grep',
matches: [
{ file: 'internal/cli/root.go', line: 41, text: 'fs := flag.NewFlagSet("zero", flag.ContinueOnError)' },
{ file: 'internal/cli/root.go', line: 47, text: 'fs.StringVar(&o.Model, "model", env("ZERO_MODEL"), "model id")' },
{ file: 'internal/cli/root.go', line: 53, text: 'fs.IntVar(&o.MaxSteps, "max-steps", 50, "agent step cap")' },
],
}},
{ kind: 'tool', name: 'read_file', target: 'internal/cli/root.go', arg: 'offset=38 limit=18', result: {
type: 'read', path: 'internal/cli/root.go', start: 38,
lines: [
'func newRootCmd(o *Options) *cobra.Command {',
'\tfs := flag.NewFlagSet("zero", flag.ContinueOnError)',
'\tfs.SetOutput(o.Stderr)',
'',
'\tfs.StringVar(&o.Model, "model", env("ZERO_MODEL"), "model id")',
'\tfs.StringVar(&o.Provider, "provider", env("ZERO_PROVIDER"), "provider")',
'\tfs.IntVar(&o.MaxSteps, "max-steps", 50, "agent step cap")',
'\tfs.DurationVar(&o.Timeout, "timeout", 0, "overall timeout")',
'',
'\tif err := fs.Parse(o.Args); err != nil {',
'\t\treturn nil, err',
'\t}',
],
}},
{ kind: 'say', text: "Clear enough. I'll register a `--version`/`-v` flag that prints the build version and exits 0 before the agent loop starts, then add a test that asserts the output and exit code." },
{ kind: 'tool', name: 'edit_file', target: 'internal/cli/root.go', arg: 'old_string ⇒ new_string', result: {
type: 'diff', path: 'internal/cli/root.go', oldStart: 53, newStart: 53,
diff: D(
" \tfs.IntVar(&o.MaxSteps, \"max-steps\", 50, \"agent step cap\")\n" +
" \tfs.DurationVar(&o.Timeout, \"timeout\", 0, \"overall timeout\")\n" +
"+\tfs.BoolVar(&o.ShowVersion, \"version\", false, \"print version and exit\")\n" +
"+\tfs.BoolVar(&o.ShowVersion, \"v\", false, \"print version and exit (shorthand)\")\n" +
" \n" +
" \tif err := fs.Parse(o.Args); err != nil {\n" +
" \t\treturn nil, err\n" +
" \t}\n" +
"+\n" +
"+\tif o.ShowVersion {\n" +
"+\t\tfmt.Fprintln(o.Stdout, \"zero \"+buildinfo.Version)\n" +
"+\t\treturn nil, errExitOK\n" +
"+\t}"
),
}},
{ kind: 'tool', name: 'write_file', target: 'internal/cli/version_test.go', arg: '38 lines', result: {
type: 'diff', path: 'internal/cli/version_test.go', created: true, oldStart: 0, newStart: 1,
diff: D(
"+package cli\n" +
"+\n" +
"+import (\n" +
"+\t\"bytes\"\n" +
"+\t\"strings\"\n" +
"+\t\"testing\"\n" +
"+)\n" +
"+\n" +
"+func TestVersionFlag(t *testing.T) {\n" +
"+\tfor _, arg := range []string{\"--version\", \"-v\"} {\n" +
"+\t\tvar out bytes.Buffer\n" +
"+\t\tcode := Run([]string{arg}, &out, &out)\n" +
"+\t\tif code != 0 {\n" +
"+\t\t\tt.Fatalf(\"%s: exit = %d, want 0\", arg, code)\n" +
"+\t\t}\n" +
"+\t\tif !strings.HasPrefix(out.String(), \"zero \") {\n" +
"+\t\t\tt.Errorf(\"%s: got %q\", arg, out.String())\n" +
"+\t\t}\n" +
"+\t}\n" +
"+}"
),
}},
{ kind: 'tool', name: 'bash', target: 'go test ./internal/cli/...', arg: 'timeout 120s', result: {
type: 'bash', cmd: 'go test ./internal/cli/...', exit: 0,
out: ['ok \tgithub.com/zero-dev/zero/internal/cli\t0.214s'],
}},
{ kind: 'tool', name: 'bash', target: 'gofmt -l ./internal/cli', arg: 'timeout 120s', result: {
type: 'bash', cmd: 'gofmt -l ./internal/cli', exit: 0, out: ['(no output — formatting clean)'],
}},
{ kind: 'final', text: "Done. Added `--version`/`-v` to the root flag set; it prints `zero <version>` to stdout and exits 0 before the loop. Added `version_test.go` covering both spellings. `go test ./internal/cli/...` passes and `gofmt -l` is clean." },
],
};
// B — go vet failing
SCN.vet = {
match: /vet|failing|fail|lint/i,
title: 'Diagnose the go vet failure',
steps: [
{ kind: 'say', text: "Let me reproduce the failure to see exactly what vet is complaining about." },
{ kind: 'tool', name: 'bash', target: 'go vet ./...', arg: 'timeout 120s', result: {
type: 'bash', cmd: 'go vet ./...', exit: 1, err: true,
out: [
'# github.com/zero-dev/zero/internal/agent',
'internal/agent/loop.go:88:11: loop variable call captured by func literal',
'exit status 1',
],
}},
{ kind: 'say', text: "Classic loop-variable capture: a `go func(){…}` inside the tool-dispatch range closes over `call`, so every goroutine sees the last iteration's value. Let me read the block." },
{ kind: 'tool', name: 'read_file', target: 'internal/agent/loop.go', arg: 'offset=83 limit=12', result: {
type: 'read', path: 'internal/agent/loop.go', start: 83,
lines: [
'\tresults := make([]ToolResult, len(reply.ToolCalls))',
'\tvar wg sync.WaitGroup',
'\tfor i, call := range reply.ToolCalls {',
'\t\twg.Add(1)',
'\t\tgo func() {',
'\t\t\tdefer wg.Done()',
'\t\t\tresults[i] = l.dispatch(ctx, call)',
'\t\t}()',
'\t}',
'\twg.Wait()',
],
}},
{ kind: 'tool', name: 'edit_file', target: 'internal/agent/loop.go', arg: 'old_string ⇒ new_string', result: {
type: 'diff', path: 'internal/agent/loop.go', oldStart: 85, newStart: 85,
diff: D(
" \tfor i, call := range reply.ToolCalls {\n" +
" \t\twg.Add(1)\n" +
"-\t\tgo func() {\n" +
"+\t\tgo func(i int, call ToolCall) {\n" +
" \t\t\tdefer wg.Done()\n" +
" \t\t\tresults[i] = l.dispatch(ctx, call)\n" +
"-\t\t}()\n" +
"+\t\t}(i, call)\n" +
" \t}"
),
}},
{ kind: 'tool', name: 'bash', target: 'go vet ./...', arg: 'timeout 120s', result: {
type: 'bash', cmd: 'go vet ./...', exit: 0, out: ['(no output — vet clean)'],
}},
{ kind: 'final', text: "Fixed. The goroutine in the parallel tool-dispatch loop captured the `call` range variable by reference, so vet flagged it (and it would race). Passing `i` and `call` as parameters pins each iteration's values. `go vet ./...` is now clean." },
],
};
// C — smoke test (from prompt #5)
SCN.hello = {
match: /hello\.txt|hello|cat it|smoke/i,
title: 'Create hello.txt and cat it',
steps: [
{ kind: 'say', text: "Straightforward — I'll write the file, then read it back with cat." },
{ kind: 'tool', name: 'write_file', target: 'hello.txt', arg: '1 line', result: {
type: 'diff', path: 'hello.txt', created: true, oldStart: 0, newStart: 1,
diff: D("+hi"),
}},
{ kind: 'tool', name: 'bash', target: 'cat hello.txt', arg: 'timeout 120s', result: {
type: 'bash', cmd: 'cat hello.txt', exit: 0, out: ['hi'],
}},
{ kind: 'final', text: "Created `hello.txt` containing `hi` and confirmed its contents with `cat`. Two steps, no errors." },
],
};
// Generic fallback for free-typed tasks
SCN.generic = {
match: /.*/,
title: 'Work the requested task',
steps: [
{ kind: 'say', text: "On it. Let me orient in the codebase before making changes." },
{ kind: 'tool', name: 'grep', target: '.', arg: 'TODO|FIXME', result: {
type: 'grep', matches: [
{ file: 'internal/session/store.go', line: 122, text: '// TODO: compaction summariser is pluggable; wire the default Model call' },
{ file: 'internal/providers/anthropic.go', line: 67, text: '// FIXME: handle interleaved tool_use + text blocks' },
],
}},
{ kind: 'tool', name: 'read_file', target: 'internal/agent/loop.go', arg: 'offset=1 limit=10', result: {
type: 'read', path: 'internal/agent/loop.go', start: 1,
lines: [
'package agent',
'',
'// Loop drives the provider-agnostic agent loop: seed messages, call the',
'// model, run any tool calls in order, append results, repeat until the',
'// model returns a final answer, MaxSteps, or ctx is cancelled.',
'func (l *Loop) Run(ctx context.Context, task string) error {',
'\tl.emit(Event{Type: EventStart})',
'\tmsgs := l.seed(task)',
'\tfor step := 0; step < l.MaxSteps; step++ {',
'\t\treply, usage, err := l.model.Complete(ctx, msgs, l.tools)',
],
}},
{ kind: 'tool', name: 'bash', target: 'go test ./...', arg: 'timeout 120s', result: {
type: 'bash', cmd: 'go test ./...', exit: 0,
out: [
'ok \tgithub.com/zero-dev/zero/internal/agent\t0.061s',
'ok \tgithub.com/zero-dev/zero/internal/tools\t0.140s',
'ok \tgithub.com/zero-dev/zero/internal/providers\t0.302s',
'ok \tgithub.com/zero-dev/zero/internal/session\t0.088s',
'ok \tgithub.com/zero-dev/zero/internal/cli\t0.211s',
],
}},
{ kind: 'final', text: "Oriented and ran the suite — everything green. Tell me the specific change you want and I'll make it; I can edit files, apply patches, and run the tests to verify." },
],
};
function pickScenario(task) {
const t = (task || '').trim();
for (const k of ['version', 'vet', 'hello']) {
if (SCN[k].match.test(t)) return SCN[k];
}
return SCN.generic;
}
// ---- Suggested tasks (chips) ----------------------------------------------
const SUGGESTIONS = [
'Add a --version flag to the CLI and a test for it',
'Why is go vet failing?',
"Create hello.txt with 'hi' then cat it",
];
// ---- Sessions (for the slide-over) ----------------------------------------
const SESSIONS = [
{ id: '0a91f3', title: 'Add streaming SSE assembly to OpenAI provider', turns: 14, model: 'anthropic/claude-sonnet-4-5', when: '4m ago', tokens: 48210 },
{ id: 'b72c08', title: 'Context compaction: summarise oldest turns', turns: 9, model: 'openai/gpt-4o', when: '1h ago', tokens: 31980 },
{ id: 'c104de', title: 'Workspace confinement: block symlink escape', turns: 22, model: 'anthropic/claude-sonnet-4-5', when: '3h ago', tokens: 71540 },
{ id: 'd55a1b', title: 'Resolver maps model strings to providers', turns: 6, model: 'ollama/qwen3-coder', when: 'yesterday', tokens: 12044 },
{ id: 'e8830f', title: 'apply_patch rejects on context mismatch', turns: 11, model: 'groq/llama-3.3-70b', when: 'yesterday', tokens: 26700 },
];
window.ZERO = { MODELS, RISK, SCN, pickScenario, SUGGESTIONS, SESSIONS, CWD: '~/code/zero' };
})();
</script>
<!-- ===== inlined: tweaks-panel ===== -->
<script type="text/babel" data-presets="react">
// @ds-adherence-ignore -- omelette starter scaffold (raw elements/hex/px by design)
/* BEGIN USAGE */
// tweaks-panel.jsx
// Reusable Tweaks shell + form-control helpers.
// Exports (to window): useTweaks, TweaksPanel, TweakSection, TweakRow, TweakSlider,
// TweakToggle, TweakRadio, TweakSelect, TweakText, TweakNumber, TweakColor, TweakButton.
//
// Owns the host protocol (listens for __activate_edit_mode / __deactivate_edit_mode,
// posts __edit_mode_available / __edit_mode_set_keys / __edit_mode_dismissed) so
// individual prototypes don't re-roll it. Ships a consistent set of controls so you
// don't hand-draw <input type="range">, segmented radios, steppers, etc.
//
// Usage (in an HTML file that loads React + Babel):
//
// const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
// "primaryColor": "#D97757",
// "palette": ["#D97757", "#29261b", "#f6f4ef"],
// "fontSize": 16,
// "density": "regular",
// "dark": false
// }/*EDITMODE-END*/;
//
// function App() {
// const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
// return (
// <div style={{ fontSize: t.fontSize, color: t.primaryColor }}>
// Hello
// <TweaksPanel>
// <TweakSection label="Typography" />
// <TweakSlider label="Font size" value={t.fontSize} min={10} max={32} unit="px"
// onChange={(v) => setTweak('fontSize', v)} />
// <TweakRadio label="Density" value={t.density}
// options={['compact', 'regular', 'comfy']}
// onChange={(v) => setTweak('density', v)} />
// <TweakSection label="Theme" />
// <TweakColor label="Primary" value={t.primaryColor}
// options={['#D97757', '#2A6FDB', '#1F8A5B', '#7A5AE0']}
// onChange={(v) => setTweak('primaryColor', v)} />
// <TweakColor label="Palette" value={t.palette}
// options={[['#D97757', '#29261b', '#f6f4ef'],
// ['#475569', '#0f172a', '#f1f5f9']]}
// onChange={(v) => setTweak('palette', v)} />
// <TweakToggle label="Dark mode" value={t.dark}
// onChange={(v) => setTweak('dark', v)} />
// </TweaksPanel>
// </div>
// );
// }
//
// TweakRadio is the segmented control for 2–3 short options (auto-falls-back to
// TweakSelect past ~16/~10 chars per label); reach for TweakSelect directly when
// options are many or long. For color tweaks always curate 3-4 options rather than
// a free picker; an option can also be a whole 2–5 color palette (the stored value
// is the array). The Tweak* controls are a floor, not a ceiling — build custom
// controls inside the panel if a tweak calls for UI they don't cover.
/* END USAGE */
// ─────────────────────────────────────────────────────────────────────────────
const __TWEAKS_STYLE = `
.twk-panel{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:280px;
max-height:calc(100vh - 32px);display:flex;flex-direction:column;
transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom right;
background:rgba(250,249,247,.78);color:#29261b;
-webkit-backdrop-filter:blur(24px) saturate(160%);backdrop-filter:blur(24px) saturate(160%);
border:.5px solid rgba(255,255,255,.6);border-radius:14px;
box-shadow:0 1px 0 rgba(255,255,255,.5) inset,0 12px 40px rgba(0,0,0,.18);
font:11.5px/1.4 ui-sans-serif,system-ui,-apple-system,sans-serif;overflow:hidden}
.twk-hd{display:flex;align-items:center;justify-content:space-between;
padding:10px 8px 10px 14px;cursor:move;user-select:none}
.twk-hd b{font-size:12px;font-weight:600;letter-spacing:.01em}
.twk-x{appearance:none;border:0;background:transparent;color:rgba(41,38,27,.55);
width:22px;height:22px;border-radius:6px;cursor:default;font-size:13px;line-height:1}
.twk-x:hover{background:rgba(0,0,0,.06);color:#29261b}
.twk-body{padding:2px 14px 14px;display:flex;flex-direction:column;gap:10px;
overflow-y:auto;overflow-x:hidden;min-height:0;
scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent}
.twk-body::-webkit-scrollbar{width:8px}
.twk-body::-webkit-scrollbar-track{background:transparent;margin:2px}
.twk-body::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);border-radius:4px;
border:2px solid transparent;background-clip:content-box}
.twk-body::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.25);
border:2px solid transparent;background-clip:content-box}
.twk-row{display:flex;flex-direction:column;gap:5px}
.twk-row-h{flex-direction:row;align-items:center;justify-content:space-between;gap:10px}
.twk-lbl{display:flex;justify-content:space-between;align-items:baseline;
color:rgba(41,38,27,.72)}
.twk-lbl>span:first-child{font-weight:500}
.twk-val{color:rgba(41,38,27,.5);font-variant-numeric:tabular-nums}
.twk-sect{font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;
color:rgba(41,38,27,.45);padding:10px 0 0}
.twk-sect:first-child{padding-top:0}
.twk-field{appearance:none;box-sizing:border-box;width:100%;min-width:0;height:26px;padding:0 8px;
border:.5px solid rgba(0,0,0,.1);border-radius:7px;
background:rgba(255,255,255,.6);color:inherit;font:inherit;outline:none}
.twk-field:focus{border-color:rgba(0,0,0,.25);background:rgba(255,255,255,.85)}
select.twk-field{padding-right:22px;
background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path fill='rgba(0,0,0,.5)' d='M0 0h10L5 6z'/></svg>");
background-repeat:no-repeat;background-position:right 8px center}
.twk-slider{appearance:none;-webkit-appearance:none;width:100%;height:4px;margin:6px 0;
border-radius:999px;background:rgba(0,0,0,.12);outline:none}
.twk-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;
width:14px;height:14px;border-radius:50%;background:#fff;
border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
.twk-slider::-moz-range-thumb{width:14px;height:14px;border-radius:50%;
background:#fff;border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
.twk-seg{position:relative;display:flex;padding:2px;border-radius:8px;
background:rgba(0,0,0,.06);user-select:none}
.twk-seg-thumb{position:absolute;top:2px;bottom:2px;border-radius:6px;
background:rgba(255,255,255,.9);box-shadow:0 1px 2px rgba(0,0,0,.12);
transition:left .15s cubic-bezier(.3,.7,.4,1),width .15s}
.twk-seg.dragging .twk-seg-thumb{transition:none}
.twk-seg button{appearance:none;position:relative;z-index:1;flex:1;border:0;
background:transparent;color:inherit;font:inherit;font-weight:500;min-height:22px;
border-radius:6px;cursor:default;padding:4px 6px;line-height:1.2;
overflow-wrap:anywhere}
.twk-toggle{position:relative;width:32px;height:18px;border:0;border-radius:999px;
background:rgba(0,0,0,.15);transition:background .15s;cursor:default;padding:0}
.twk-toggle[data-on="1"]{background:#34c759}
.twk-toggle i{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;
background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s}
.twk-toggle[data-on="1"] i{transform:translateX(14px)}
.twk-num{display:flex;align-items:center;box-sizing:border-box;min-width:0;height:26px;padding:0 0 0 8px;
border:.5px solid rgba(0,0,0,.1);border-radius:7px;background:rgba(255,255,255,.6)}
.twk-num-lbl{font-weight:500;color:rgba(41,38,27,.6);cursor:ew-resize;
user-select:none;padding-right:8px}
.twk-num input{flex:1;min-width:0;height:100%;border:0;background:transparent;
font:inherit;font-variant-numeric:tabular-nums;text-align:right;padding:0 8px 0 0;
outline:none;color:inherit;-moz-appearance:textfield}
.twk-num input::-webkit-inner-spin-button,.twk-num input::-webkit-outer-spin-button{
-webkit-appearance:none;margin:0}
.twk-num-unit{padding-right:8px;color:rgba(41,38,27,.45)}
.twk-btn{appearance:none;height:26px;padding:0 12px;border:0;border-radius:7px;
background:rgba(0,0,0,.78);color:#fff;font:inherit;font-weight:500;cursor:default}
.twk-btn:hover{background:rgba(0,0,0,.88)}
.twk-btn.secondary{background:rgba(0,0,0,.06);color:inherit}
.twk-btn.secondary:hover{background:rgba(0,0,0,.1)}
.twk-swatch{appearance:none;-webkit-appearance:none;width:56px;height:22px;
border:.5px solid rgba(0,0,0,.1);border-radius:6px;padding:0;cursor:default;
background:transparent;flex-shrink:0}
.twk-swatch::-webkit-color-swatch-wrapper{padding:0}
.twk-swatch::-webkit-color-swatch{border:0;border-radius:5.5px}
.twk-swatch::-moz-color-swatch{border:0;border-radius:5.5px}
.twk-chips{display:flex;gap:6px}
.twk-chip{position:relative;appearance:none;flex:1;min-width:0;height:46px;
padding:0;border:0;border-radius:6px;overflow:hidden;cursor:default;
box-shadow:0 0 0 .5px rgba(0,0,0,.12),0 1px 2px rgba(0,0,0,.06);
transition:transform .12s cubic-bezier(.3,.7,.4,1),box-shadow .12s}
.twk-chip:hover{transform:translateY(-1px);
box-shadow:0 0 0 .5px rgba(0,0,0,.18),0 4px 10px rgba(0,0,0,.12)}
.twk-chip[data-on="1"]{box-shadow:0 0 0 1.5px rgba(0,0,0,.85),
0 2px 6px rgba(0,0,0,.15)}
.twk-chip>span{position:absolute;top:0;bottom:0;right:0;width:34%;
display:flex;flex-direction:column;box-shadow:-1px 0 0 rgba(0,0,0,.1)}
.twk-chip>span>i{flex:1;box-shadow:0 -1px 0 rgba(0,0,0,.1)}
.twk-chip>span>i:first-child{box-shadow:none}
.twk-chip svg{position:absolute;top:6px;left:6px;width:13px;height:13px;
filter:drop-shadow(0 1px 1px rgba(0,0,0,.3))}
`;
// ── useTweaks ───────────────────────────────────────────────────────────────
// Single source of truth for tweak values. setTweak persists via the host
// (__edit_mode_set_keys → host rewrites the EDITMODE block on disk).
function useTweaks(defaults) {
const [values, setValues] = React.useState(defaults);
// Accepts either setTweak('key', value) or setTweak({ key: value, ... }) so a
// useState-style call doesn't write a "[object Object]" key into the persisted
// JSON block.
const setTweak = React.useCallback((keyOrEdits, val) => {
const edits = typeof keyOrEdits === 'object' && keyOrEdits !== null
? keyOrEdits : { [keyOrEdits]: val };
setValues((prev) => ({ ...prev, ...edits }));
window.parent.postMessage({ type: '__edit_mode_set_keys', edits }, '*');
// Same-window signal so in-page listeners (deck-stage rail thumbnails)
// can react — the parent message only reaches the host, not peers.
window.dispatchEvent(new CustomEvent('tweakchange', { detail: edits }));
}, []);
return [values, setTweak];
}
// ── TweaksPanel ─────────────────────────────────────────────────────────────
// Floating shell. Registers the protocol listener BEFORE announcing
// availability — if the announce ran first, the host's activate could land
// before our handler exists and the toolbar toggle would silently no-op.
// The close button posts __edit_mode_dismissed so the host's toolbar toggle
// flips off in lockstep; the host echoes __deactivate_edit_mode back which
// is what actually hides the panel.
function TweaksPanel({ title = 'Tweaks', children }) {
const [open, setOpen] = React.useState(false);
const dragRef = React.useRef(null);
const offsetRef = React.useRef({ x: 16, y: 16 });
const PAD = 16;
const clampToViewport = React.useCallback(() => {
const panel = dragRef.current;
if (!panel) return;
const w = panel.offsetWidth, h = panel.offsetHeight;
const maxRight = Math.max(PAD, window.innerWidth - w - PAD);
const maxBottom = Math.max(PAD, window.innerHeight - h - PAD);
offsetRef.current = {
x: Math.min(maxRight, Math.max(PAD, offsetRef.current.x)),
y: Math.min(maxBottom, Math.max(PAD, offsetRef.current.y)),
};
panel.style.right = offsetRef.current.x + 'px';
panel.style.bottom = offsetRef.current.y + 'px';
}, []);
React.useEffect(() => {
if (!open) return;
clampToViewport();
if (typeof ResizeObserver === 'undefined') {
window.addEventListener('resize', clampToViewport);
return () => window.removeEventListener('resize', clampToViewport);
}
const ro = new ResizeObserver(clampToViewport);
ro.observe(document.documentElement);
return () => ro.disconnect();
}, [open, clampToViewport]);
React.useEffect(() => {
const onMsg = (e) => {
const t = e?.data?.type;
if (t === '__activate_edit_mode') setOpen(true);
else if (t === '__deactivate_edit_mode') setOpen(false);
};
window.addEventListener('message', onMsg);
window.parent.postMessage({ type: '__edit_mode_available' }, '*');
return () => window.removeEventListener('message', onMsg);
}, []);
const dismiss = () => {
setOpen(false);
window.parent.postMessage({ type: '__edit_mode_dismissed' }, '*');
};
const onDragStart = (e) => {
const panel = dragRef.current;
if (!panel) return;
const r = panel.getBoundingClientRect();
const sx = e.clientX, sy = e.clientY;
const startRight = window.innerWidth - r.right;
const startBottom = window.innerHeight - r.bottom;
const move = (ev) => {
offsetRef.current = {
x: startRight - (ev.clientX - sx),
y: startBottom - (ev.clientY - sy),
};
clampToViewport();
};
const up = () => {
window.removeEventListener('mousemove', move);
window.removeEventListener('mouseup', up);
};
window.addEventListener('mousemove', move);
window.addEventListener('mouseup', up);
};
if (!open) return null;
return (
<>
<style>{__TWEAKS_STYLE}</style>
<div ref={dragRef} className="twk-panel" data-omelette-chrome=""
style={{ right: offsetRef.current.x, bottom: offsetRef.current.y }}>
<div className="twk-hd" onMouseDown={onDragStart}>
<b>{title}</b>
<button className="twk-x" aria-label="Close tweaks"
onMouseDown={(e) => e.stopPropagation()}
onClick={dismiss}>✕</button>
</div>
<div className="twk-body">
{children}
</div>
</div>
</>
);
}
// ── Layout helpers ──────────────────────────────────────────────────────────
function TweakSection({ label, children }) {
return (
<>
<div className="twk-sect">{label}</div>
{children}
</>
);
}
function TweakRow({ label, value, children, inline = false }) {
return (
<div className={inline ? 'twk-row twk-row-h' : 'twk-row'}>
<div className="twk-lbl">
<span>{label}</span>
{value != null && <span className="twk-val">{value}</span>}
</div>
{children}
</div>
);
}
// ── Controls ────────────────────────────────────────────────────────────────
function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) {
return (
<TweakRow label={label} value={`${value}${unit}`}>
<input type="range" className="twk-slider" min={min} max={max} step={step}
value={value} onChange={(e) => onChange(Number(e.target.value))} />
</TweakRow>
);
}
function TweakToggle({ label, value, onChange }) {
return (
<div className="twk-row twk-row-h">
<div className="twk-lbl"><span>{label}</span></div>
<button type="button" className="twk-toggle" data-on={value ? '1' : '0'}
role="switch" aria-checked={!!value}
onClick={() => onChange(!value)}><i /></button>
</div>
);
}
function TweakRadio({ label, value, options, onChange }) {
const trackRef = React.useRef(null);
const [dragging, setDragging] = React.useState(false);
// The active value is read by pointer-move handlers attached for the lifetime
// of a drag — ref it so a stale closure doesn't fire onChange for every move.
const valueRef = React.useRef(value);
valueRef.current = value;
// Segments wrap mid-word once per-segment width runs out. The track is
// ~248px (280 panel − 28 body pad − 4 seg pad), each button loses 12px
// to its own padding, and 11.5px system-ui averages ~6.3px/char — so 2
// options fit ~16 chars each, 3 fit ~10. Past that (or >3 options), fall
// back to a dropdown rather than wrap.
const labelLen = (o) => String(typeof o === 'object' ? o.label : o).length;
const maxLen = options.reduce((m, o) => Math.max(m, labelLen(o)), 0);
const fitsAsSegments = maxLen <= ({ 2: 16, 3: 10 }[options.length] ?? 0);
if (!fitsAsSegments) {
// <select> emits strings — map back to the original option value so the
// fallback stays type-preserving (numbers, booleans) like the segment path.
const resolve = (s) => {
const m = options.find((o) => String(typeof o === 'object' ? o.value : o) === s);
return m === undefined ? s : typeof m === 'object' ? m.value : m;
};
return <TweakSelect label={label} value={value} options={options}
onChange={(s) => onChange(resolve(s))} />;
}
const opts = options.map((o) => (typeof o === 'object' ? o : { value: o, label: o }));
const idx = Math.max(0, opts.findIndex((o) => o.value === value));
const n = opts.length;
const segAt = (clientX) => {
const r = trackRef.current.getBoundingClientRect();
const inner = r.width - 4;
const i = Math.floor(((clientX - r.left - 2) / inner) * n);
return opts[Math.max(0, Math.min(n - 1, i))].value;
};
const onPointerDown = (e) => {
setDragging(true);
const v0 = segAt(e.clientX);
if (v0 !== valueRef.current) onChange(v0);
const move = (ev) => {
if (!trackRef.current) return;
const v = segAt(ev.clientX);
if (v !== valueRef.current) onChange(v);
};
const up = () => {
setDragging(false);
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
};
return (
<TweakRow label={label}>
<div ref={trackRef} role="radiogroup" onPointerDown={onPointerDown}
className={dragging ? 'twk-seg dragging' : 'twk-seg'}>
<div className="twk-seg-thumb"
style={{ left: `calc(2px + ${idx} * (100% - 4px) / ${n})`,
width: `calc((100% - 4px) / ${n})` }} />
{opts.map((o) => (
<button key={o.value} type="button" role="radio" aria-checked={o.value === value}>
{o.label}
</button>
))}
</div>
</TweakRow>
);
}
function TweakSelect({ label, value, options, onChange }) {
return (
<TweakRow label={label}>
<select className="twk-field" value={value} onChange={(e) => onChange(e.target.value)}>
{options.map((o) => {
const v = typeof o === 'object' ? o.value : o;
const l = typeof o === 'object' ? o.label : o;
return <option key={v} value={v}>{l}</option>;
})}
</select>
</TweakRow>
);
}
function TweakText({ label, value, placeholder, onChange }) {
return (
<TweakRow label={label}>
<input className="twk-field" type="text" value={value} placeholder={placeholder}
onChange={(e) => onChange(e.target.value)} />
</TweakRow>
);
}
function TweakNumber({ label, value, min, max, step = 1, unit = '', onChange }) {
const clamp = (n) => {
if (min != null && n < min) return min;
if (max != null && n > max) return max;
return n;
};
const startRef = React.useRef({ x: 0, val: 0 });
const onScrubStart = (e) => {
e.preventDefault();
startRef.current = { x: e.clientX, val: value };
const decimals = (String(step).split('.')[1] || '').length;
const move = (ev) => {
const dx = ev.clientX - startRef.current.x;
const raw = startRef.current.val + dx * step;
const snapped = Math.round(raw / step) * step;
onChange(clamp(Number(snapped.toFixed(decimals))));
};
const up = () => {
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
};
return (
<div className="twk-num">
<span className="twk-num-lbl" onPointerDown={onScrubStart}>{label}</span>
<input type="number" value={value} min={min} max={max} step={step}
onChange={(e) => onChange(clamp(Number(e.target.value)))} />
{unit && <span className="twk-num-unit">{unit}</span>}
</div>
);
}
// Relative-luminance contrast pick — checkmarks drawn over a swatch need to
// read on both #111 and #fafafa without per-option configuration. Hex input
// only (#rgb / #rrggbb); named or rgb()/hsl() colors fall through to "light".
function __twkIsLight(hex) {
const h = String(hex).replace('#', '');
const x = h.length === 3 ? h.replace(/./g, (c) => c + c) : h.padEnd(6, '0');
const n = parseInt(x.slice(0, 6), 16);
if (Number.isNaN(n)) return true;
const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
return r * 299 + g * 587 + b * 114 > 148000;
}
const __TwkCheck = ({ light }) => (
<svg viewBox="0 0 14 14" aria-hidden="true">
<path d="M3 7.2 5.8 10 11 4.2" fill="none" strokeWidth="2.2"
strokeLinecap="round" strokeLinejoin="round"
stroke={light ? 'rgba(0,0,0,.78)' : '#fff'} />
</svg>
);
// TweakColor — curated color/palette picker. Each option is either a single
// hex string or an array of 1-5 hex strings; the card adapts — a lone color
// renders solid, a palette renders colors[0] as the hero (left ~2/3) with the
// rest stacked in a sharp column on the right. onChange emits the
// option in the shape it was passed (string stays string, array stays array).
// Without options it falls back to the native color input for back-compat.
function TweakColor({ label, value, options, onChange }) {
if (!options || !options.length) {
return (
<div className="twk-row twk-row-h">
<div className="twk-lbl"><span>{label}</span></div>
<input type="color" className="twk-swatch" value={value}
onChange={(e) => onChange(e.target.value)} />
</div>
);
}
// Native <input type=color> emits lowercase hex per the HTML spec, so
// compare case-insensitively. String() guards JSON.stringify(undefined),
// which returns the primitive undefined (no .toLowerCase).
const key = (o) => String(JSON.stringify(o)).toLowerCase();
const cur = key(value);
return (
<TweakRow label={label}>
<div className="twk-chips" role="radiogroup">
{options.map((o, i) => {
const colors = Array.isArray(o) ? o : [o];
const [hero, ...rest] = colors;
const sup = rest.slice(0, 4);
const on = key(o) === cur;
return (
<button key={i} type="button" className="twk-chip" role="radio"
aria-checked={on} data-on={on ? '1' : '0'}
aria-label={colors.join(', ')} title={colors.join(' · ')}
style={{ background: hero }}
onClick={() => onChange(o)}>
{sup.length > 0 && (
<span>
{sup.map((c, j) => <i key={j} style={{ background: c }} />)}
</span>
)}
{on && <__TwkCheck light={__twkIsLight(hero)} />}
</button>
);
})}
</div>
</TweakRow>
);
}
function TweakButton({ label, onClick, secondary = false }) {
return (
<button type="button" className={secondary ? 'twk-btn secondary' : 'twk-btn'}
onClick={onClick}>{label}</button>
);
}
Object.assign(window, {
useTweaks, TweaksPanel, TweakSection, TweakRow,
TweakSlider, TweakToggle, TweakRadio, TweakSelect,
TweakText, TweakNumber, TweakColor, TweakButton,
});
</script>
<!-- ===== inlined: components ===== -->
<script type="text/babel" data-presets="react">
/* ZERO prototype — terminal render components.
Exports render atoms to window. Requires React (global). */
// ---- tiny inline icons (UI glyphs, crisp 1px) ------------------------------
function Icon({ name, size = 13 }) {
const p = { width: size, height: size, viewBox: '0 0 16 16', fill: 'none',
stroke: 'currentColor', strokeWidth: 1.4, strokeLinecap: 'round', strokeLinejoin: 'round' };
const paths = {
read: <><path d="M3 2h6l4 4v8H3z" /><path d="M9 2v4h4" /></>,
write: <><path d="M3 2h6l4 4v8H3z" /><path d="M9 2v4h4" /><path d="M6 9.5h4M6 11.5h4" /></>,
edit: <><path d="M10.5 2.5l3 3L6 13l-3.5.5L3 10z" /></>,
patch: <><path d="M8 2v12M2 8h12" /></>,
bash: <><path d="M3 4l3 4-3 4M8.5 12h4.5" /></>,
grep: <><circle cx="7" cy="7" r="4" /><path d="M10 10l3.5 3.5" /></>,
glob: <><circle cx="7" cy="7" r="4" /><path d="M10 10l3.5 3.5" /></>,
};
return <svg {...p} className="ti">{paths[name] || paths.read}</svg>;
}
function Spinner() {
return <span className="spin" aria-label="running"><span></span></span>;
}
// status dot
function StatusGlyph({ status }) {
if (status === 'running') return <Spinner />;
if (status === 'error') return <span className="sg sg-err">✗</span>;
return <span className="sg sg-ok">✓</span>;
}
// ---- diff view -------------------------------------------------------------
function DiffView({ data }) {
let oldNo = data.oldStart || 0;
let newNo = data.newStart || 0;
return (
<div className="diff">
<div className="diff-head">
<span className="diff-path">{data.path}</span>
{data.created && <span className="tag tag-new">new file</span>}
<span className="diff-stat">
<span className="ds-add">+{data.diff.filter((l) => l.t === '+').length}</span>
<span className="ds-del">−{data.diff.filter((l) => l.t === '-').length}</span>
</span>
</div>
<div className="diff-body">
{data.diff.map((l, i) => {
let n;
if (l.t === ' ') { oldNo++; newNo++; n = newNo; }
else if (l.t === '+') { newNo++; n = newNo; }
else { oldNo++; n = oldNo; }
const cls = l.t === '+' ? 'dl-add' : l.t === '-' ? 'dl-del' : 'dl-ctx';
return (
<div key={i} className={'dl ' + cls}>
<span className="dl-no">{n}</span>
<span className="dl-sign">{l.t === ' ' ? '' : l.t}</span>
<span className="dl-tx">{l.text || '\u00a0'}</span>
</div>
);
})}
</div>
</div>
);
}
// ---- read view -------------------------------------------------------------
function ReadView({ data }) {
return (
<div className="read">
<div className="read-head"><span className="diff-path">{data.path}</span>
<span className="read-range">lines {data.start}–{data.start + data.lines.length - 1}</span></div>
<div className="read-body">
{data.lines.map((ln, i) => (
<div key={i} className="rl">
<span className="rl-no">{data.start + i}</span>
<span className="rl-tx">{ln || '\u00a0'}</span>
</div>
))}
</div>
</div>
);
}
// ---- bash view -------------------------------------------------------------
function BashView({ data }) {
return (
<div className={'bash' + (data.err ? ' bash-err' : '')}>
<div className="bash-cmd"><span className="bash-prompt">$</span><span>{data.cmd}</span></div>
<div className="bash-out">
{data.out.map((line, i) => <div key={i} className="bo">{line || '\u00a0'}</div>)}
</div>
<div className="bash-foot">
<span className={data.exit === 0 ? 'exit-ok' : 'exit-bad'}>exit {data.exit}</span>
</div>
</div>
);
}
// ---- grep view -------------------------------------------------------------
function GrepView({ data }) {
return (
<div className="grep">
{data.matches.map((m, i) => (
<div key={i} className="gl">
<span className="gl-loc">{m.file}<span className="gl-no">:{m.line}</span></span>
<span className="gl-tx">{m.text}</span>
</div>
))}
<div className="grep-foot">{data.matches.length} matches</div>
</div>
);
}
function ToolResult({ result }) {
if (!result) return null;
switch (result.type) {
case 'diff': return <DiffView data={result} />;
case 'read': return <ReadView data={result} />;
case 'bash': return <BashView data={result} />;
case 'grep': return <GrepView data={result} />;
default: return null;
}
}
// ---- tool card -------------------------------------------------------------
function ToolCard({ step }) {
const { name, target, arg, status } = step;
const open = status !== 'running';
return (
<div className={'tool tool-' + status}>
<div className="tool-head">
<span className="tool-ico"><Icon name={name} /></span>
<span className="tool-name">{name}</span>
<span className="tool-target">{target}</span>
{arg && <span className="tool-arg">{arg}</span>}
{step.auto && <span className="tool-auto">auto · session</span>}
<span className="tool-status"><StatusGlyph status={status} /></span>
</div>
{open && <div className="tool-body"><ToolResult result={step.result} /></div>}
</div>
);
}
// ---- permission prompt -----------------------------------------------------
function PermissionPrompt({ step, onDecide }) {
const riskLabel = { low: 'low', medium: 'medium', high: 'high' }[step.risk];
return (
<div className={'perm perm-' + step.risk}>
<div className="perm-top">
<span className="perm-badge">permission</span>
<span className="perm-risk">risk: {riskLabel}</span>
</div>
<div className="perm-msg">
ZERO wants to run <span className="perm-tool">{step.name}</span>
<span className="perm-target"> {step.target}</span>
</div>
<div className="perm-actions">
<button className="pbtn pbtn-allow" onClick={() => onDecide('once')}>Allow once <kbd>↵</kbd></button>
<button className="pbtn pbtn-always" onClick={() => onDecide('always')}>Always allow {step.name}</button>
<button className="pbtn pbtn-deny" onClick={() => onDecide('deny')}>Deny <kbd>esc</kbd></button>
</div>
</div>
);
}
// ---- JSON event line (headless mode) ---------------------------------------
function JsonLine({ ev }) {
return (
<div className="jl">
<span className="jp">{'{'}</span>
{Object.entries(ev).map(([k, v], i, a) => (
<span key={k} className="jkv">
<span className="jk">"{k}"</span><span className="jc">:</span>
{typeof v === 'number'
? <span className="jnum">{v}</span>
: <span className={'jstr js-' + k}>"{String(v)}"</span>}
{i < a.length - 1 && <span className="jc">, </span>}
</span>
))}
<span className="jp">{'}'}</span>
</div>
);
}
Object.assign(window, {
Icon, Spinner, StatusGlyph, DiffView, ReadView, BashView, GrepView,
ToolResult, ToolCard, PermissionPrompt, JsonLine,
});
</script>
<!-- ===== inlined: app ===== -->
<script type="text/babel" data-presets="react">
/* ZERO prototype — app engine + chrome.
Requires React, data.js (window.ZERO), components.jsx atoms, tweaks. */
const { useState, useEffect, useRef, useCallback } = React;
const Z = window.ZERO;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const uid = () => Math.random().toString(36).slice(2, 8);
// ============================================================ MODEL MENU
function ModelMenu({ model, onPick }) {
const [open, setOpen] = useState(false);
const cur = Z.MODELS.find((m) => m.id === model) || Z.MODELS[0];
const ref = useRef(null);
useEffect(() => {
const h = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
document.addEventListener('mousedown', h); return () => document.removeEventListener('mousedown', h);
}, []);
return (
<div className="mm" ref={ref}>
<button className={'mm-btn' + (open ? ' on' : '')} onClick={() => setOpen(!open)}>
<span className="mm-dot" data-local={cur.local}></span>
<span className="mm-id">{cur.id}</span>
<span className="mm-ctx">{cur.ctx}</span>
<span className="mm-car">▾</span>
</button>
{open && (
<div className="mm-pop">
<div className="mm-pop-h">BYOK · model-agnostic — any OpenAI-compatible or Anthropic endpoint</div>
{Z.MODELS.map((m) => (
<button key={m.id} className={'mm-row' + (m.id === model ? ' sel' : '')}
onClick={() => { onPick(m.id); setOpen(false); }}>
<span className="mm-dot" data-local={m.local}></span>
<span className="mm-row-id">{m.id}</span>
<span className="mm-row-meta">
<span>{m.endpoint}</span><span className="mm-row-ctx">{m.ctx}</span>
<span className="mm-row-key">{m.key}</span>
</span>
{m.id === model && <span className="mm-check">●</span>}
</button>
))}
</div>
)}
</div>
);
}
// ============================================================ SESSIONS PANEL
function SessionsPanel({ open, onClose, onResume }) {
return (
<div className={'sx-wrap' + (open ? ' open' : '')}>
<div className="sx-scrim" onClick={onClose}></div>
<aside className="sx">
<div className="sx-head">
<span className="sx-title">sessions</span>
<span className="sx-sub">~/.zero/sessions · append-only jsonl</span>
<button className="sx-x" onClick={onClose}>esc</button>
</div>
<div className="sx-list">
{Z.SESSIONS.map((s) => (
<div key={s.id} className="sxr">
<div className="sxr-top">
<span className="sxr-id">{s.id}</span>
<span className="sxr-when">{s.when}</span>
</div>
<div className="sxr-title">{s.title}</div>
<div className="sxr-meta">
<span>{s.turns} turns</span><span className="sxr-dot">·</span>
<span>{s.model}</span><span className="sxr-dot">·</span>
<span>{(s.tokens / 1000).toFixed(1)}K tok</span>
</div>
<div className="sxr-actions">
<button onClick={() => onResume(s)}>resume</button>
<button>fork</button>
<button>export md</button>
</div>
</div>
))}
</div>
</aside>
</div>
);
}
// ============================================================ MAIN APP
function App() {
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
"accent": "#caff3f",
"termFont": "'JetBrains Mono'",
"typeSpeed": 1,
"scanlines": false
}/*EDITMODE-END*/;
const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
const prefs = (() => { try { return JSON.parse(localStorage.getItem('zero_prefs') || '{}'); } catch { return {}; } })();
const [model, setModel] = useState(prefs.model || 'anthropic/claude-sonnet-4-5');
const [format, setFormat] = useState(prefs.format || 'text');
const [autoApprove, setAutoApprove] = useState(prefs.autoApprove || false);
useEffect(() => { localStorage.setItem('zero_prefs', JSON.stringify({ model, format, autoApprove })); }, [model, format, autoApprove]);
const [blocks, setBlocks] = useState([]); // text-mode transcript
const [jsonLog, setJsonLog] = useState([]); // json-mode events
const [running, setRunning] = useState(false);
const [input, setInput] = useState('');
const [sessOpen, setSessOpen] = useState(false);
const [alwaysAllow, setAlwaysAllow] = useState([]); // tool names remembered this session
const [tokens, setTokens] = useState(0);
const [stepCount, setStepCount] = useState(0);
const runRef = useRef(0);
const decideRef = useRef(null);
const scrollRef = useRef(null);
const inputRef = useRef(null);
const scrollDown = useCallback(() => {
const el = scrollRef.current; if (el) requestAnimationFrame(() => { el.scrollTop = el.scrollHeight + 999; });
}, []);
useEffect(() => { scrollDown(); }, [blocks, jsonLog, scrollDown]);
const spd = () => 1 / (t.typeSpeed || 1);
// mutate the last block
const patchLast = (fn) => setBlocks((b) => { const c = b.slice(); c[c.length - 1] = fn({ ...c[c.length - 1] }); return c; });
const push = (blk) => setBlocks((b) => [...b, blk]);
const pushJson = (ev) => setJsonLog((l) => [...l, ev]);
async function typeInto(full, key, myRun) {
const frames = 12;
for (let f = 1; f <= frames; f++) {
if (runRef.current !== myRun) return;
const n = Math.round((full.length * f) / frames);
patchLast((blk) => ({ ...blk, [key]: full.slice(0, n) }));
await sleep(28 * spd());
}
patchLast((blk) => ({ ...blk, [key]: full }));
}
function addUsage(p, c) { setTokens((x) => x + p + c); }
function awaitDecision() { return new Promise((res) => { decideRef.current = res; }); }
// ---------- TEXT RUNNER ----------
async function runText(scn, task, myRun) {
push({ k: 'user', text: task });
await sleep(180 * spd());
let steps = 0;
for (const s of scn.steps) {
if (runRef.current !== myRun) return;
if (s.kind === 'say' || s.kind === 'final') {
push({ k: s.kind, text: '', full: s.text });
await typeInto(s.text, 'text', myRun);
addUsage(120, Math.round(s.text.length / 3));
await sleep(160 * spd());
} else if (s.kind === 'tool') {
const risk = Z.RISK[s.name];
// permission gate (high-risk unless remembered or auto-approve)
const needPerm = risk === 'high' && !autoApprove && !alwaysAllow.includes(s.name);
if (needPerm) {
push({ k: 'perm', name: s.name, target: s.target, risk });
const decision = await awaitDecision();
if (runRef.current !== myRun) return;
if (decision === 'deny') {
patchLast((b) => ({ ...b, resolved: 'deny' }));
push({ k: 'note', tone: 'deny', text: `Denied. ZERO stopped — ${s.name} was not run.` });
push({ k: 'done', exit: 1, steps, tokens: 0, elapsed: 0 });
return;
}
patchLast((b) => ({ ...b, resolved: decision }));
if (decision === 'always') setAlwaysAllow((a) => a.includes(s.name) ? a : [...a, s.name]);
}
const autoTag = (risk === 'high' && (autoApprove || alwaysAllow.includes(s.name)));
push({ k: 'tool', name: s.name, target: s.target, arg: s.arg, status: 'running', result: s.result, auto: autoTag });
steps++; setStepCount((x) => x + 1);
await sleep((420 + (s.result.type === 'bash' ? 380 : 120)) * spd());
if (runRef.current !== myRun) return;
const isErr = s.result.type === 'bash' && s.result.exit !== 0 && s.result.err;
patchLast((b) => ({ ...b, status: isErr ? 'error' : 'done' }));
addUsage(90, 60);
await sleep(220 * spd());
}
}
push({ k: 'done', exit: 0, steps, elapsed: (1.2 + steps * 0.7).toFixed(1) });
}
// ---------- JSON (HEADLESS) RUNNER ----------
async function runJson(scn, task, myRun) {
pushJson({ type: 'start', task, model });
await sleep(160 * spd());
let steps = 0, tok = 0;
for (const s of scn.steps) {
if (runRef.current !== myRun) return;
if (s.kind === 'say') { pushJson({ type: 'assistant', text: clip(s.text) }); tok += 80; }
else if (s.kind === 'final') { pushJson({ type: 'final', text: clip(s.text) }); tok += 80; }
else if (s.kind === 'tool') {
const risk = Z.RISK[s.name];
if (risk === 'high' && !autoApprove && !alwaysAllow.includes(s.name)) {
pushJson({ type: 'permission_required', tool: s.name, target: s.target, hint: 're-run with --yes' });
await sleep(160 * spd());
pushJson({ type: 'done', exit: 2, reason: 'permission_denied', steps });
setTokens((x) => x + tok);
return;
}
pushJson({ type: 'tool_call', name: s.name, target: s.target });
steps++; setStepCount((x) => x + 1);
await sleep((300 + (s.result.type === 'bash' ? 260 : 80)) * spd());
const ok = !(s.result.type === 'bash' && s.result.err);
pushJson({ type: 'tool_result', name: s.name, status: ok ? 'ok' : 'error' });
tok += 150;
}
await sleep(120 * spd());
}
setTokens((x) => x + tok);
pushJson({ type: 'done', exit: 0, steps, tokens: tok });
}
function clip(s) { return s.length > 64 ? s.slice(0, 61) + '…' : s; }
async function run(task) {
if (running || !task.trim()) return;
const myRun = ++runRef.current;
setRunning(true);
const scn = Z.pickScenario(task);
try {
if (format === 'json') await runJson(scn, task.trim(), myRun);
else await runText(scn, task.trim(), myRun);
} finally {
if (runRef.current === myRun) setRunning(false);
}
}
function decide(v) { if (decideRef.current) { const f = decideRef.current; decideRef.current = null; f(v); } }
function onSubmit(e) { e && e.preventDefault(); if (running) return; const v = input; setInput(''); run(v); }
function clearAll() {
runRef.current++; setRunning(false); decideRef.current = null;
setBlocks([]); setJsonLog([]); setTokens(0); setStepCount(0);
}
function resumeSession(s) {
clearAll(); setModel(s.model); setSessOpen(false);
setFormat('text');
setBlocks([
{ k: 'note', tone: 'sys', text: `resumed session ${s.id} · ${s.turns} turns · ${(s.tokens / 1000).toFixed(1)}K tokens` },
{ k: 'user', text: s.title },
{ k: 'say', text: '…history replayed from ~/.zero/sessions/' + s.id + '.jsonl. Continue the conversation below.', full: '' },
]);
setTokens(s.tokens);
setTimeout(() => inputRef.current && inputRef.current.focus(), 50);
}
// keyboard for permission
useEffect(() => {
const h = (e) => {
if (!decideRef.current) return;
if (e.key === 'Enter') { e.preventDefault(); decide('once'); }
if (e.key === 'Escape') { e.preventDefault(); decide('deny'); }
};
window.addEventListener('keydown', h); return () => window.removeEventListener('keydown', h);
}, []);
const cur = Z.MODELS.find((m) => m.id === model) || Z.MODELS[0];
const empty = blocks.length === 0 && jsonLog.length === 0;
return (
<div className="stage" style={{
'--accent': t.accent,
'--term-font': t.termFont + ", 'SFMono-Regular', ui-monospace, monospace",
}}>
<div className={'win' + (t.scanlines ? ' scan' : '')}>
{/* ---- title bar ---- */}
<header className="bar">
<div className="bar-l">
<span className="zmark">0</span>
<span className="zname">zero</span>
<span className="bar-sep">/</span>
<span className="bar-cwd">{Z.CWD}</span>
</div>
<div className="bar-r">
<ModelMenu model={model} onPick={setModel} />
<div className="seg">
<button className={format === 'text' ? 'on' : ''} onClick={() => setFormat('text')}>text</button>
<button className={format === 'json' ? 'on' : ''} onClick={() => setFormat('json')}>json</button>
</div>
<button className={'tgl' + (autoApprove ? ' on' : '')} onClick={() => setAutoApprove(!autoApprove)} title="--yes / auto-approve permissions">
<span className="tgl-k">--yes</span>
</button>
<button className="iconbtn" onClick={() => setSessOpen(true)} title="sessions">sessions</button>
</div>
</header>
{/* ---- stream ---- */}
<main className="stream" ref={scrollRef}>
{empty && <EmptyState model={cur} format={format} onPick={(s) => run(s)} />}
{format === 'text'
? blocks.map((b, i) => <Block key={i} b={b} onDecide={decide} />)
: <div className="jsonwrap">
<div className="json-cmd"><span>$</span> zero run -p "{(jsonLog[0] && jsonLog[0].task) || '…'}" --model {model} --format json {autoApprove ? '--yes' : ''}</div>
{jsonLog.map((ev, i) => <JsonLine key={i} ev={ev} />)}
{running && <div className="jl jl-cursor">▌</div>}
</div>}
{format === 'text' && running && <div className="cursor-line"><span className="blk">▌</span></div>}
</main>
{/* ---- input ---- */}
<form className="composer" onSubmit={onSubmit}>
<span className="cmp-prompt">❯</span>
<input ref={inputRef} className="cmp-in" value={input} placeholder={running ? 'running… ⌃C to interrupt' : 'describe a task for zero…'}
onChange={(e) => setInput(e.target.value)} disabled={running} autoFocus />
{running
? <button type="button" className="cmp-btn stop" onClick={clearAll}>stop</button>
: <button type="submit" className="cmp-btn" disabled={!input.trim()}>run ↵</button>}
</form>
{/* ---- status bar ---- */}
<footer className="status">
<span className="st-grp"><span className="st-acc">●</span> {cur.provider}</span>
<span className="st-grp">{cur.id}</span>
<span className="st-sp"></span>
<span className="st-grp">steps <b>{stepCount}</b>/50</span>
<span className="st-grp">{(tokens / 1000).toFixed(1)}K tok</span>
<span className="st-grp">{format === 'json' ? 'headless' : 'interactive'}</span>
{alwaysAllow.length > 0 && <span className="st-grp allow">always: {alwaysAllow.join(', ')}</span>}
{autoApprove && <span className="st-grp warn">--yes</span>}
</footer>
</div>
<SessionsPanel open={sessOpen} onClose={() => setSessOpen(false)} onResume={resumeSession} />
<TweaksPanel>
<TweakSection label="Signal" />
<TweakColor label="Accent" value={t.accent}
options={['#caff3f', '#5eead4', '#ffb454', '#a78bfa', '#f87171']}
onChange={(v) => setTweak('accent', v)} />
<TweakSection label="Terminal" />
<TweakSelect label="Mono font" value={t.termFont}
options={["'JetBrains Mono'", "'IBM Plex Mono'", "'Space Mono'", "'Courier New'"]}
onChange={(v) => setTweak('termFont', v)} />
<TweakSlider label="Type speed" value={t.typeSpeed} min={0.5} max={3} step={0.5} unit="×"
onChange={(v) => setTweak('typeSpeed', v)} />
<TweakToggle label="CRT scanlines" value={t.scanlines} onChange={(v) => setTweak('scanlines', v)} />
<TweakSection label="Session" />
<TweakButton label="Clear transcript" onClick={clearAll} />
</TweaksPanel>
</div>
);
}
// ---- empty state ----
function EmptyState({ model, format, onPick }) {
return (
<div className="empty">
<div className="empty-mark">0</div>
<div className="empty-tag">a std-lib-first coding agent · bring your own key · no lock-in</div>
<div className="empty-hint">
running <span>{format === 'json' ? 'zero run --format json' : 'zero'}</span> against <span>{model.id}</span>
</div>
<div className="chips">
{Z.SUGGESTIONS.map((s, i) => (
<button key={i} className="chip" onClick={() => onPick(s)}>
<span className="chip-arrow">❯</span>{s}
</button>
))}
</div>
</div>
);
}
// ---- block dispatcher ----
function Block({ b, onDecide }) {
switch (b.k) {
case 'user': return <div className="blk-user"><span className="bu-p">❯</span><span className="bu-t">{b.text}</span></div>;
case 'say': return <div className="blk-say">{b.text}{b.text !== b.full && <span className="tcur">▌</span>}</div>;
case 'final': return (
<div className="blk-final">
<div className="bf-rail"></div>
<div className="bf-tx">{b.text}{b.text !== b.full && <span className="tcur">▌</span>}</div>
</div>);
case 'tool': return <ToolCard step={b} />;
case 'perm': return b.resolved
? <div className={'perm-resolved ' + b.resolved}>permission {b.resolved === 'deny' ? 'denied' : (b.resolved === 'always' ? 'always-allowed' : 'allowed once')} · {b.name} {b.target}</div>
: <PermissionPrompt step={b} onDecide={onDecide} />;
case 'done': return (
<div className={'blk-done' + (b.exit !== 0 ? ' bad' : '')}>
<span className="bd-dot">■</span>
<span>done · {b.exit === 0 ? 'completed' : 'stopped'}</span>
<span className="bd-sep">·</span><span>exit {b.exit}</span>
<span className="bd-sep">·</span><span>{b.steps} steps</span>
{b.elapsed != null && b.exit === 0 && <><span className="bd-sep">·</span><span>{b.elapsed}s</span></>}
</div>);
case 'note': return <div className={'blk-note note-' + (b.tone || 'sys')}>{b.text}</div>;
default: return null;
}
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
</body>
</html>