Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ diffui/
├── 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, bulk resolve, suggestion apply
│ ├── routes_diff.py # /api/files, diff, file, blame, preview, risk scoring
Expand All @@ -29,7 +28,8 @@ diffui/
│ ├── 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
│ ├── terminal_host.py # herdr CLI calls: agent panes, paste, sidebar status
│ ├── turns.py # Last turn: agent status poll, start snapshot, /api/last-turn
│ ├── models.py # Pydantic request models
│ ├── state.py # AppState singleton
│ └── theme_css.py # generate_css_vars(theme)
Expand Down Expand Up @@ -70,11 +70,12 @@ diffui/
- Diff caching: server-side `_diff_cache` is keyed by `(repo, merge_base, path, view:context:whitespace, mtime)` and holds one entry per path and view key. The web client keeps a `diffCache` Map and clears it on `files_changed` and `git_changed`. `AppState.numstat` refreshes on `reload_repo_state` and on `files_changed`.
- Auto-unreview: `_invalidate_stale_reviews()` in `events.py` unreviews files whose mtime no longer matches the stored one, on both `files_changed` and `git_changed`.
- Risk scoring: `_score_risk()` in `routes_diff.py` turns path patterns, config extensions, deletions, churn and test removal into a `risk_level`. `/api/files?order=risk` sorts by its score.
- Review counts: `AppState.review_files`, `reviewed_count` and `open_comments()` feed the progress bar, the summary, the agent prompt and the herdr sidebar, so they all count the same files. `diffui --json` builds the same file set without `AppState`.
- Review counts: `AppState.review_files`, `reviewed_count` and `open_comments()` feed the progress bar, the summary, the agent prompt and the sidebar status, so they all count the same files. `diffui --json` builds the same file set without `AppState`.
- Ignore patterns: `.diffuiignore` in the repo root uses fnmatch syntax. `/api/files` marks matches `ignored: true` and drops them when `hide_ignored` is set. The agent context still includes them.
- Agent orchestration: the confirm dialog calls `/api/agent/start`, which only queues a start. The agent spawns when the client opens `/api/agent/ws` and sends `init` with the terminal size, so the PTY begins at the right dimensions. The agent's TUI streams to the `AgentTerminal` panel. Output goes into a 64KB buffer that replays on reconnect. Three seconds without output after activity sends an `idle` message. `_build_agent_context` writes the prompt context file. `/api/agent/run` and `/api/agent/status` are the non-terminal path. The web UI does not call them.
- 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` sets two sidebar tokens after each state change: `comments=N open` and `reviewed=☑ N/M`. An empty value clears a token. `clear_status` removes both 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`. It 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`. That ref 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. `reload_last_turn` only marks it stale. The next read of `last_turn` snapshots, so file events while an agent writes cost nothing until a client asks for the view. Each file list and diff comes from one pair of trees, untracked files included. `GET /api/last-turn` returns the reason an empty view is empty. 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.
- Terminal host: `terminal_host.py` is the only module that names herdr. The rest of `server/`, the TUI and the web UI talk about the agent pane. Callers get an agent's name (`agent_pane_name`) and statuses (`repo_agent_statuses`), never herdr's agent dicts.
- Agent pane: inside a herdr pane (`HERDR_WORKSPACE_ID` set), `/api/agent/prompt` returns the workspace's agent as `pane_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` sets two sidebar tokens after each state change: `comments=N open` and `reviewed=☑ N/M`. An empty value clears a token. `clear_status` removes both 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 `repo_agent_statuses` (`herdr agent list`) every second, only when `HERDR_WORKSPACE_ID` is set and `herdr` is on `PATH`. It 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`. That ref 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. `reload_last_turn` only marks it stale. The next read of `last_turn` snapshots, so file events while an agent writes cost nothing until a client asks for the view. Each file list and diff comes from one pair of trees, untracked files included. `GET /api/last-turn` returns the reason an empty view is empty. 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`, `hits` (`path`, `line`, `text`, read from the working tree, case-insensitive, at most `MAX_HITS` = 200) and `truncated`. Files and hits both carry `changed`, whether `view` lists the file. `scope=changed`, the default, covers the files `view_files(view)` lists, minus `.diffuiignore` matches. It matches names as a substring or in-order letters, best first, adds fff's fuzzy matches so a typo still finds its file, reads the first 1 MB of each file and skips binary ones. `scope=all` searches the whole repo through fff, which skips `.gitignore`d and binary files and does not apply `.diffuiignore`. fff's fuzzy name matches are capped at `FILE_CAP` = 30.
- fff index: `fff_index.finder()` hands out one `FileFinder` for the active repo under a lock, since sync routes run in a threadpool. The first search that needs it builds it, never 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()` rescans before it hands the index out. The index is dropped when the repo changes (`switch_repo`, or a different `get_repo_root()`) and closed in the lifespan shutdown. A search that arrives inside the 400ms debounce misses the write, and the next keystroke finds it. `/api/search` lowercases the query because fff matches a lowercase query case-insensitively. `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. Without the allowlist, a branch under review can run script in the diffui origin. Diagram blocks go to kroki.io, the one call that leaves the machine.
Expand Down Expand Up @@ -113,9 +114,9 @@ After a change:

## Terminal UI patterns

- Thin client: the TUI holds no review logic. Comments, reviewed state, risk scores, file order, the agent prompt, the herdr paste and the herdr status all stay in `server/`. When the TUI needs data the API lacks, add or extend an endpoint. Pygments highlighting of the `text` the diff endpoint returns is the one thing computed client-side, because it is presentation. `_next_unreviewed()` in `tui/app.py` is the exception: it takes the first unreviewed file in the order `/api/files` returned.
- Thin client: the TUI holds no review logic. Comments, reviewed state, risk scores, file order, the agent prompt, the pane paste and the sidebar status all stay in `server/`. When the TUI needs data the API lacks, add or extend an endpoint. Pygments highlighting of the `text` the diff endpoint returns is the one thing computed client-side, because it is presentation. `_next_unreviewed()` in `tui/app.py` is the exception: it takes the first unreviewed file in the order `/api/files` returned.
- Everything the server returns passes through `_clean` in `client.py`, which strips control characters. Diff text and comments come from the branch under review, and an escape sequence in them would reach the terminal as a command. A new `ApiClient` method must return through `_json`.
- `LocalServer` runs `create_app` under uvicorn in a background thread so the lifespan starts the watcher and herdr reporting. The client uses `http://localhost:<port>`, which `_LocalOriginOnly` accepts.
- `LocalServer` runs `create_app` under uvicorn in a background thread so the lifespan starts the watcher and status reporting. The client uses `http://localhost:<port>`, which `_LocalOriginOnly` accepts.
- Layout: the diff is on the left and `FileList` on the right. `z` hides the list. Below 110 columns, `HORIZONTAL_BREAKPOINTS` on `DiffuiApp` adds `-narrow` to the screen and the list stacks below the diff. `DiffuiApp.view` is the scope (`all`, `working`, `last-turn` or a commit sha) and goes to `/api/files`, `/api/diff`, `/api/file` and `/api/search` as `view`. `u`, `b` and `t` switch it and `C` picks a commit. The header shows it, and an empty last-turn scope shows the server's reason.
- File order: `/api/files?order=tree|risk`. `hide_ignored` drops `.diffuiignore` matches. Each entry carries `dir` and `open_comment_count`.
- `DiffView` is a `ScrollView` that renders rows on demand. Pygments runs on a row the first time it is drawn, so a few thousand lines open at once. A comment's `line_index` is the diff line's `index`, the same anchor the web UI uses. Threads whose line left the diff are listed after the last hunk.
Expand Down
2 changes: 1 addition & 1 deletion diffui/server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
from diffui.server import fff_index
from diffui.server.events import router as events_router
from diffui.server.events import start_poller
from diffui.server.herdr import clear_status, report_status
from diffui.server.routes_comments import router as comments_router
from diffui.server.routes_diff import router as diff_router
from diffui.server.routes_repo import router as repo_router
Expand All @@ -22,6 +21,7 @@
from diffui.server.routes_search import router as search_router
from diffui.server.routes_settings import router as settings_router
from diffui.server.state import app_state
from diffui.server.terminal_host import clear_status, report_status
from diffui.server.turns import router as turns_router
from diffui.server.turns import start_turn_poller
from diffui.themes import ALL_THEMES
Expand Down
2 changes: 1 addition & 1 deletion diffui/server/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@
load_comments,
)
from diffui.server import fff_index
from diffui.server.herdr import report_status
from diffui.server.state import app_state
from diffui.server.terminal_host import report_status

log = logging.getLogger(__name__)

Expand Down
2 changes: 1 addition & 1 deletion diffui/server/routes_repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@
)
from diffui.server import fff_index
from diffui.server.events import restart_watcher
from diffui.server.herdr import report_status
from diffui.server.models import RepoSwitch
from diffui.server.state import app_state
from diffui.server.terminal_host import report_status

router = APIRouter(prefix="/api")

Expand Down
7 changes: 3 additions & 4 deletions diffui/server/routes_review.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@
from fastapi import APIRouter

from diffui.git_utils import get_file_mtime, repo_relative, save_reviewed
from diffui.server.herdr import find_agent, paste_to_agent
from diffui.server.state import app_state
from diffui.server.terminal_host import agent_pane_name, paste_to_agent_pane

_agent_process: subprocess.Popen | None = None
_explain_process: subprocess.Popen | None = None
Expand Down Expand Up @@ -237,14 +237,13 @@ def get_agent_prompt():
if result[0] is None:
return {"ok": False, "error": result[1]}
prompt, repo_root, context_path, open_count = result
herdr_agent = find_agent(Path(repo_root))
return {
"ok": True,
"prompt": prompt,
"context_path": context_path,
"agent_cli": app_state.agent_cli,
"comment_count": open_count,
"herdr_agent": herdr_agent["agent"] if herdr_agent else None,
"pane_agent": agent_pane_name(Path(repo_root)),
}


Expand All @@ -254,7 +253,7 @@ def paste_agent_prompt():
if result[0] is None:
return {"ok": False, "error": result[1]}
prompt, repo_root, _, _ = result
return paste_to_agent(prompt, Path(repo_root))
return paste_to_agent_pane(prompt, Path(repo_root))


@router.post("/agent/run")
Expand Down
29 changes: 23 additions & 6 deletions diffui/server/herdr.py → diffui/server/terminal_host.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
"""The terminal host: the multiplexer diffui runs inside, which owns the agent panes and the sidebar.

Only this module knows the host is herdr. Callers get agent names and status strings (`idle`, `working`,
`done`, `blocked`), never the host's agent dicts.
"""

from __future__ import annotations

import json
Expand All @@ -8,6 +14,7 @@

from diffui.server.state import app_state

NAME = "herdr" # shown to users in messages that say which host diffui must run in
_SOURCE = "diffui"
_PASTE_START = "\x1b[200~"
_PASTE_END = "\x1b[201~"
Expand All @@ -31,18 +38,22 @@ def available() -> bool:
return bool(_workspace()) and shutil.which("herdr") is not None


def repo_agents(repo_root: Path) -> list[dict] | None:
"""Every herdr agent working inside the repo, in any workspace. None when herdr could not answer."""
def repo_agent_statuses(repo_root: Path) -> list[str] | None:
"""Status of every agent working inside the repo, in any workspace. None when the host could not answer."""
listing = _herdr("agent", "list")
if listing is None:
return None
root = repo_root.resolve()
agents = listing.get("result", {}).get("agents", [])
# foreground_cwd is where the agent is now. cwd is where its pane started.
return [a for a in agents if Path(a.get("foreground_cwd") or a.get("cwd") or "/").resolve().is_relative_to(root)]
return [
a.get("agent_status", "unknown")
for a in agents
if Path(a.get("foreground_cwd") or a.get("cwd") or "/").resolve().is_relative_to(root)
]


def find_agent(repo_root: Path) -> dict | None:
def _find_agent(repo_root: Path) -> dict | None:
workspace = _workspace()
if not workspace:
return None
Expand All @@ -55,8 +66,8 @@ def find_agent(repo_root: Path) -> dict | None:
return (in_repo or agents or [None])[0]


def paste_to_agent(text: str, repo_root: Path) -> dict:
agent = find_agent(repo_root)
def paste_to_agent_pane(text: str, repo_root: Path) -> dict:
agent = _find_agent(repo_root)
if agent is None:
return {"ok": False, "error": "No agent found in this herdr workspace"}
# Claude Code silently drops a paste while a permission prompt is open.
Expand All @@ -71,6 +82,12 @@ def paste_to_agent(text: str, repo_root: Path) -> dict:
return {"ok": True, "agent": agent["agent"]}


def agent_pane_name(repo_root: Path) -> str | None:
"""Name of the agent a paste would reach, or None when there is no pane to paste into."""
agent = _find_agent(repo_root)
return agent["agent"] if agent else None


def _report_metadata(*flags: str) -> None:
workspace = _workspace()
if workspace:
Expand Down
14 changes: 8 additions & 6 deletions diffui/server/turns.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from fastapi import APIRouter

from diffui.git_utils import get_repo_root, snapshot_worktree, write_turn_tree
from diffui.server import herdr
from diffui.server import terminal_host
from diffui.server.events import _bg_tasks, _broadcast
from diffui.server.routes_diff import clear_diff_cache
from diffui.server.state import app_state
Expand Down Expand Up @@ -48,11 +48,11 @@ def start_turn() -> None:


def poll_once(tracker: TurnTracker) -> bool:
agents = herdr.repo_agents(get_repo_root())
if agents is None:
statuses = terminal_host.repo_agent_statuses(get_repo_root())
if statuses is None:
tracker.lost()
return False
if not tracker.observe([a.get("agent_status", "unknown") for a in agents]):
if not tracker.observe(statuses):
return False
start_turn()
return True
Expand All @@ -64,7 +64,9 @@ def last_turn():
turn = app_state.last_turn
if turn is None:
tail = (
"" if herdr.available() else ". Last turn needs diffui in a herdr pane with an agent working in this repo"
""
if terminal_host.available()
else f". Last turn needs diffui in a {terminal_host.NAME} pane with an agent working in this repo"
)
return {"reason": "No agent turn seen yet" + tail}
return {"reason": None if turn.stats else "Nothing has changed since the last turn started"}
Expand All @@ -86,7 +88,7 @@ async def _poll_loop() -> None:


def start_turn_poller() -> asyncio.Task | None:
if not herdr.available():
if not terminal_host.available():
return None
task = asyncio.get_running_loop().create_task(_poll_loop())
_bg_tasks.add(task)
Expand Down
6 changes: 3 additions & 3 deletions diffui/static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -565,9 +565,9 @@ function App() {
setShowAgentConfirm(data);
}, []);

const handleConfirmAgent = useCallback(async (herdrAgent) => {
const handleConfirmAgent = useCallback(async (paneAgent) => {
setShowAgentConfirm(null);
if (herdrAgent) {
if (paneAgent) {
const r = await safeFetch('/api/agent/paste', { method: 'POST' });
const data = await r.json();
showToast(data.ok ? `Pasted into ${data.agent}. Press Enter there to send.` : data.error, data.ok ? 'success' : 'error');
Expand Down Expand Up @@ -1168,7 +1168,7 @@ function App() {
${showAgentConfirm && html`
<${AgentConfirmDialog}
agentCli=${showAgentConfirm.agent_cli}
herdrAgent=${showAgentConfirm.herdr_agent}
paneAgent=${showAgentConfirm.pane_agent}
commentCount=${showAgentConfirm.comment_count}
onConfirm=${handleConfirmAgent}
onCancel=${() => setShowAgentConfirm(null)}
Expand Down
Loading
Loading