"""Chat-style Gradio UI for a locally-running ``opencode serve``. Prereq: ``opencode serve`` on http://127.0.0.1:4096. Run: uv run --with gradio --with httpx python local_ui.py """ from __future__ import annotations import html as _html import json import threading import time from typing import Any, Generator import gradio as gr import httpx BASE = "http://127.0.0.1:4096" # ── HTTP helpers ──────────────────────────────────────────────────────────── def _get(path: str, **kw) -> Any: r = httpx.get(f"{BASE}{path}", timeout=15, **kw) r.raise_for_status() return r.json() def _create_session() -> str: return httpx.post(f"{BASE}/session", json={"title": "gradio"}, timeout=15).json()["id"] def _fire_async(sid: str, prompt: str) -> None: httpx.post( f"{BASE}/session/{sid}/prompt_async", json={"parts": [{"type": "text", "text": prompt}]}, timeout=30, ).raise_for_status() def _abort(sid: str) -> None: try: httpx.post(f"{BASE}/session/{sid}/abort", timeout=10) except Exception: pass def _session_diff(sid: str) -> list[dict]: try: return _get(f"/session/{sid}/diff") or [] except Exception: return [] def _session_todo(sid: str) -> list[dict]: try: return _get(f"/session/{sid}/todo") or [] except Exception: return [] # ── Server identity ──────────────────────────────────────────────────────── def _banner() -> str: try: h = _get("/global/health") c = _get("/config") prov = (c.get("provider") or {}).get("vllm") or {} opts = prov.get("options") or {} model = c.get("model") or "?" base_url = opts.get("baseURL") or "?" limit = next(iter(prov.get("models", {}).values()), {}).get("limit") or {} try: tools = _get("/experimental/tool/ids") or [] except Exception: tools = [] tool_line = ( f"
{_esc(inp.get('filePath') or inp.get('path'))}"
body = f"{_esc(_cap(str(out)))}"
elif name == "write":
path = inp.get("filePath") or inp.get("path")
content = inp.get("content") or ""
summary = f"✍️ write {_esc(path)} ({len(content)} chars)"
body = f"{_esc(_cap(content))}"
elif name == "edit":
path = inp.get("filePath") or inp.get("path")
old = inp.get("oldString") or ""
new = inp.get("newString") or ""
summary = f"✏️ edit {_esc(path)}"
body = (
f"{_esc(_cap(old, 3000))}"
f"{_esc(_cap(new, 3000))}"
)
if out:
body += f"{_esc(_cap(str(out), 2000))}"
elif name == "bash":
cmd = inp.get("command") or inp.get("cmd") or ""
summary = f"⚡ bash {_esc(cmd[:160])}"
body = f"{_esc(_cap(str(out)))}"
elif name in ("glob", "find"):
pattern = inp.get("pattern") or inp.get("query") or ""
summary = f"🔎 {name} {_esc(pattern)}"
body = f"{_esc(_cap(str(out), 4000))}"
elif name == "grep":
pattern = inp.get("pattern") or ""
path = inp.get("path") or ""
summary = f"🔎 grep {_esc(pattern)}" + (
f" in {_esc(path)}" if path else ""
)
body = f"{_esc(_cap(str(out), 4000))}"
elif name == "todowrite":
todos = inp.get("todos") or []
summary = f"📝 todowrite ({len(todos)} items)"
body = "{_esc(_cap(str(out), 4000))}"
elif name == "webfetch":
summary = f"🌐 webfetch {_esc(inp.get('url'))}"
body = f"{_esc(_cap(str(out), 4000))}"
else:
summary = f"🔧 {_esc(name)}"
body = (
f"{_esc(_cap(json.dumps(inp, indent=2, default=str), 4000))}"
f"{_esc(_cap(str(out), 4000))}"
)
return (
"{_esc(_cap(txt, 4000))}{_esc(txt)}{_esc(_cap(patch, 6000))}{sid[:18]}… · "
f"{time.time()-t0:.1f}s · {len(parts)} parts · {len(_STATE.events)} events"
)
diff_html = ""
if idle:
diff_html = _render_diff(_session_diff(sid))
yield status, _render_transcript(parts, errors), _render_todo(todos), diff_html
if idle:
break
time.sleep(0.4)
def abort_cb() -> str:
if _STATE.sid:
_abort(_STATE.sid)
# leave SSE open so user sees the abort-related events; actual teardown on new session
return "⏹ aborted (session kept — click New session to clear)"
def refresh_banner() -> str:
return _banner()
# ── CSS ────────────────────────────────────────────────────────────────────
_CSS = """
.banner { margin:4px 0 2px; }
.tools { font-size:11px; color:#888; margin:2px 0 8px; }
.chip { display:inline-block; padding:2px 8px; margin:2px; border-radius:10px;
background:#2b2d31; color:#ddd; font-size:12px; }
.chip.ok { background:#1f6f43; }
.chip.err { background:#7a1e1e; }
.chip code { background:transparent; color:#9ad; }
.errbox { background:#2a1414; border:1px solid #7a1e1e; border-radius:6px;
padding:6px 10px; margin:6px 0; color:#f88; font-size:13px; }
.errbox ul { margin:2px 0 0 18px; }
.chat { font-size:14px; }
.assistant pre { background:#0e1013; padding:10px; border-radius:8px;
white-space:pre-wrap; color:#eee; margin:6px 0; }
.reasoning { opacity:0.8; margin:4px 0; }
.reasoning pre { background:#0a0b0d; color:#aab; padding:8px; white-space:pre-wrap; }
.tool { border:1px solid #2a2f3a; border-radius:8px; padding:6px 10px;
margin:6px 0; background:#12161c; }
.tool summary { cursor:pointer; color:#ddd; }
.tool code { background:#222; color:#9cf; padding:1px 4px; border-radius:3px; }
.tbody { margin-top:6px; }
.tbody pre { background:#0a0b0d; padding:8px; border-radius:4px;
white-space:pre-wrap; max-height:400px; overflow:auto;
font-size:12px; color:#ddd; margin:2px 0; }
.tbody pre.add { border-left:3px solid #2e6; }
.tbody pre.del { border-left:3px solid #e53; }
.tbody .lbl { color:#888; font-size:11px; margin-top:6px; }
.badge { padding:1px 6px; border-radius:8px; font-size:11px;
background:#333; color:#ddd; }
.badge.ok { background:#1f6f43; color:white; }
.badge.err { background:#7a1e1e; color:white; }
.badge.run { background:#7a5c1e; color:white; }
.step { color:#555; text-align:center; margin:10px 0; font-size:11px; }
.stepfin { color:#666; font-size:11px; margin:4px 0 12px; }
.empty { color:#666; font-style:italic; padding:12px; }
.todostrip { background:#14181e; border:1px solid #2a2f3a; border-radius:6px;
padding:6px 10px; margin:6px 0; font-size:13px; }
.todostrip ul { margin:4px 0 0 18px; }
.diff-wrap { margin:8px 0; }
.diff summary { cursor:pointer; color:#9ad; font-family:monospace; }
.diff pre { background:#0a0b0d; padding:8px; border-radius:4px;
white-space:pre; font-size:12px; color:#ddd; overflow:auto; }
"""
# ── Layout ─────────────────────────────────────────────────────────────────
with gr.Blocks(title="opencode serve", css=_CSS) as demo:
banner_html = gr.HTML(value="_(loading…)_")
status_md = gr.Markdown()
todo_html = gr.HTML()
transcript_html = gr.HTML(value="