diff --git a/AGENTS.md b/AGENTS.md index 7a44aea..d5ff875 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,502 +1,182 @@ # diffui -UI for reviewing AI agent diffs. FastAPI backend + Preact frontend, -no build step required, plus a Textual terminal client of the same API. - -## Tech Stack - -- Python 3.10+ -- FastAPI + uvicorn (server) -- Preact + HTM (frontend, no build step) -- Pygments for syntax highlighting -- watchfiles for real-time filesystem watching (Rust-backed FSEvents/inotify) -- xterm.js for interactive agent terminal (loaded via ESM importmap) -- Git operations via `subprocess` (no gitpython dependency) -- PTY-based agent process management (`pty` + `select` stdlib) -- Textual + httpx for the terminal UI (`diffui --tui`) -- fff-search (Rust file finder) for whole-repo search -- Installed globally via `pipx install -e .` +UI for reviewing AI agent diffs. A FastAPI server with a Preact web frontend, plus a Textual terminal client (`diffui --tui`) that talks to the same API. + +## Stack + +- Python 3.10+, FastAPI + uvicorn, Pygments, watchfiles, fff-search (whole-repo search), Textual + httpx (TUI). +- The frontend has no build step. Preact, HTM, marked and xterm.js load through the importmap in `static/index.html`. Don't add a bundler. +- Git runs through `subprocess` (no gitpython). The agent terminal uses `pty` and `select`. +- Install with `pipx install -e .`. ## Architecture ```text diffui/ -├── cli.py # Entry point: starts FastAPI server -├── diff.py # Pure functions: diff parsing, line classification, syntax -│ # highlighting, word-level diff ranges -├── git_utils.py # Git subprocess helpers, blame, state persistence, -│ # repo discovery, session management -├── server/ # FastAPI backend (web UI) -│ ├── app.py # App factory: mounts routes, static files, starts watcher -│ ├── events.py # WebSocket + SSE endpoints, watchfiles-based filesystem -│ │ # watcher with 400ms debounce -│ ├── herdr.py # herdr CLI calls: agent paste, sidebar status, agent list +├── cli.py # Entry point: server, --tui, --comments, --json +├── diff.py # Pure functions: diff parsing, line classification, word-level ranges +├── git_utils.py # Git subprocess helpers, blame, state persistence, repo discovery +├── server/ # FastAPI backend +│ ├── app.py # App factory, _LocalOriginOnly middleware, lifespan (watcher, pollers) +│ ├── events.py # /api/ws and /api/events, watchfiles watcher with 400ms debounce +│ ├── herdr.py # herdr CLI calls: agent list, paste, sidebar status │ ├── highlight.py # Pygments-to-HTML adapter, word highlight overlay -│ ├── routes_comments.py # Comment CRUD, suggestion apply endpoint -│ ├── routes_diff.py # Diff, files, blame, preview, risk scoring -│ ├── routes_repo.py # Repo listing and switching -│ ├── routes_review.py # Review toggle, summary export, agent/explain -│ │ # orchestration (thread-safe process management) -│ ├── routes_agent_terminal.py # PTY-based interactive agent terminal, -│ │ # WebSocket streaming, idle detection -│ ├── fff_index.py # Resident fff index of the active repo behind /api/search -│ ├── routes_search.py # /api/search: file names and content, in a view or the whole repo -│ ├── routes_settings.py # Settings, editor open, session persistence -│ ├── models.py # Pydantic request models for all endpoints -│ ├── state.py # AppState singleton: shared server state -│ ├── turns.py # Last-turn tracking: herdr agent poll, start snapshot -│ └── theme_css.py # generate_css_vars(theme): CSS custom properties -├── tui/ # Textual client (`diffui --tui`), a thin client of server/ -│ ├── app.py # DiffuiApp: key bindings, scopes, review actions, SSE-driven refresh +│ ├── routes_comments.py # Comment CRUD, bulk resolve, suggestion apply +│ ├── routes_diff.py # /api/files, diff, file, blame, preview, risk scoring +│ ├── routes_repo.py # Repo listing and switching, branch, commits +│ ├── routes_review.py # Reviewed toggle, summary, agent prompt/paste/run, explain +│ ├── routes_agent_terminal.py # PTY agent terminal: /api/agent/start, kill, ws +│ ├── routes_search.py # /api/search: file names and content +│ ├── routes_settings.py # Settings, editor open, session +│ ├── fff_index.py # Resident fff index of the active repo behind /api/search +│ ├── turns.py # Last turn: herdr poll, start snapshot, /api/last-turn +│ ├── models.py # Pydantic request models +│ ├── state.py # AppState singleton +│ └── theme_css.py # generate_css_vars(theme) +├── tui/ # Textual client, a thin client of server/ +│ ├── app.py # DiffuiApp: bindings, scopes, review actions, SSE-driven refresh │ ├── client.py # LocalServer (uvicorn in a thread) and ApiClient (httpx) │ ├── diffview.py # DiffView: virtual-scrolled diff with inline threads, cursor, mouse -│ ├── rows.py # Row and build_rows: unified rows and split old/new pairs +│ ├── rows.py # Row, build_rows, split_pairs │ ├── unified.py # Strip for one unified line row │ ├── split.py # Strip for one split line row -│ ├── style.py # Style cache, tints, code segments with find highlights +│ ├── style.py # Style cache, tints, marker and prefix_fg, code segments with find spans │ ├── find.py # Find (matches over rows) and the FindBar widget -│ ├── search.py # SearchScreen: names and content, Changed or All files +│ ├── search.py # SearchScreen │ ├── filelist.py # FileList: tree or risk-ordered files │ └── screens.py # Help, comment composer, agent prompt and commit picker -├── static/ # Preact frontend (no build step, ESM imports via importmap) -│ ├── app.js # Root component, state management, keyboard shortcuts, -│ │ # WebSocket client, session restore -│ ├── lib/ # Shared utilities -│ │ ├── markdown.js # marked.js wrapper with GFM config -│ │ └── utils.js # shortName(), mergeRef(), useResize() helpers -│ ├── components/ # TopBar, FileHeader, DiffViewer, SplitDiffViewer, -│ │ # FullFileViewer, FileTree, CommentBox, -│ │ # CommentDisplay, CommentsPanel, SearchBar, -│ │ # FileFilterBar, LineActions, SettingsPanel, -│ │ # Minimap, ShortcutOverlay, Toast, -│ │ # CommandPalette, CompletionScreen, PreviewViewer, -│ │ # AgentStatusBar, AgentConfirmDialog, -│ │ # AgentTerminal, GoToLineDialog -│ ├── index.html # Shell page with importmap for preact/htm CDN -│ └── style.css # All styles via CSS custom properties (themed) -└── themes/ - ├── __init__.py # Theme state (get/set_current_theme), re-exports - ├── theme.py # Theme dataclass (30+ color fields) - └── definitions.py # 16 theme instances with Pygments token color maps +├── static/ # Preact frontend: app.js, components/, lib/ (markdown.js, utils.js), style.css +└── themes/ # Theme dataclass, 16 themes in definitions.py, current-theme state ``` -### Shared Patterns - -- Multi-repo discovery: `resolve_repos()` in `git_utils.py` scans - the parent directory for sibling git repos. -- Path containment: `repo_path()` in `git_utils.py` joins a path onto the repo - root and raises `PathOutsideRepo` if the result resolves outside it, symlinks - included, which `app.py` maps to a 400. Every read and write driven by a - request goes through it. A route that only hands the path to git calls - `repo_relative()`, which checks the same way and returns the original string. - Joining `get_repo_root()` with a request value by hand reaches any file on the - machine, because pathlib returns an absolute right-hand operand whole. -- Revisions from a client: a `view` that is none of `all`, `working` or `last-turn` - is looked up in `app_state.commits` before it reaches `git diff`. Git reads a - leading `--` as an option, so an unchecked value can write files. -- Branch-scoped state: reviewed status, comments, and session stored - at `~/.config/diffui/{repo}/{branch}/`. `get_repo_root()` and - `_cached_current_branch()` are `lru_cache`'d. `_save_json` writes a temp - file and renames it. `_load_json` moves an unparseable file aside to - `.corrupt`, so the next save cannot overwrite it. -- Comment threads: comments have `id` (UUID), `author`/`author_type` - fields, `category` (bug/suggestion/nit/question), optional `suggestion` - (proposed code), and `status` (open/resolved). Replies are structured - dicts. Edit locking: user content is editable until an agent replies. - Threads are collapsible in the web UI. -- Multi-agent identity: agent authors get unique colors via a name - hash. Multiple agents reviewing the same branch appear as distinct - identities in comment headers and reply threads. -- Word-level diff: `pair_diff_lines` matches consecutive remove/add - pairs and `word_diff_ranges` uses `difflib.SequenceMatcher` to find - changed word spans. -- Worktree support: `get_git_dir()` resolves the actual `.git` - directory for both regular repos and worktrees. - -### Web UI Patterns - -- WebSocket + SSE: `events.py` provides a WebSocket endpoint - (`/api/ws`) for bidirectional communication. Client prefers WebSocket - and falls back to SSE (`/api/events`) if the connection closes. - Both use `watchfiles` (Rust-backed FSEvents/inotify) for real-time - filesystem watching with 400ms debounce. Watches repo dir, `.git` - dir, and comments file. Background tasks use `_bg_tasks` set to - prevent garbage collection. `start_poller` runs from the lifespan handler, - so both it and `_do_restart` can use `asyncio.get_running_loop()`. -- Git dir boundary: `_classify_changes` compares against the git dir plus a - separator. A bare `startswith` also matches `.gitignore` and `.github/`, - which turns a workflow edit into a full `reload_repo_state()`. -- Watcher follows the branch: comments live in a per-branch dir, and - `_watch_loop` snapshots that path when it starts, so a branch switch - (`git_changed`) restarts the watcher. `reload_repo_state()` also re-reads - comments, so `_apply_state_updates` adds `comments_changed` to the broadcast - when they actually differ, so the client does not sit on a stale thread - while the server has the new one. -- Diff caching: server-side `_diff_cache` keyed by - `(repo, merge_base, path, view, mtime)`. Client-side `diffCache` Map - for instant file switching with stale-while-revalidate. Numstat is - cached in `AppState` and refreshed on `reload_repo_state`. -- Virtual scrolling: `FullFileViewer` uses windowed rendering (only - visible lines + buffer in DOM). Hunk blocks in unified/split views - use `content-visibility: auto` for browser-native off-screen culling. -- Scroll persistence: client saves/restores scroll positions per - file in a Map. Comment dropdown navigation scrolls to the exact line - using `data-line-new` attributes for O(1) DOM lookup. -- Risk scoring: `_score_risk()` in `routes_diff.py` assigns - risk_level (low/medium/high) based on file patterns (migrations, - configs, large deletions, test removal, high churn). Shown as colored - dots on tree items, with the level in the tooltip. Sort-by-risk toggle via command - palette. -- Agent orchestration: confirmation dialog shows agent name, comment - count, and behavior summary. `/api/agent/start` spawns the configured - agent CLI in interactive mode via PTY (`pty.openpty()`). The agent's - full TUI streams to a slide-up terminal panel (`AgentTerminal`) via - a dedicated WebSocket (`/api/agent/ws`). Users can answer permission - prompts, navigate multi-choice menus, and interact directly. Output - is buffered (64KB ring buffer) for reconnect replay. Idle detection - (3s of no output after activity) updates status to "Done — waiting - for input". Stop/Close buttons in terminal header. Context built by - `_build_agent_context` with full diffs, review state, and thread - history. Agent CLI configurable in settings (Claude Code, Codex, - OpenCode, Cursor Agent). Legacy polling endpoints (`/api/agent/run`, - `/api/agent/status`) still available for non-terminal use. -- herdr: inside a herdr pane (`HERDR_WORKSPACE_ID` set), `/api/agent/prompt` - returns the workspace's agent as `herdr_agent`, and the confirm dialog - calls `/api/agent/paste` in place of `/api/agent/start`. The prompt goes to - that pane as one bracketed paste. `report_status` pushes the open comment - and reviewed counts to the herdr sidebar after each state change, and - `clear_status` removes them on shutdown. Tests run a fake `herdr` from - `PATH`. -- Last turn: `view=last-turn` diffs the tree saved when an agent's latest turn - started against the worktree as it is now. `turns.py` polls `herdr agent list` - every second, only when `HERDR_WORKSPACE_ID` is set and `herdr` is on `PATH`, - and counts the agents whose `foreground_cwd` (else `cwd`) is inside the repo, - in any workspace. `TurnTracker` fires on the sample where the repo goes from - resting (`idle`, `done`, or no agent) to `working`. A `blocked` or unknown - agent is mid-turn, so working after it starts nothing, and the first sample - after startup never fires. On the edge `snapshot_worktree` copies the real - index to a temp file, runs `git add -A` and `git write-tree` against the copy, - and `write_turn_tree` stores the tree under `refs/worktree/diffui/last-turn`, - which is per linked worktree and survives a restart. `AppState.last_turn` - holds the base tree, a fresh tree of the worktree and `git diff --numstat` - between them, and `reload_last_turn` rebuilds it from `reload_repo_state` and - every `files_changed` event, so the file list and each diff come from one pair - of trees, untracked files included. `GET /api/last-turn` returns the reason an - empty view is empty ("No agent turn seen yet", or that nothing changed). - Limits: herdr flips to `working` a moment after the agent starts writing, and - the poll adds up to a second, so those first writes sit in the base tree and - do not show. A turn that changes nothing still replaces the previous base. -- Search: `GET /api/search?q=&view=&scope=` returns `files` (names matching `q` as a - substring or in-order letters, best first), `hits` (`path`, `line`, `text`, - read from the working tree, case-insensitive, at most `MAX_HITS` = 200) and - `truncated`. It covers the files `view_files(view)` lists, minus - `.diffuiignore` matches, and skips binary and over 1 MB files. Names also match - fuzzily through fff, so a typo still finds its file, in the order fff ranks them. - `scope=all` searches the whole repo through fff, and `files` are then - `{path, changed}` and each hit carries `changed`, whether `view` lists the file. - `scope=changed`, the default, keeps the plain shape. fff skips `.gitignore`d - and binary files and does not apply `.diffuiignore`. -- fff index: `fff_index.finder()` hands out one `FileFinder` for the active repo - under a lock, since sync routes run in a threadpool. It is built on the first - search that needs it, never at startup, with `watch=False`, because `events.py` - already watches the tree. `_apply_state_updates` calls `mark_stale()` on - `files_changed` and `git_changed`, and the next `finder()` runs `scan_files()` - before it hands the index out, so an idle repo costs no rescans. The index is - dropped when the repo changes (`switch_repo`, or a different `get_repo_root()`) - and closed in the lifespan shutdown. A write is missed by a search that arrives - inside the 400 ms debounce before the event, and the next keystroke finds it. - A lowercase query matches case-insensitively under fff's smart case, so - `/api/search` lowercases it, and `multi_grep` takes it as plain text where - `grep` would read a `*.py` word as a path filter. fff's fuzzy name match - returns something for almost any query, so `_fuzzy_names` keeps only scores - within 75% of the best and above 8 per character of the longest word. -- Markdown is sanitized: `renderMd` in `lib/markdown.js` runs marked's output - through an allowlist of tags, attributes and URL schemes before it reaches - `dangerouslySetInnerHTML`. marked emits raw HTML, and comment bodies, agent - replies and previewed `.md` files all come from outside, so without the - allowlist a branch under review can run script in the diffui origin. Diagram - blocks are sent to kroki.io, the one call that leaves the machine. -- Explain changes: `/api/explain` spawns the configured agent CLI - to generate a self-contained HTML walkthrough. `/api/explain/status` - polls. `/api/explain/view` serves the result as a localhost page - (path-traversal guarded to tempdir). Top bar button + status bar link. -- Ignore whitespace: `ignore_whitespace` query param on diff - endpoints passes `-w` to `git diff`. Frontend toggle (`w` key). -- Ignore patterns: `.diffuiignore` file in repo root, fnmatch - syntax. Files marked `ignored: true` in `/api/files`, hidden by - default in UI but always included in agent context. -- Bulk resolve: `/api/comments/bulk-resolve` with optional - `file_path` filter and `action` (resolve/reopen). Used by command - palette, comments panel header, and the file header. -- Font size / word wrap: `font_size` and `word_wrap` persisted in - settings. Font size sets `--code-font-size` CSS variable. Word wrap - adds `.word-wrap-enabled` class to ``, uses `pre-wrap` on - `.diff-code` with flex-shrink-0 gutter to preserve line numbers. -- Custom keybindings: `keybindings` dict in settings maps action - IDs to key strings. SettingsPanel has a collapsible rebinding UI with - key capture. ShortcutOverlay and CommandPalette reflect overrides. -- Blame gutter: toggleable per-file. Fetches `git blame --porcelain` - via `/api/blame/{path}`. Results cached client-side in a `Map`. -- Preview mode: toggleable for `.md` and image files. Backend uses - `mimetypes.guess_type` for image detection with 10MB size cap. Markdown - rendered via `marked.js` through shared `lib/markdown.js`. -- Layout: one top bar (explorer toggle, repo and branch, connection dot, view - filter, review progress, then Search files, Comments, Explain changes, - Shortcuts, Settings and Send to agent). `App` renders a single `FileHeader` - above the active viewer with the path breadcrumb, the Unified/Split/File - control, Whitespace, Hide reviewed, Blame, context, Resolve all and Mark - reviewed. The file tree is the only file navigator, and `treeOrder()` in - `FileTree.js` gives the order that prev/next file and next-unreviewed follow. - Sort by risk turns the tree into one flat list in risk order. -- Diff file headers: `split_into_hunks` returns the lines before the first `@@` - as a pseudo hunk. `parse_diff_to_json` does not emit that hunk or the `@@` - line of each hunk, but both still advance the line `index`, which saved - comments anchor on. `file_note` (from `file_change_note`) is set when the - header holds a rename, mode change, new or deleted file, or binary marker. - The unified view shows each hunk header once, in the collapsible bar. The - split view prints it in the left pane only. -- Theme switching: server generates CSS custom properties from - Theme dataclass fields. Client injects a `