Skip to content

Commit 09552eb

Browse files
committed
web: project-path URLs, syntax highlighting, richer review comments + agent chat
Dashboard (`pyclawd web`) improvements: - URL maps to the project: the active project lives in the path (`/<project>`) with per-project view state (refs, mode, layout, selected file) restored from localStorage on reload; FastAPI serves a SPA catch-all so deep links work. - Server-side syntax highlighting: Pygments (ships with rich) colours diff lines using whole-file lexer context, so multi-line constructs render correctly. - Diff fixes: line-number gutters now tint with their add/del row (the bg-panel base was overriding the tint), and every row cell is top-aligned so numbers and comment markers pin to the first line when content wraps. - Review comments redesigned: GitHub-style cards with a markdown editor + live preview (react-markdown + remark-gfm), inline edit, caret-at-end on open, and a split-view anchor dedup fix. - Review list grouped by file with click-to-jump (scrolls + flashes the line). - Agent: stronger, structured review prompt; the agent panel is now an interactive chat (resume the session for follow-ups) with a model picker defaulting to sonnet.
1 parent b772644 commit 09552eb

17 files changed

Lines changed: 2547 additions & 255 deletions

pyproject.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,3 +175,10 @@ ignore_missing_imports = true
175175
[[tool.mypy.overrides]]
176176
module = ["fastapi", "fastapi.*", "pydantic", "uvicorn", "watchfiles"]
177177
ignore_missing_imports = true
178+
179+
# Pygments ships transitively with rich (a core dep) and powers the web layer's
180+
# server-side syntax highlighting (src/pyclawd/web/highlight.py), but publishes no
181+
# type stubs. Scope the missing-import allowance to it alone.
182+
[[tool.mypy.overrides]]
183+
module = ["pygments", "pygments.*"]
184+
ignore_missing_imports = true

src/pyclawd/web/app.py

Lines changed: 53 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,7 @@
2626
from pathlib import Path
2727

2828
from fastapi import FastAPI, HTTPException
29-
from fastapi.responses import StreamingResponse
30-
from fastapi.staticfiles import StaticFiles
29+
from fastapi.responses import FileResponse, StreamingResponse
3130
from pydantic import BaseModel
3231

3332
from .git import WORKING_TREE, GitRepo, Ref
@@ -91,12 +90,16 @@ class AgentBody(BaseModel):
9190
9291
``full_access`` chooses the permission mode: ``True`` lets the agent run commands
9392
as well as edit files (``bypassPermissions``); ``False`` is edits-only
94-
(``acceptEdits``) — safer, but it can't run shell commands.
93+
(``acceptEdits``) — safer, but it can't run shell commands. ``model`` is a CLI
94+
model alias (``sonnet``/``opus``/``haiku``); ``resume`` continues an existing
95+
``claude`` session by id, turning the one-shot run into an interactive chat.
9596
"""
9697

9798
project: str
9899
prompt: str
99100
full_access: bool = False
101+
model: str | None = None
102+
resume: str | None = None
100103

101104

102105
#: The pyclawd verbs the dashboard can launch, mapped to their argv. An allowlist —
@@ -275,7 +278,7 @@ def agent_run(body: AgentBody) -> StreamingResponse:
275278
if shutil.which("claude") is None:
276279
raise HTTPException(status_code=400, detail="the 'claude' CLI is not on PATH")
277280
return StreamingResponse(
278-
_agent_stream(Path(path), body.prompt, body.full_access),
281+
_agent_stream(Path(path), body.prompt, body.full_access, body.model, body.resume),
279282
media_type="text/event-stream",
280283
)
281284

@@ -317,9 +320,31 @@ def set_config(body: ConfigBody) -> dict:
317320
return {"ok": True, "roots": reg.set_roots(body.roots)}
318321

319322
# -- static SPA (only when built) -------------------------------------- #
323+
#
324+
# The frontend routes the active project in the URL *path* (``/<project>``) so a
325+
# reload — or a shared link — lands back in the same project, with that project's
326+
# view state restored from localStorage. That means any non-API path must serve
327+
# the SPA shell (``index.html``) rather than 404, while still serving the real
328+
# built assets (``/assets/…``, ``favicon`` …) straight from disk. A single
329+
# catch-all does both: existing file → that file; anything else → ``index.html``.
320330

321331
if _STATIC_DIR.is_dir():
322-
app.mount("/", StaticFiles(directory=str(_STATIC_DIR), html=True), name="spa")
332+
index_html = _STATIC_DIR / "index.html"
333+
334+
@app.get("/{full_path:path}")
335+
def spa(full_path: str) -> FileResponse:
336+
"""Serve a built asset if it exists, else the SPA shell (client routing)."""
337+
if full_path.startswith("api/"):
338+
# An unmatched /api/* path is a genuine miss, not a client route.
339+
raise HTTPException(status_code=404, detail="not found")
340+
candidate = (_STATIC_DIR / full_path).resolve()
341+
if (
342+
full_path
343+
and candidate.is_file()
344+
and candidate.is_relative_to(_STATIC_DIR.resolve())
345+
):
346+
return FileResponse(str(candidate))
347+
return FileResponse(str(index_html))
323348

324349
return app
325350

@@ -400,7 +425,13 @@ def _summarise_agent_json(line: str) -> list[tuple[str, str]]:
400425
etype = event.get("type")
401426
if etype == "system" and event.get("subtype") == "init":
402427
model = event.get("model", "")
403-
return [("log", f"● session started{f' ({model})' if model else ''}")]
428+
init_frames: list[tuple[str, str]] = [
429+
("log", f"● session started{f' ({model})' if model else ''}")
430+
]
431+
if sid := event.get("session_id"):
432+
# Hand the session id to the client so it can resume (continue the chat).
433+
init_frames.append(("session", sid))
434+
return init_frames
404435
if etype == "assistant":
405436
frames: list[tuple[str, str]] = []
406437
for block in event.get("message", {}).get("content", []):
@@ -423,30 +454,34 @@ def _summarise_agent_json(line: str) -> list[tuple[str, str]]:
423454
return []
424455

425456

426-
async def _agent_stream(cwd: Path, prompt: str, full_access: bool) -> AsyncIterator[str]:
457+
async def _agent_stream(
458+
cwd: Path,
459+
prompt: str,
460+
full_access: bool,
461+
model: str | None = None,
462+
resume: str | None = None,
463+
) -> AsyncIterator[str]:
427464
"""Run ``claude -p`` headless in *cwd* and stream a readable progress log over SSE.
428465
429466
With *full_access* the agent runs under ``bypassPermissions`` (edits files AND
430467
runs commands unattended); otherwise ``acceptEdits`` (edits only — safer, but it
431-
cannot run shell commands). Structured ``stream-json`` events are distilled to
432-
readable frames; a final ``done`` frame carries the exit code.
468+
cannot run shell commands). *model* picks a CLI model alias; *resume* continues an
469+
existing session id (a follow-up chat turn). Structured ``stream-json`` events are
470+
distilled to readable frames; a final ``done`` frame carries the exit code.
433471
"""
434472
mode = "bypassPermissions" if full_access else "acceptEdits"
473+
argv = ["-p", prompt, "--output-format", "stream-json", "--verbose", "--permission-mode", mode]
474+
if model:
475+
argv += ["--model", model]
476+
if resume:
477+
argv += ["--resume", resume]
435478
proc = await asyncio.create_subprocess_exec(
436479
"claude",
437-
"-p",
438-
prompt,
439-
"--output-format",
440-
"stream-json",
441-
"--verbose",
442-
"--permission-mode",
443-
mode,
480+
*argv,
444481
cwd=str(cwd),
445482
stdout=asyncio.subprocess.PIPE,
446483
stderr=asyncio.subprocess.STDOUT,
447484
)
448-
access = "full access" if full_access else "edits only"
449-
yield _agent_event("log", f"$ claude -p (headless · {access}) in {cwd.name}")
450485
try:
451486
assert proc.stdout is not None
452487
async for raw in proc.stdout:

src/pyclawd/web/git.py

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
import hashlib
2424
import re
2525
import subprocess
26-
from dataclasses import dataclass, field
26+
from dataclasses import dataclass, field, replace
2727
from enum import Enum
2828
from pathlib import Path
2929
from typing import Final
@@ -99,12 +99,15 @@ class DiffLine:
9999
old: 1-based line number on the old side, or ``None`` for an added line.
100100
new: 1-based line number on the new side, or ``None`` for a deleted line.
101101
content: The line's text, without the leading ``+``/``-``/`` `` marker.
102+
html: Syntax-highlighted HTML for :attr:`content` (Pygments), or ``None``
103+
when the file type is not highlightable — callers render plain text.
102104
"""
103105

104106
kind: LineKind
105107
old: int | None
106108
new: int | None
107109
content: str
110+
html: str | None = None
108111

109112

110113
@dataclass(frozen=True)
@@ -441,19 +444,56 @@ def file_view(
441444

442445
if base_wt and target_wt:
443446
# Both sides are the working tree — nothing to diff; just show the file.
444-
return self._plain_view(path, WORKING_TREE)
447+
return self._highlight(self._plain_view(path, WORKING_TREE), WORKING_TREE, WORKING_TREE)
445448

446449
status = self._derive_status(base, target, path)
447450
diff = self._raw_diff(base, target, path, target_wt)
448451
hunks = parse_hunks(diff)
449452
if hunks is None:
450453
return FileView(path=path, status=status, mode=mode, binary=True)
451454
if not hunks:
452-
return self._plain_view(path, new_ref)
455+
return self._highlight(self._plain_view(path, new_ref), old_ref, new_ref)
453456

454457
if mode == "full":
455-
return self._full_view(path, status, hunks, old_ref, new_ref)
456-
return FileView(path=path, status=status, mode="diff", hunks=hunks)
458+
return self._highlight(
459+
self._full_view(path, status, hunks, old_ref, new_ref), old_ref, new_ref
460+
)
461+
view = FileView(path=path, status=status, mode="diff", hunks=hunks)
462+
return self._highlight(view, old_ref, new_ref)
463+
464+
def _highlight(self, view: FileView, old_ref: Ref | None, new_ref: Ref | None) -> FileView:
465+
"""Attach Pygments HTML to every diff line, using full-file lexer context.
466+
467+
Each line is coloured from the *whole-file* highlight of the side it lives
468+
on (deletions from the old side, additions/context from the new), so
469+
multi-line constructs like docstrings render correctly on every line.
470+
Returns *view* unchanged when the file type is not highlightable.
471+
"""
472+
if view.binary:
473+
return view
474+
from .highlight import highlight_lines
475+
476+
old_html = highlight_lines(view.path, self._read_side(old_ref, view.path))
477+
new_html = highlight_lines(view.path, self._read_side(new_ref, view.path))
478+
if old_html is None and new_html is None:
479+
return view
480+
481+
def hl(line: DiffLine) -> DiffLine:
482+
if line.kind is LineKind.DEL and line.old is not None and old_html is not None:
483+
src, idx = old_html, line.old - 1
484+
elif line.new is not None and new_html is not None:
485+
src, idx = new_html, line.new - 1
486+
elif line.old is not None and old_html is not None:
487+
src, idx = old_html, line.old - 1
488+
else:
489+
return line
490+
return replace(line, html=src[idx]) if 0 <= idx < len(src) else line
491+
492+
if view.mode == "full":
493+
return replace(view, lines=[hl(line) for line in view.lines])
494+
return replace(
495+
view, hunks=[replace(h, lines=[hl(line) for line in h.lines]) for h in view.hunks]
496+
)
457497

458498
def _raw_diff(self, base: Ref, target: Ref | None, path: str, target_wt: bool) -> str:
459499
"""Return raw ``git diff`` text for *path*, handling brand-new untracked files."""

src/pyclawd/web/highlight.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
"""Server-side syntax highlighting for diff lines (Pygments, full-file context).
2+
3+
Highlighting a diff line-by-line in the browser would mangle multi-line
4+
constructs — docstrings, triple-quoted strings — because each line loses the
5+
lexer state of the lines around it. Instead we tokenise the *whole* file on the
6+
server with Pygments and hand the frontend one ready-made HTML fragment per
7+
source line, so a docstring spanning ten lines is coloured correctly on every
8+
one of them.
9+
10+
Pygments ships transitively with rich (a core pyclawd dependency), so this adds
11+
no install cost. It still degrades gracefully: any failure — no lexer for the
12+
file type, Pygments unavailable — returns ``None`` and callers fall back to
13+
plain text.
14+
"""
15+
16+
from __future__ import annotations
17+
18+
from functools import lru_cache
19+
from typing import Any
20+
21+
#: CSS class prefix for emitted token spans (matched by the dashboard stylesheet).
22+
CLASS_PREFIX = "pl-"
23+
24+
25+
@lru_cache(maxsize=256)
26+
def _lexer_for(path: str) -> Any | None:
27+
"""Return a Pygments lexer for *path* by filename, or ``None`` if unknown."""
28+
try:
29+
from pygments.lexers import get_lexer_for_filename
30+
from pygments.lexers.special import TextLexer
31+
from pygments.util import ClassNotFound
32+
except ImportError:
33+
return None
34+
try:
35+
lexer = get_lexer_for_filename(path, stripnl=False, ensurenl=False)
36+
except ClassNotFound:
37+
return None
38+
# Plain-text files resolve to TextLexer, which emits no token spans — skip it
39+
# so callers fall back to plain text instead of shipping redundant markup.
40+
return None if isinstance(lexer, TextLexer) else lexer
41+
42+
43+
def highlight_lines(path: str, source: list[str]) -> list[str] | None:
44+
"""Return one HTML fragment per line of *source*, or ``None`` if not highlightable.
45+
46+
The result is aligned 1:1 with *source* (same length). Each fragment is safe
47+
to inject as-is: Pygments HTML-escapes the text and never emits a span that
48+
crosses a line boundary, so per-line injection is sound.
49+
50+
Args:
51+
path: Repo-relative path; its extension selects the lexer.
52+
source: The file's lines, without trailing newlines.
53+
54+
Returns:
55+
A list of HTML strings (one per input line), or ``None`` when no lexer
56+
matches the file type or Pygments is unavailable.
57+
"""
58+
lexer = _lexer_for(path)
59+
if lexer is None or not source:
60+
return None
61+
try:
62+
from pygments import highlight
63+
from pygments.formatters import HtmlFormatter
64+
except ImportError:
65+
return None
66+
fmt = HtmlFormatter(nowrap=True, classprefix=CLASS_PREFIX)
67+
rendered = highlight("\n".join(source), lexer, fmt).split("\n")
68+
# Pygments may emit a trailing empty element; align strictly to the input.
69+
if len(rendered) < len(source):
70+
rendered += [""] * (len(source) - len(rendered))
71+
return rendered[: len(source)]

0 commit comments

Comments
 (0)