Spaces:
Paused
Paused
| """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"), | |
| } | |
| class TranscriptLine: | |
| kind: str | |
| text: str | |
| 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>" | |
| ) | |