"""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('
· thinking…
') parts.append("
") return f'
\n' + "\n".join(parts) + "\n
" def _line_html(kind: str, text: str) -> str: glyph, color, _bg = _KIND_STYLE.get(kind, _KIND_STYLE["info"]) body = html.escape(text).replace("\n", "
") return ( f'
' f'{glyph} ' f'{body}
' ) 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'⎇ {html.escape(git_branch)}{dirty}' model_part = html.escape(model) if model else "—" host_part = html.escape(host) if host else "" return ( '
' f'◆ smolcode' f"{git_part}" f'{model_part}' f'@ {host_part}' f'{html.escape(theme)}' "
" ) 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'think:{settings.think}' run = 'running' 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 ( '
' f'smolcode' f'{sess}' f'{ws}' f'' f'' f"{think}{run}" f'' f'' "
" ) 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'
… +{more} more
') 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'
{html.escape(session_id[:26])}
', '
', ] for line in extra_lines or []: parts.append(f'
{html.escape(line)}
') parts.append(f'
files: {file_count}
') parts.append(f'
agent: {html.escape(agent)}
') 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
… {total - len(paths)} more files
' return ( f'
' f'
{html.escape(title)}
' f'
{body}
' f"
" )