Imported from ctdio/skim (
AGENTS.md). Install upstream withnpx skills add ctdio/skim. Copyright stays with the author.
AGENTS.md
This file provides guidance to AI agents when working with code in this repository.
Project Overview
Skim is a keyboard-driven TUI for code reviews built in Zig. Vim-style navigation, sub-10ms startup, 60 FPS scrolling.
Current Status: Alpha - AI agent integration complete (ACP + MCP support).
Build System
Prerequisites
- Zig 0.16.0 or later
- Git must be available in PATH
Common Commands
# Build debug binary (default - use for development and debugging)
zig build
# Build optimized release binary (for production use)
zig build -Doptimize=ReleaseFast
# Build the browser (wasm) demo into zig-out/web/
zig build web
# Run repo-configured ziglint checks
zig build lint
# Build and run (passes args to the app)
zig build run -- [args]
# Run unit tests
zig build test
# Run the built binary directly
./zig-out/bin/skim
./zig-out/bin/skim --staged
./zig-out/bin/skim main..feature-branch
# Debug with stderr logging
./zig-out/bin/skim 2>debug.log
# Run ziglint directly against specific files
./scripts/ziglint.sh src/app.zig
Render benchmarks
Always benchmark with -Doptimize=ReleaseFast; debug numbers are meaningless.
# Full per-keystroke scroll cost: frame.render + vaxis diff/encode + bytes emitted
zig build bench-scroll -Doptimize=ReleaseFast
# Isolated content-renderer cost at a fixed scroll offset
zig build bench-render-content -Doptimize=ReleaseFast
# Whole paging session: live highlight worker, real frame pacer, optional
# rate-limited terminal. Use this for anything about scrolling *smoothness*.
zig build bench-highlight-scroll -Doptimize=ReleaseFast
bench_scroll is also installed to ./zig-out/bin/bench_scroll so knobs can be
swept without rebuilding. Shared setup (synthetic diff generation, env parsing,
synchronous highlighting, stats) lives in src/testing/bench_support.zig.
| Env var | Default | Meaning |
|---|---|---|
SKIM_BENCH_DIFF_PATH |
— | Bench a real git diff file instead of a synthetic one |
SKIM_BENCH_FILES / _HUNKS / _LINES |
10 / 6 / 60 | Synthetic diff shape |
SKIM_BENCH_WIDTH / _HEIGHT |
190 / 60 | Terminal size |
SKIM_BENCH_VIEW |
unified |
unified, side_by_side, or both |
SKIM_BENCH_MOTION |
line |
line (j), half_page, page, file — scroll only |
SKIM_BENCH_HIGHLIGHT |
1 |
Pre-highlight every hunk (steady state) — scroll only |
SKIM_BENCH_SHIFT |
0 |
Rows per step, overriding _MOTION: models coalesced keystrokes — scroll only |
SKIM_BENCH_UP |
0 |
Scroll toward the top of the diff instead of the bottom |
SKIM_BENCH_ITERS / _WARMUP |
200 / 20 | Sample counts |
SKIM_BENCH_PAGES / _PAGE_MS |
120 / 40 | Keystroke count and repeat interval — highlight-scroll only |
SKIM_BENCH_SBS |
0 |
Side-by-side view — highlight-scroll only |
SKIM_BENCH_DRAIN_KBPS |
0 |
Terminal consumption rate; 0 writes to memory — highlight-scroll only |
bench_highlight_scroll exists because the other two cannot see smoothness:
they render against a writer that never blocks, with highlighting either fully
warm or fully absent. This one pages on a wall clock with the worker thread
racing alongside, and SKIM_BENCH_DRAIN_KBPS writes frames into a pipe drained
at a fixed rate, so a write blocks the way it does against a slow terminal or an
ssh link — which is what puts FramePacer under load. It reports pop-in (share
of visible rows still unstyled when a frame is drawn), the spread of frame gaps,
and keystroke-to-frame lag. A p99 frame gap far above p50 is the stutter.
bytes/frame is the number to watch. It is what the terminal emulator has
to parse, and on a slow terminal or over SSH it dominates everything measured
inside the process.
IMPORTANT for debugging: Always use zig build (debug mode) when debugging. Debug builds have:
- Better stack traces
- Assertions enabled
- No optimizations that interfere with debugging
- std.log.debug() messages enabled
Build Configuration
- Output:
./zig-out/bin/skim - Dependencies (in
build.zig.zon):- vaxis (TUI rendering)
- tree-sitter + language grammars (syntax highlighting - JS, TS, Zig, Python, Rust, Go, C, C++, JSON, YAML, TOML, Markdown, HTML, CSS, Bash)
- Release builds strip symbols (~209KB)
Architecture
For detailed architecture documentation, see docs/architecture.md.
Quick Overview:
- CLI Layer (
main.zig): Arg parsing, init, subcommand routing - Application Layer (
app.zig): Modal state machine, event handling - I/O Handle (
io.zig): Process-widestd.Io+ environment, plus 0.16 shims - Line Tracking (
line_map.zig): Position registry - Git Integration (
git/): Command execution, diff parsing - Rendering (
rendering/): Unified/side-by-side views - Syntax Highlighting (
highlighting/): Async tree-sitter with parser caching - ACP System (
acp/): Agent Client Protocol for built-in agent panel - Agent UI (
agent/): Chat panel, markdown rendering, message history - MCP Server (
mcp/): Model Context Protocol for external agent integration - Sockets (
net.zig): Non-blocking loopback TCP for the MCP server/clients - CLI Commands (
cli/): Session management, comment operations - Logging (
logging.zig): File logging to~/.skim/*.log
The skim_io module (read before touching I/O)
Zig 0.16 put file, process, and synchronization operations behind an Io value
that every call takes as a parameter. Rather than thread it through several
hundred signatures, main adopts std.process.Init and stashes the handle in
src/io.zig; everything else reaches it through skim_io.get().
Two rules follow:
- Import it by name —
@import("skim_io"), never a relative path. It is wired into every module inbuild.zig, which is what lets a test step rooted atsrc/acp/orsrc/testing/import it at all. A relative import fails to compile in exactly those steps. - Every executable must call
skim_io.init(process_init)first. Amainthat skips it leaves the handleundefinedand the first file, timer, or lock operation crashes. That includes the benchmarks insrc/bench_*.zig.
io.zig also holds the shims for APIs 0.16 removed outright: Timer,
sleep/timestamp helpers, getEnv, readFile/readAllAlloc, AppendFile
(0.16 dropped File.seek), absolutePathAlloc (replaces realpathAlloc), and
setNonBlocking (replaces posix.fcntl). Prefer extending it over
reimplementing a workaround locally.
Key Design Principles:
- Modal interface (vim-style)
- Shell-out to git (respects user config)
- LineMap registry for positioning
- Virtual scrolling (render visible lines only)
- Minimal dependencies (vaxis + tree-sitter)
- Direct subprocess spawning for AI agents (no daemon)
Logging System
Logs are written to files in ~/.skim/ instead of stderr (since stdout/stderr are used for TUI rendering):
~/.skim/
├── tui.log # TUI client logs
└── mcp.log # MCP adapter logs
Using logs for debugging:
# Watch TUI logs in real-time
tail -f ~/.skim/tui.log
The logging module (src/logging.zig) overrides std.log to write to these files with timestamps and log levels.
AI Integration Overview
Skim integrates with AI agents in two ways:
1. Agent Panel (ACP - Built-in)
The built-in agent panel (Ctrl-e) uses the Agent Client Protocol for direct communication with AI agents. Agents are spawned as subprocesses with stdio communication.
┌─────────────────────────────────────────────────────────────────┐
│ Skim TUI │
│ - Spawns agent as child process │
│ - Communicates via JSON-RPC over stdio │
│ - Renders agent responses in chat panel │
└───────────────────────────┬─────────────────────────────────────┘
│ stdio (JSON-RPC)
┌───────────────────────────▼─────────────────────────────────────┐
│ AI Agent Process │
│ (Claude Code, Codex, etc.) │
└─────────────────────────────────────────────────────────────────┘
Key ACP files:
acp/manager.zig: Session lifecycle and agent discoveryacp/client.zig: Agent communication and message handlingacp/codec.zig: JSON-RPC encoding/decodingacp/transport.zig: Stdio transport layeracp/process.zig: Agent process spawningacp/sessions/: Vendor-specific adapters (Claude, Codex)
Key Agent UI files:
agent/state.zig: Agent panel state machineagent/render.zig: Chat panel renderingagent/chat_line_map.zig: Message line registryagent/markdown/: Markdown parsing and rendering
2. MCP Server (External Agents)
For AI agents that support MCP (Model Context Protocol), skim provides a stdio-based MCP server (skim mcp --stdio).
Key MCP files:
mcp/adapter.zig: stdio MCP server for external agentsmcp/tools.zig: MCP tool implementations (list_clients, add_comment, etc.)mcp/framework.zig: Mini MCP JSON-RPC framework
Development Workflow
Testing
- Tests colocated with implementation
- Run:
zig build test - Coverage includes: arg parsing, diff execution, parser, line_map, comments, editor
Ziglint
- Use
./scripts/ziglint.sh <paths...>while iterating on touched Zig files for fast, file-scoped feedback. - Run
zig build lintbefore finishing a Zig task to validate against the repo defaults in.ziglint.zon. - Treat ziglint as incremental: fix findings in files you touched for the current task, but do not do broad cleanup of pre-existing findings unless explicitly requested.
- The wrapper script prefers an installed
ziglintbinary and otherwise usesmisewith the pinnedgithub:rockorager/ziglint@v0.5.2tool version.
Snapshot Testing (IMPORTANT for UI changes)
When modifying UI rendering, ALWAYS add or update snapshot tests.
The project uses snapshot testing to verify UI output. Infrastructure is in src/testing/:
snapshot.zig: Core snapshot comparison logicharness.zig: Mock screen/window for capturing rendered outputsnapshot_scenarios.zig: Test scenarios organized by domainsnapshots/: 55+ snapshot files (.snapextension)
Three testing domains:
- Diff rendering - File headers, hunk headers, diff lines (
diff_test_helpers.zig) - Agent chat UI - Messages, tool calls, plan entries (
agent_test_helpers.zig) - Markdown rendering - Headers, formatting, code blocks (
markdown_test_helpers.zig)
Running snapshot tests:
# Run tests (compares against existing snapshots)
zig build test
# Update snapshots after intentional changes
SKIM_UPDATE_SNAPSHOTS=1 zig build test
Writing a snapshot test:
test "snapshot: my_feature" {
const allocator = std.testing.allocator;
var ctx = try harness.createTestContext(allocator, 80, 24);
defer ctx.deinit();
// Render to test window
const win = ctx.window();
renderMyFeature(win, params, ctx.frameAllocator());
// Compare against snapshot
const text = try ctx.captureToText();
defer allocator.free(text);
try snapshot.expectSnapshot(allocator, "my_feature", text);
}
When to add snapshot tests:
- Adding new UI components or rendering functions
- Modifying existing renderers (diff lines, headers, status bar, etc.)
- Changing text formatting, spacing, or visual structure
- Adding new line types to LineMap
Debugging TUI Apps
- Stdout/stderr not available (TUI rendering) - logs go to
~/.skim/*.log - Use
std.log.debug/info/warn/err()- routed to component-specific log files - Watch logs in real-time:
tail -f ~/.skim/tui.log - Terminal in raw mode - crashes may corrupt it (run
reset) - Debug builds:
zig build(better stack traces, assertions enabled)
Code Style
- Run
zig fmt - Run
zig build lintafter Zig changes. The repo config is intentionally incremental, so fix findings in touched code and avoid broad cleanup unless requested. - Descriptive names, focused functions
- Explicit error handling
Key Implementation Patterns
See docs/architecture.md.
LineMap System
- Registry of renderable lines (file headers, hunk headers, code lines, comments, spacers)
- Source of truth for positioning
- Global line numbers (0-based, sequential)
- Rebuilt on: init, refresh, comment add/delete
Modal State Machine
- Modes: normal, comment, search, visual, command_palette, help, branch_selection, commit_selection, graphite_stack, agent, model_selection, agent_selection, session_picker
- Mode handlers in
src/modes/ - When adding modes: update
Modeenum, create handler file, update status bar
App Struct Boundaries (avoid the god object)
App (src/app.zig) is an orchestrator, not a feature dumping ground. It owns
process lifecycle (init/deinit/run), the event loop, mode dispatch, and the
shared State. Feature logic lives in feature modules, not as App methods.
Before adding a method to App, stop. Only these belong on App:
- lifecycle (
init/deinit/run), the event loop, andrenderorchestration - mode dispatch (
handleKey→src/modes/<mode>.zig) - thin forwarders that hand a sub-state slice to a feature module
Everything else is a feature controller. The pattern that already governs key
dispatch (modes/<mode>.zig calling handleKey(app, key)) governs feature
logic too:
- Feature state = a top-level
pubstruct (e.g.PrReviewState), stored as one field onState(e.g.state.pr) — not a scatter of looseStatefields. - Feature logic = free functions in the feature module taking
*ThatStateplus the narrow deps it needs (allocator, a status-message callback), not*App. If a function only reaches throughselfto touchself.state.<feature>, it does not belong onApp. modes/<mode>.zigand the command palette call the controller directly (pr_controller.move(&app.state.pr, 1)), not a forwardingAppmethod.
This keeps app.zig readable top-to-bottom and lets features be unit-tested without
constructing an App. Reference layout: src/pr/ (state + controller.zig +
render.zig) is the template. If a change would push app.zig meaningfully larger,
that is the signal to extract a controller, not to add another method.
Adding Features
- New feature with its own state/logic: Add a
pubstate struct + controller module (see App Struct Boundaries); wire oneStatefield and dispatch fromsrc/modes/. Do not add the logic asAppmethods. - New keybinding: Update mode handler in
src/modes/, update status bar help, update README - New language: Add grammar to
build.zig.zon, updatehighlighting/core.zig, add.scmquery file inqueries/ - New line type: Update
LineTypeenum, updateLineMap.build(), update renderers, add snapshot tests - New MCP tool: Add to
src/mcp/tools.zig, update tool docs - UI rendering changes: Update renderers, add/update snapshot tests in
src/testing/
Git Integration
Three diff modes:
- Working directory:
skim - Staged:
skim --staged - Ref comparison:
skim ref1..ref2
Runs git in CWD, respects user config.