smolcode / engine /web_tui.py
seanpoyner's picture
Upload folder using huggingface_hub
daea45b verified
Raw
History Blame Contribute Delete
15.5 kB
"""CLI-shaped web UI: transcript buffer, HTML rendering, layout helpers."""
from __future__ import annotations
import html
from dataclasses import dataclass, field
from .gradio_shell import UiSettings
from .rust_session import list_commands
from .themes import theme_at, theme_names
_BUILTIN_SLASH = [
"/help", "/mode", "/think", "/mcp", "/rules", "/skills", "/skill", "/bg",
"/init", "/new", "/sessions", "/rename", "/fork", "/delete", "/timeline",
"/stats", "/export", "/search", "/config", "/commit", "/agents", "/models",
"/themes", "/files", "/clear", "/quit",
]
_KIND_STYLE = {
"user": ("›", "#e2e8f0", "#1e293b"),
"assistant": ("◆", "#c4b5fd", "#1e1b4b"),
"tool": ("⚙", "#a78bfa", "#0f172a"),
"result": ("·", "#94a3b8", "#0f172a"),
"info": ("·", "#94a3b8", "#0f172a"),
"error": ("✕", "#f87171", "#450a0a"),
"final": ("✓", "#34d399", "#052e16"),
}
@dataclass
class TranscriptLine:
kind: str
text: str
@dataclass
class Transcript:
lines: list[TranscriptLine] = field(default_factory=list)
partial: str = ""
def clear(self) -> None:
self.lines.clear()
self.partial = ""
def append(self, kind: str, text: str) -> None:
text = (text or "").strip()
if not text:
return
self.lines.append(TranscriptLine(kind=kind, text=text))
def append_user(self, text: str) -> None:
self.append("user", text)
def append_assistant(self, text: str) -> None:
self.append("assistant", text)
def append_info(self, text: str) -> None:
self.append("info", text)
def append_error(self, text: str) -> None:
self.append("error", text)
def append_tool_call(self, name: str, args: str) -> None:
self.append("tool", f"{name} {args[:200]}")
def append_tool_result(self, name: str, text: str) -> None:
clipped = text[:400] + ("…" if len(text) > 400 else "")
self.append("result", f"{name}: {clipped}")
def set_partial(self, text: str) -> None:
self.partial = text
def from_stored_chat(self, stored: list[dict[str, str]]) -> None:
self.clear()
for m in stored:
role = m.get("role", "assistant")
kind = "user" if role == "user" else "assistant"
self.append(kind, m.get("text", ""))
def append_final(self, text: str) -> None:
self.append("final", text)
def plain_texts(self) -> list[str]:
return [ln.text for ln in self.lines]
def search(self, query: str, limit: int = 20) -> list[str]:
if not query.strip():
return []
q = query.lower()
hits: list[str] = []
for ln in self.lines:
if q in ln.text.lower():
hits.append(f"[{ln.kind}] {ln.text[:120]}")
if len(hits) >= limit:
break
return hits
def render_html(self, *, running: bool = False) -> str:
if not self.lines and not self.partial and not running:
return (
'<div class="sc-transcript-wrap">'
'<div class="sc-transcript-empty">'
"smolcode — describe a coding task, or type <code>/help</code>"
"</div></div>"
)
parts: list[str] = ['<div class="sc-transcript-inner">']
for ln in self.lines:
parts.append(_line_html(ln.kind, ln.text))
if self.partial:
parts.append(_line_html("assistant", self.partial + "▏"))
if running and not self.partial:
parts.append('<div class="sc-tline sc-tline-info">· thinking…</div>')
parts.append("</div>")
return f'<div class="sc-transcript-wrap">\n' + "\n".join(parts) + "\n</div>"
def _line_html(kind: str, text: str) -> str:
glyph, color, _bg = _KIND_STYLE.get(kind, _KIND_STYLE["info"])
body = html.escape(text).replace("\n", "<br>")
return (
f'<div class="sc-tline sc-tline-{kind}">'
f'<span class="sc-tglyph" style="color:{color}">{glyph}</span> '
f'<span class="sc-ttext">{body}</span></div>'
)
def slash_commands(workspace: str) -> list[str]:
custom = [f"/{n}" for n in list_commands(workspace)]
return _BUILTIN_SLASH + custom
def filter_slash_commands(prefix: str, workspace: str) -> list[str]:
p = prefix if prefix.startswith("/") else f"/{prefix}"
return [c for c in slash_commands(workspace) if c.startswith(p)]
def header_bar_html(
*,
git_branch: str = "",
git_dirty: bool = False,
model: str = "",
host: str = "",
theme: str = "default",
) -> str:
git_part = ""
if git_branch:
dirty = " ●" if git_dirty else ""
git_part = f'<span class="sc-hgit">⎇ {html.escape(git_branch)}{dirty}</span>'
model_part = html.escape(model) if model else "—"
host_part = html.escape(host) if host else ""
return (
'<div class="sc-header-bar">'
f'<span class="sc-hbrand">◆ smol<span class="hf-accent">code</span></span>'
f"{git_part}"
f'<span class="sc-hmodel">{model_part}</span>'
f'<span class="sc-hhost">@ {host_part}</span>'
f'<span class="sc-htheme">{html.escape(theme)}</span>'
"</div>"
)
def status_bar_html(
settings: UiSettings,
*,
session_title: str = "new session",
model: str = "",
running: bool = False,
) -> str:
mode = settings.mode.upper()
if settings.mode == "auto":
mode = "AUTO"
elif settings.mode == "plan":
mode = "PLAN"
else:
mode = "EDIT"
think = ""
if settings.think and settings.think != "off":
think = f'<span class="sc-chip sc-chip-think">think:{settings.think}</span>'
run = '<span class="sc-chip sc-chip-run">running</span>' if running else ""
ws = html.escape(settings.workspace[:48])
sess = html.escape(session_title[:32])
ag = html.escape(settings.agent)
mdl = html.escape(model or settings.model or "—")
return (
'<div class="sc-status-bar">'
f'<span class="sc-chip sc-chip-brand">smolcode</span>'
f'<span class="sc-chip">{sess}</span>'
f'<span class="sc-chip sc-chip-dim">{ws}</span>'
f'<button type="button" class="sc-chip sc-chip-clickable" data-picker="agents">{ag}</button>'
f'<button type="button" class="sc-chip sc-chip-clickable sc-chip-mode" data-action="cycle-mode">{mode}</button>'
f"{think}{run}"
f'<button type="button" class="sc-chip sc-chip-clickable sc-chip-model" data-picker="models">{mdl}</button>'
f'<button type="button" class="sc-chip sc-chip-clickable sc-chip-dim" data-picker="themes">theme</button>'
"</div>"
)
def parse_git_header(git_text: str) -> tuple[str, bool]:
branch = ""
dirty = False
for line in git_text.splitlines():
if line.startswith("##"):
branch = line[2:].strip().split("...")[0]
if line.strip() and not line.startswith("#"):
dirty = True
return branch, dirty
def host_from_url(base_url: str) -> str:
u = base_url.strip()
for prefix in ("https://", "http://"):
if u.startswith(prefix):
u = u[len(prefix):]
return u.split("/")[0] if u else ""
def cycle_mode(current: str) -> str:
order = ["normal", "auto", "plan"]
try:
i = order.index(current)
except ValueError:
return "normal"
return order[(i + 1) % len(order)]
def cycle_think(current: str) -> str:
order = ["off", "low", "high", "xtra"]
try:
i = order.index(current)
except ValueError:
return "off"
return order[(i + 1) % len(order)]
def cycle_agent(current: str) -> str:
order = ["build", "plan"]
try:
i = order.index(current)
except ValueError:
return "build"
return order[(i + 1) % len(order)]
def cycle_model(models: list[str], current: str) -> str:
if not models:
return current
try:
i = models.index(current)
except ValueError:
return models[0]
return models[(i + 1) % len(models)]
def ingest_agent_event(transcript: Transcript, ev: dict) -> None:
kind = ev.get("kind")
if kind == "token":
transcript.set_partial(transcript.partial + ev.get("text", ""))
elif kind == "assistant":
transcript.set_partial(ev.get("text", ""))
elif kind == "tool_call":
transcript.set_partial("")
transcript.append_tool_call(ev.get("name", ""), ev.get("args", ""))
elif kind == "tool_result":
transcript.append_tool_result(ev.get("name", ""), ev.get("text", ""))
elif kind == "final":
transcript.set_partial("")
transcript.append_final(ev.get("text", ""))
elif kind == "error":
transcript.set_partial("")
transcript.append_error(ev.get("text", ""))
def help_overlay_html() -> str:
lines = [
"Enter — run task",
"Shift+Enter — newline",
"/ — slash commands (Tab complete)",
"@ — attach file",
"! cmd — shell (no LLM)",
"Ctrl+L — clear transcript",
"Ctrl+X — leader key menu",
"Tab — cycle agent",
"Shift+Tab — cycle mode",
"F2 — cycle model",
"Esc — interrupt / close overlay",
]
body = "<br>".join(html.escape(ln) for ln in lines)
return f'<div class="sc-overlay-body"><b>smolcode keys</b><br><br>{body}</div>'
def whichkey_overlay_html() -> str:
lines = [
"m models", "a agents", "t themes", "l sessions",
"n new session", "b sidebar", "s stats/files", "f focus files",
"h help", "o mode", "e think", "q quit",
]
body = "<br>".join(html.escape(ln) for ln in lines)
return f'<div class="sc-overlay-body"><b>ctrl+x leader</b><br><br>{body}</div>'
def render_picker_html(
kind: str,
items: list[str],
selected: int,
*,
title: str | None = None,
) -> str:
"""TUI-style bordered picker list with scroll window."""
label = title or kind
if not items:
return (
f'<div class="sc-picker" data-kind="{html.escape(kind)}">'
f'<div class="sc-picker-title">{html.escape(label)}</div>'
'<div class="sc-picker-empty">(empty)</div></div>'
)
win = 12
sel = min(max(0, selected), len(items) - 1)
start = max(0, sel - win // 2)
end = min(len(items), start + win)
start = max(0, end - win)
rows: list[str] = []
for i in range(start, end):
item = items[i]
marker = "❯" if i == sel else " "
cls = "sc-picker-item sc-picker-sel" if i == sel else "sc-picker-item"
rows.append(
f'<button type="button" class="{cls}" data-idx="{i}" '
f'onclick="window.__smolcodePick && window.__smolcodePick({i})">'
f'<span class="sc-picker-mark">{marker}</span>'
f"<span>{html.escape(item)}</span></button>"
)
body = "\n".join(rows)
return (
f'<div class="sc-picker" data-kind="{html.escape(kind)}">'
f'<div class="sc-picker-title">{html.escape(label)}</div>'
f'<div class="sc-picker-list">{body}</div>'
f'<div class="sc-picker-hint">↑↓ navigate · Enter select · Esc close</div>'
f"</div>"
)
def shell_theme_html(theme_idx: int) -> str:
"""Inject data-theme on the TUI shell wrapper."""
name = theme_at(theme_idx).name
safe = html.escape(name, quote=True)
return (
f'<script>(function(){{var el=document.querySelector(".sc-tui-shell");'
f'if(el)el.setAttribute("data-theme","{safe}");}})();</script>'
)
def agent_choices() -> list[str]:
return ["build", "plan"]
def theme_picker_items() -> list[str]:
return theme_names()
def _sorted_file_paths(files: dict[str, str] | list[str]) -> list[str]:
if isinstance(files, dict):
return sorted(files.keys())
return sorted(files)
def _paths_for_ui(files: dict[str, str] | list[str] | None) -> list[str]:
return _sorted_file_paths(files or [])
def _files_sidebar_body(paths: list[str], *, selected: int = 0, max_rows: int = 48) -> str:
"""Flat file list grouped by directory, matching the CLI TUI sidebar."""
if not paths:
return '<div class="sc-sb-empty">no files</div>'
rows: list[str] = []
sel_row: int | None = None
last_dir = ""
sel = min(selected, max(0, len(paths) - 1))
for i, path in enumerate(paths):
if "/" in path:
j = path.rfind("/")
dir_part, file_part = path[:j], path[j + 1 :]
else:
dir_part, file_part = "", path
if dir_part != last_dir:
last_dir = dir_part
label = "." if not dir_part else f"{dir_part}/"
rows.append(f'<div class="sc-sb-dir">▾ {html.escape(label)}</div>')
is_sel = i == sel
if is_sel:
sel_row = len(rows)
prefix = "❯" if is_sel else ""
cls = "sc-sb-file sc-sb-sel" if is_sel else "sc-sb-file"
rows.append(
f'<div class="{cls}">'
f'<span class="sc-sb-mark">{prefix}</span>'
f'<span class="sc-sb-glyph"> </span>'
f'<span class="sc-sb-name">{html.escape(file_part)}</span>'
f"</div>"
)
total = len(rows)
start = 0
if total > max_rows:
anchor = sel_row if sel_row is not None else 0
start = min(max(0, anchor - max_rows + 1), total - max_rows)
visible = rows[start : start + max_rows]
if total > max_rows and start + max_rows < total:
more = total - (start + max_rows) + 1
visible.append(f'<div class="sc-sb-more">… +{more} more</div>')
return "\n".join(visible)
def _stats_sidebar_body(
*,
session_id: str,
file_count: int,
agent: str,
extra_lines: list[str] | None = None,
) -> str:
parts = [
f'<div class="sc-sb-stat sc-sb-dim">{html.escape(session_id[:26])}</div>',
'<div class="sc-sb-stat"></div>',
]
for line in extra_lines or []:
parts.append(f'<div class="sc-sb-stat">{html.escape(line)}</div>')
parts.append(f'<div class="sc-sb-stat">files: {file_count}</div>')
parts.append(f'<div class="sc-sb-stat">agent: {html.escape(agent)}</div>')
return "\n".join(parts)
def render_sidebar_html(
*,
view: str = "files",
files: dict[str, str] | list[str] | None = None,
selected: int = 0,
focused: bool = False,
session_id: str = "(none)",
agent: str = "build",
stats_lines: list[str] | None = None,
file_total: int | None = None,
) -> str:
"""CLI TUI-shaped sidebar panel (flat file list or stats)."""
paths = _paths_for_ui(files)
total = file_total if file_total is not None else len(paths)
title = "stats" if view == "stats" else ("files ▸" if focused else "files")
panel_cls = "sc-sidebar-panel"
if focused:
panel_cls += " sc-sidebar-focused"
if view == "stats":
body = _stats_sidebar_body(
session_id=session_id,
file_count=total,
agent=agent,
extra_lines=stats_lines,
)
else:
body = _files_sidebar_body(paths, selected=selected)
if total > len(paths):
body += f'\n<div class="sc-sb-more">… {total - len(paths)} more files</div>'
return (
f'<div class="{panel_cls}">'
f'<div class="sc-sidebar-title">{html.escape(title)}</div>'
f'<div class="sc-sidebar-body">{body}</div>'
f"</div>"
)