From 9b51c344f8815aa36cc917f7d2b40ae14faa1470 Mon Sep 17 00:00:00 2001 From: Dillon Sienko Date: Fri, 23 Oct 2026 09:40:08 -0700 Subject: [PATCH 1/3] Move herdr calls behind terminal_host and rename the pane_agent field --- diffui/server/app.py | 2 +- diffui/server/events.py | 2 +- diffui/server/routes_repo.py | 2 +- diffui/server/routes_review.py | 7 ++--- diffui/server/{herdr.py => terminal_host.py} | 29 +++++++++++++++---- diffui/server/turns.py | 14 +++++---- diffui/static/app.js | 6 ++-- .../static/components/AgentConfirmDialog.js | 8 ++--- diffui/tui/client.py | 2 +- diffui/tui/screens.py | 10 +++---- tests/test_server.py | 10 +++++++ 11 files changed, 60 insertions(+), 32 deletions(-) rename diffui/server/{herdr.py => terminal_host.py} (73%) diff --git a/diffui/server/app.py b/diffui/server/app.py index 672aa40..c554ddc 100644 --- a/diffui/server/app.py +++ b/diffui/server/app.py @@ -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 @@ -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 diff --git a/diffui/server/events.py b/diffui/server/events.py index e8db5cb..00250e3 100644 --- a/diffui/server/events.py +++ b/diffui/server/events.py @@ -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__) diff --git a/diffui/server/routes_repo.py b/diffui/server/routes_repo.py index c375015..c21ca3f 100644 --- a/diffui/server/routes_repo.py +++ b/diffui/server/routes_repo.py @@ -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") diff --git a/diffui/server/routes_review.py b/diffui/server/routes_review.py index 1e550b7..2d29495 100644 --- a/diffui/server/routes_review.py +++ b/diffui/server/routes_review.py @@ -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 @@ -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)), } @@ -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") diff --git a/diffui/server/herdr.py b/diffui/server/terminal_host.py similarity index 73% rename from diffui/server/herdr.py rename to diffui/server/terminal_host.py index 34e7717..0ceb588 100644 --- a/diffui/server/herdr.py +++ b/diffui/server/terminal_host.py @@ -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 @@ -8,6 +14,7 @@ from diffui.server.state import app_state +NAME = "herdr" # for user-facing copy that has to say what is required _SOURCE = "diffui" _PASTE_START = "\x1b[200~" _PASTE_END = "\x1b[201~" @@ -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 @@ -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. @@ -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: diff --git a/diffui/server/turns.py b/diffui/server/turns.py index 7ecf434..99bb15b 100644 --- a/diffui/server/turns.py +++ b/diffui/server/turns.py @@ -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 @@ -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 @@ -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"} @@ -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) diff --git a/diffui/static/app.js b/diffui/static/app.js index 220447c..349d95d 100644 --- a/diffui/static/app.js +++ b/diffui/static/app.js @@ -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'); @@ -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)} diff --git a/diffui/static/components/AgentConfirmDialog.js b/diffui/static/components/AgentConfirmDialog.js index 54d5bdd..0bbee9b 100644 --- a/diffui/static/components/AgentConfirmDialog.js +++ b/diffui/static/components/AgentConfirmDialog.js @@ -3,15 +3,15 @@ import htm from 'htm'; const html = htm.bind(h); -export function AgentConfirmDialog({ agentCli, herdrAgent, commentCount, onConfirm, onCancel }) { +export function AgentConfirmDialog({ agentCli, paneAgent, commentCount, onConfirm, onCancel }) { const comments = html`${commentCount || 'the'} open comment${commentCount === 1 ? '' : 's'}`; return html`
{ if (e.target.classList.contains('settings-overlay')) onCancel(); }}>
Send to agent
- ${herdrAgent - ? html`

This will paste a prompt for ${comments} into your ${herdrAgent} pane in herdr. Press Enter there to send it.

` + ${paneAgent + ? html`

This will paste a prompt for ${comments} into your ${paneAgent} pane. Press Enter there to send it.

` : html`

This will spawn ${agentCli || 'your configured agent'} to address ${comments}.

`}

The agent will:

    @@ -24,7 +24,7 @@ export function AgentConfirmDialog({ agentCli, herdrAgent, commentCount, onConfi
- +
diff --git a/diffui/tui/client.py b/diffui/tui/client.py index 737530d..a74d12a 100644 --- a/diffui/tui/client.py +++ b/diffui/tui/client.py @@ -37,7 +37,7 @@ def _json(response: httpx.Response): class LocalServer: - """The FastAPI app on a free port, served from a background thread so the lifespan (watcher, herdr status) runs.""" + """The FastAPI app on a free port, served from a background thread so the lifespan (watcher, host status) runs.""" def __init__(self, repos: list[Path], active_index: int = 0) -> None: self.port = _find_free_port() diff --git a/diffui/tui/screens.py b/diffui/tui/screens.py index 9868e6b..2d2ccb7 100644 --- a/diffui/tui/screens.py +++ b/diffui/tui/screens.py @@ -30,7 +30,7 @@ [ ("u", "Uncommitted changes"), ("b", "Everything on the branch"), - ("t", "Changes since the agent's last turn started (needs herdr)"), + ("t", "Changes since the agent's last turn started (needs an agent pane)"), ("C", "Pick one commit"), ], ), @@ -121,7 +121,7 @@ def action_cancel(self) -> None: class AgentScreen(ModalScreen[bool]): - """The prompt the server built. Inside herdr, enter pastes it into the workspace's agent pane.""" + """The prompt the server built. With an agent pane to paste into, enter pastes it there.""" BINDINGS: ClassVar[list[BindingType]] = [ Binding("escape,q", "cancel", show=False), @@ -134,7 +134,7 @@ def __init__(self, data: dict) -> None: self._data = data def compose(self) -> ComposeResult: - agent = self._data.get("herdr_agent") + agent = self._data.get("pane_agent") count = self._data["comment_count"] if agent: title = f"Paste {count} open comment(s) into {agent}" @@ -144,7 +144,7 @@ def compose(self) -> ComposeResult: else: title = f"{count} open comment(s) for the agent" hint = ( - "Not inside herdr, so nothing is pasted. Copy this prompt into your agent with c. " + "No agent pane found, so nothing is pasted. Copy this prompt into your agent with c. " f"Context file: {self._data.get('context_path', '')}. Esc closes." ) with Vertical(classes="dialog"): @@ -153,7 +153,7 @@ def compose(self) -> ComposeResult: yield Static(hint, classes="hint", markup=False) def action_paste(self) -> None: - self.dismiss(bool(self._data.get("herdr_agent"))) + self.dismiss(bool(self._data.get("pane_agent"))) def action_copy(self) -> None: self.app.copy_to_clipboard(self._data["prompt"]) diff --git a/tests/test_server.py b/tests/test_server.py index 1942bfc..30e4907 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1349,6 +1349,16 @@ def test_paste_sends_one_bracketed_paste_to_the_right_agent( assert text.count("\x1b[201~") == 1 assert ["agent", "focus", target] in fake_herdr() + def test_prompt_names_the_pane_agent_only_inside_a_workspace(self, _server_app, temp_repo, fake_herdr, monkeypatch): + monkeypatch.setenv("HERDR_AGENTS", json.dumps([_agent("w1:p1", temp_repo)])) + _server_app.post("/api/comments", json={"file_path": "app.py", "line_index": 1, "comment": "check"}) + try: + assert _server_app.post("/api/agent/prompt").json()["pane_agent"] == "claude" + monkeypatch.delenv("HERDR_WORKSPACE_ID") + assert _server_app.post("/api/agent/prompt").json()["pane_agent"] is None + finally: + _delete_app_py_comments(_server_app) + def test_paste_refuses_blocked_agent(self, _server_app, temp_repo, fake_herdr, monkeypatch): r = self._paste(_server_app, monkeypatch, [_agent("w1:p1", temp_repo, "blocked")]) assert r["ok"] is False From 038fadf31b68b8245705a67f8b220bd852f67036 Mon Sep 17 00:00:00 2001 From: Dillon Sienko Date: Fri, 23 Oct 2026 09:40:08 -0700 Subject: [PATCH 2/3] Document the terminal_host boundary --- AGENTS.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ea66879..0b18b72 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 @@ -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 # The one module that knows herdr: 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) @@ -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 its status string (`repo_agent_statuses`: `idle`, `working`, `done`, `blocked`), never herdr's agent dicts. `NAME` is for copy that has to say what the user needs. +- 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` every second (`herdr agent list` underneath), only when `terminal_host.available()`: `HERDR_WORKSPACE_ID` set and `herdr` 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. @@ -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:`, 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:`, 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. From 062f446cc78c24fe9a7e911dbefb27146d138383 Mon Sep 17 00:00:00 2001 From: Dillon Sienko Date: Fri, 23 Oct 2026 10:11:34 -0700 Subject: [PATCH 3/3] Simplify the terminal host boundary --- AGENTS.md | 6 +++--- diffui/server/terminal_host.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0b18b72..b9a7829 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,7 +28,7 @@ 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 -│ ├── terminal_host.py # The one module that knows herdr: agent panes, paste, sidebar status +│ ├── 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 @@ -73,9 +73,9 @@ diffui/ - 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. -- 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 its status string (`repo_agent_statuses`: `idle`, `working`, `done`, `blocked`), never herdr's agent dicts. `NAME` is for copy that has to say what the user needs. +- 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` every second (`herdr agent list` underneath), only when `terminal_host.available()`: `HERDR_WORKSPACE_ID` set and `herdr` 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. +- 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. diff --git a/diffui/server/terminal_host.py b/diffui/server/terminal_host.py index 0ceb588..a8f2934 100644 --- a/diffui/server/terminal_host.py +++ b/diffui/server/terminal_host.py @@ -14,7 +14,7 @@ from diffui.server.state import app_state -NAME = "herdr" # for user-facing copy that has to say what is required +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~"