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
20 changes: 10 additions & 10 deletions AGENTS.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ You get syntax-highlighted diffs, inline comment threads and risk scoring. If yo

Use it before you push. Review what your agent just wrote while nothing is on a remote yet, or look at your own branch in a real diff UI without opening a draft MR. GitHub or GitLab review still happens after.

It runs as a local web server with a Preact frontend, or as a terminal UI with `diffui --tui`. There is no build step and no accounts. Your code stays on your machine with one exception: a markdown file with a mermaid or plantuml block is rendered by kroki.io, so that block's source is sent there when you preview the file. The web UI loads its frontend libraries from a CDN, so the first paint needs a network.
It runs as a local web server with a Preact frontend, or as a terminal UI with `diffui --tui`. There is no build step and no accounts. Your code stays on your machine with one exception: a markdown file with a diagram block (mermaid, plantuml, graphviz, d2 and others) is rendered by kroki.io, so that block's source is sent there when you preview the file. The web UI loads its frontend libraries from a CDN, so the first paint needs a network.

## Install

Expand Down Expand Up @@ -76,7 +76,7 @@ The browser has the agent terminal panel, Explain changes, markdown and mermaid
- `ctrl+f` searches within the diff and highlights the matches. `ctrl+g` jumps to a line. `ctrl+shift+f`, or the Search files button, filters the file list.
- `/` opens a search over file names and text. It starts on the files in the current scope, and `tab` switches to the whole repo, which skips `.gitignore`d and binary files. Results group by file with line numbers, up to 200 lines. Enter opens the file at that line, and when the diff does not show the line, the view goes to the nearest one it does. Files that are not in the diff are dimmed and marked "not in diff". They open read-only in the file view, with no comments and no Mark reviewed, and picking a file from the explorer returns to the diff. The terminal UI has the same search, described in [Search and find](#search-and-find).
- Files are scored for risk from their path and size: migration, infra, schema and deploy paths, config files, large deletions, high churn and test removal. Medium and high risk files get a colored dot in the file tree, with the level in the tooltip. `s` sorts by risk.
- A `.diffuiignore` file in the repo root hides matching files from the file list and from search. It takes one glob per line, and `#` starts a comment. `diffui --json` still lists them.
- A `.diffuiignore` file in the repo root hides matching files from the file list and from changed-scope search. Searching the whole repo does not apply it. It takes one glob per line, and `#` starts a comment. `diffui --json` still lists them.

## Comments

Expand Down
4 changes: 2 additions & 2 deletions diffui/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
)


def _find_free_port() -> int:
def find_free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("", 0))
return s.getsockname()[1]
Expand Down Expand Up @@ -122,7 +122,7 @@ def main() -> None:

from diffui.server.app import create_app

port = _find_free_port()
port = find_free_port()
app = create_app(repos, active_index=active_index)

import uvicorn
Expand Down
9 changes: 3 additions & 6 deletions diffui/diff.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

from pygments.lexers import get_lexer_for_filename
from pygments.lexers.special import TextLexer
from pygments.util import ClassNotFound

_META_PREFIXES = ("diff ", "index ")
_HUNK_RE = re.compile(r"^@@ -(\d+)")
Expand All @@ -20,7 +21,7 @@ def is_meta_line(line: str) -> bool:
def get_lexer(file_path: str):
try:
return get_lexer_for_filename(file_path, stripnl=False, stripall=False)
except Exception:
except ClassNotFound:
return TextLexer(stripnl=False, stripall=False)


Expand Down Expand Up @@ -79,11 +80,7 @@ def classify_line(line: str) -> str:


def strip_diff_prefix(line: str) -> str:
if line.startswith("+") or line.startswith("-"):
return line[1:]
if line.startswith(" "):
return line[1:]
return line
return line[1:] if line[:1] in ("+", "-", " ") else line


def _word_offsets(words: list[str]) -> list[int]:
Expand Down
8 changes: 1 addition & 7 deletions diffui/git_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ def set_active_repo(path: Path) -> None:
_active_repo_root = path
get_repo_root.cache_clear()
_resolved_repo_root.cache_clear()
_cached_current_branch.cache_clear()
clear_branch_cache()


def discover_sibling_repos(current_root: Path) -> list[Path]:
Expand Down Expand Up @@ -388,12 +388,6 @@ def get_head_sha() -> str:
return result.stdout.strip() if result.returncode == 0 else ""


def get_git_user_name() -> str:
result = _git("config", "user.name")
name = result.stdout.strip()
return name if name else "User"


def short_name(path: str) -> str:
return path.split("/")[-1]

Expand Down
3 changes: 1 addition & 2 deletions diffui/server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
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.theme_css import generate_css_vars
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 Expand Up @@ -100,8 +101,6 @@ def list_themes():

@app.get("/api/theme/css")
def get_theme_css():
from diffui.server.theme_css import generate_css_vars

return {"css": generate_css_vars(app_state.theme)}

_index_html = (_STATIC_DIR / "index.html").read_text()
Expand Down
41 changes: 14 additions & 27 deletions diffui/server/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,16 @@

from diffui.git_utils import (
_comments_path,
get_diff_numstat,
get_file_mtime,
get_git_dir,
get_repo_root,
get_working_changed_files,
load_comments,
save_reviewed,
)
from diffui.server import fff_index
from diffui.server.routes_diff import clear_diff_cache
from diffui.server.state import app_state
from diffui.server.terminal_host import report_status

Expand Down Expand Up @@ -46,12 +51,9 @@ def _broadcast(events: list[str]) -> None:
pass

for ws in list(_ws_clients):
try:
task = asyncio.create_task(ws.send_text(data))
_bg_tasks.add(task)
task.add_done_callback(_bg_tasks.discard)
except Exception:
_ws_clients.discard(ws)
task = asyncio.create_task(ws.send_text(data))
_bg_tasks.add(task)
task.add_done_callback(_bg_tasks.discard)


def _classify_changes(changes: set[tuple], git_dir: str, comments_path: str) -> list[str]:
Expand All @@ -67,8 +69,6 @@ def _classify_changes(changes: set[tuple], git_dir: str, comments_path: str) ->


def _invalidate_stale_reviews() -> None:
from diffui.git_utils import get_file_mtime, save_reviewed

stale = [p for p, t in app_state.reviewed.items() if get_file_mtime(p) != t]
if stale:
for p in stale:
Expand All @@ -83,8 +83,6 @@ def _apply_state_updates(events: list[str]) -> list[str]:
prev_branch = app_state.branch_name
prev_comments = app_state.comments
app_state.reload_repo_state()
from diffui.server.routes_diff import clear_diff_cache

clear_diff_cache()
_invalidate_stale_reviews()
if "files_changed" not in events:
Expand All @@ -97,8 +95,6 @@ def _apply_state_updates(events: list[str]) -> list[str]:
if app_state.branch_name != prev_branch:
restart_watcher()
elif "files_changed" in events:
from diffui.git_utils import get_diff_numstat, get_working_changed_files

app_state.numstat = get_diff_numstat(app_state.merge_base)
app_state.working_files = get_working_changed_files()
app_state.reload_last_turn()
Expand Down Expand Up @@ -160,15 +156,6 @@ async def _watch_loop(cancel: asyncio.Event) -> None:
# --- SSE fallback ---


async def _event_stream(queue: asyncio.Queue):
try:
while True:
msg = await queue.get()
yield msg
except asyncio.CancelledError:
pass


@router.get("/events")
async def sse_events():
queue: asyncio.Queue = asyncio.Queue(maxsize=32)
Expand All @@ -177,8 +164,8 @@ async def sse_events():
async def generate():
try:
yield 'data: {"events": ["connected"]}\n\n'
async for msg in _event_stream(queue):
yield msg
while True:
yield await queue.get()
finally:
_sse_clients.discard(queue)

Expand Down Expand Up @@ -206,10 +193,10 @@ async def websocket_endpoint(ws: WebSocket):
data = await ws.receive_text()
try:
msg = json.loads(data)
if msg.get("type") == "ping":
await ws.send_text(json.dumps({"type": "pong"}))
except (json.JSONDecodeError, KeyError):
pass
except json.JSONDecodeError:
continue
if msg.get("type") == "ping":
await ws.send_text(json.dumps({"type": "pong"}))
except WebSocketDisconnect:
pass
finally:
Expand Down
63 changes: 17 additions & 46 deletions diffui/server/highlight.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,42 +50,18 @@ def _apply_word_highlights(code_html: str, ranges: list[tuple[int, int]], code_p
if in_highlight:
result.append("</mark>")
in_highlight = False
end_tag = code_html.find(">", i)
if end_tag == -1:
result.append(code_html[i:])
break
result.append(code_html[i : end_tag + 1])
i = end_tag + 1
continue
if code_html[i] == "&":
semi = code_html.find(";", i)
if semi == -1:
result.append(code_html[i])
plain_idx += 1
i += 1
continue
entity = code_html[i : semi + 1]
end = code_html.index(">", i) + 1
result.append(code_html[i:end])
else:
# highlight_line_html escapes its input, so every `&` opens a complete entity.
end = code_html.index(";", i) + 1 if code_html[i] == "&" else i + 1
should_mark = plain_idx in marked_chars
if should_mark and not in_highlight:
result.append("<mark>")
in_highlight = True
elif not should_mark and in_highlight:
result.append("</mark>")
in_highlight = False
result.append(entity)
if should_mark != in_highlight:
result.append("<mark>" if should_mark else "</mark>")
in_highlight = should_mark
result.append(code_html[i:end])
plain_idx += 1
i = semi + 1
continue
should_mark = plain_idx in marked_chars
if should_mark and not in_highlight:
result.append("<mark>")
in_highlight = True
elif not should_mark and in_highlight:
result.append("</mark>")
in_highlight = False
result.append(code_html[i])
plain_idx += 1
i += 1
i = end
if in_highlight:
result.append("</mark>")
return "".join(result)
Expand Down Expand Up @@ -121,7 +97,7 @@ def parse_diff_to_json(diff_text: str, file_path: str, theme: Theme, with_html:
line_type = classify_line(line)
if line_type == "hunk":
continue
old_num, new_num = numbers[i] if i < len(numbers) else (None, None)
old_num, new_num = numbers[i]

entry = {"index": i, "type": line_type, "old_num": old_num, "new_num": new_num, "text": line}
if with_html:
Expand Down Expand Up @@ -149,21 +125,16 @@ def highlight_file_to_json(content: str, diff_text: str, file_path: str, theme:
lexer = get_lexer(file_path)
file_lines = content.splitlines()

changed_lines: dict[int, str] = {}
if diff_text:
numbers = parse_line_numbers(diff_text)
diff_lines = diff_text.splitlines()
for i, line in enumerate(diff_lines):
line_type = classify_line(line)
if i < len(numbers):
_, new_num = numbers[i]
if new_num and line_type == "add":
changed_lines[int(new_num)] = "add"
added = {
int(new_num)
for (_, new_num), line in zip(parse_line_numbers(diff_text), diff_text.splitlines(), strict=True)
if new_num and classify_line(line) == "add"
}

result_lines: list[dict] = []
for i, line in enumerate(file_lines):
line_num = i + 1
line_type = changed_lines.get(line_num, "context")
line_type = "add" if line_num in added else "context"
entry = {"num": line_num, "text": line, "type": line_type}
if with_html:
entry["html"] = highlight_line_html(line, lexer, theme.syntax)
Expand Down
1 change: 0 additions & 1 deletion diffui/server/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ class RepoSwitch(BaseModel):
class SettingsUpdate(BaseModel):
theme_index: int | None = None
editor: str | None = Field(default=None, pattern="^(code|cursor|vim|nvim)$")
view_mode: str | None = None
user_name: str | None = None
agent_cli: str | None = Field(default=None, pattern="^(claude|codex|opencode|cursor)$")
font_size: int | None = Field(default=None, ge=8, le=32)
Expand Down
8 changes: 2 additions & 6 deletions diffui/server/routes_agent_terminal.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,7 @@ def _append_output(data: bytes) -> None:
def _pending_is_stale() -> bool:
if _pending_start is None:
return True
return time.monotonic() - _pending_start.get("created", 0.0) > _PENDING_TTL


def _flush_buffer() -> bytes:
return b"".join(_output_buffer)
return time.monotonic() - _pending_start["created"] > _PENDING_TTL


def _is_running() -> bool:
Expand Down Expand Up @@ -188,7 +184,7 @@ async def agent_ws(ws: WebSocket):
if _agent_proc is not None and _agent_pty_fd is not None:
pty_fd = _agent_pty_fd
proc = _agent_proc
replay = _flush_buffer()
replay = b"".join(_output_buffer)
if replay:
await ws.send_json({"type": "output", "data": replay.decode("utf-8", errors="replace")})
await ws.send_json({"type": "started", "pid": proc.pid})
Expand Down
Loading
Loading