"""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 (
'
'
'
'
"smolcode — describe a coding task, or type /help"
"
"
)
parts: list[str] = ['
']
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('
"
)
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 = " ".join(html.escape(ln) for ln in lines)
return f'
smolcode keys
{body}
'
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 = " ".join(html.escape(ln) for ln in lines)
return f'
ctrl+x leader
{body}
'
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'
'
f'
{html.escape(label)}
'
'
(empty)
'
)
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'"
)
body = "\n".join(rows)
return (
f'
'
f'
{html.escape(label)}
'
f'
{body}
'
f'
↑↓ navigate · Enter select · Esc close
'
f"
"
)
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''
)
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 '
no files
'
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'
▾ {html.escape(label)}
')
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'
'
f'{prefix}'
f''
f'{html.escape(file_part)}'
f"
"
)
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'