mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-12 16:23:18 -10:00
* 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]>
1775 lines
82 KiB
HTML
1775 lines
82 KiB
HTML
<!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>
|