A fast, native graph viewer for a folder of markdown notes β with your AI agents living inside it. Point it at a vault (an Obsidian vault works as-is) and get an Obsidian-style force-directed graph you can drive entirely from the keyboard. Terminal agents (claude, codex, pi, β¦) running in the vault appear as live, typeable terminal cards tethered to the folder they work in: watch them stream, jump in to type, and watch the graph ripple as they write notes. One native Rust binary β no Electron, no webview, no runtime.
cargo run --release -- ~/notes # open the graph window
cargo run --release -- stats ~/notes # headless statistics
A wiki of ~100 notes being built by three agents at once. Each card is a live tmux pane, tethered to the folder its agent works in and typed into in place; the graph behind them redraws as they write. The line at the top says which pane has the keyboard, and how to get it back.
There is a flake, so on NixOS β or anywhere with nix flakes β nothing needs to be built by hand:
nix run github:marbinner/text-graph -- ~/notes
Declaratively, add the flake as an input and its overlay to your NixOS config:
inputs.text-graph.url = "github:marbinner/text-graph";
# ...
nixpkgs.overlays = [ inputs.text-graph.overlays.default ];
environment.systemPackages = [ pkgs.text-graph ];nix develop gives you a shell with the toolchain, tmux and the GUI libraries,
where scripts/check.sh runs as-is.
Building it yourself instead, the binary wants a GL-capable session (Wayland or
X11) and tmux on PATH for the terminal cards β without tmux everything else
still works and the cards are simply absent. pkg-config and the Xorg client
headers must be present at build time even for a Wayland-only session, because
winit's x11-dl probes for them.
The vault is the database: nodes are your .md files and directories, edges
come from the directory structure and [[wikilinks]]. text-graph never
edits your notes β it writes only the empty file or folder you explicitly
create from the right-click menu, plus a small hidden .text-graph/ state
dir (camera, card arrangements, pins). Your preferences live with you, not
with the vault, in ~/.config/text-graph/config. Editing happens in your
own editor, and the graph live-reloads when you save.
| Element | Source |
|---|---|
| File node | every .md file (hidden dirs, .obsidian/, .trash/, node_modules/, target/, __pycache__/ skipped; git-ignore semantics deliberately off) |
| Image node | every raster image (png/jpg/jpeg/gif/webp/bmp) β rendered as its actual picture once you zoom in |
| Asset node | every other visible file β code, config, data, binaries. Text-classified assets get previews; all are linkable by full name ([[data.csv]]) |
| Dir node | every directory with files somewhere beneath it; F toggles them β hidden, they leave the simulation too, and the graph re-settles on wikilinks alone |
| Ghost node | a [[target]] that resolves to nothing β a note you've referenced but not written; drawn hollow |
| Web node | every external URL cited anywhere β ONE cyan globe per normalized URL however many notes cite it, so shared sources become visible bridges between their citers; w toggles them, Enter/double-click opens the browser |
| Contains edge | filesystem parent β child; a true tree by construction, and the layout's skeleton β drawn as a tapered wedge, thick at the parent thinning to the child |
| WikiLink edge | [[...]] in note bodies; faint arrowheaded curves, bright on the hovered node |
| External edge | note β cited URL; fainter cyan curves β context, not structure |
Big enough to read, every node paints as its file-type icon (Nerd Font
glyphs bundled in assets/icons.ttf): the python logo on .py, the css
shield, markdown pages, blue folders, per-language colors β zoomed way
out they collapse to plain color-coded discs, so huge graphs render
cheap. Ghosts stay hollow outlines. The hierarchy is legible at a
glance: folders shrink and darken with tree depth (the root is the
biggest, brightest thing on the canvas) and their names render in blue
at a size that scales with the node β a readable directory outline over
the graph, wedge edges pointing the way down.
The closer you look, the more you see. Labels ease in early (hubs
first) and moving the mouse acts as a label flashlight: names near the
cursor fade in even when the zoom would hide them. Zoom into an image and
its disc becomes the picture (decoded on a background thread, so big
photos never hitch a frame); zoom into a note β or any text-classified
asset β and it opens into a card showing its first lines (prose
proportional, code monospace), text growing with the zoom. And hovering
any node for a moment pops up the full thing, headed by its metadata
β edited/created age, size, lines Β· words, and a links line (N out Β· N in Β· N external, external URLs listed): notes render as markdown, text
assets raw, images large, folders show their listing plus direct and
recursive counts and the total wiki + external links leaving their files,
ghosts list their referencers β all without selecting anything.
Selecting a node opens the preview pane: file-type glyph, name, the
path as clickable ancestors, size and age, then the file itself β
rendered markdown for notes, source with line numbers for code (r
switches either way), the child listing for folders, the picture for
images β with syntax colouring in both, from the same themes. p
climbs to the parent; everything else about getting somewhere is the
finder. A connections strip along the bottom lists
everything the node touches, color-coded and clickable: blue βΈ folder/
and gray βΈ file children, amber β outgoing links, purple β incoming
links.
Casefolded throughout; .md suffix optional; alias ([[x|shown]]) and
heading/block ([[x#h]], [[x#^id]]) suffixes are stripped before resolving.
- A target containing
/matches by path-component suffix β[[daily/2026-08-14]]findsnotes/daily/2026-08-14.md. - A bare name matches file stems firstβ¦
- β¦then frontmatter
aliases:(or singularalias:) β so a note named2025-0803-1746-transcoders.mdwithaliases: [SAE]is reachable as[[SAE]], and the alias becomes its label in the graph. - Several candidates β the first in sorted path order wins and the link
is flagged ambiguous (visible in
stats). - A plain
[[pic.png]]or[[data.csv]](with extension) resolves to the image or asset node if that file exists in the vault. - Not edges at all:
![[embeds]], anything inside code fences or inline code, self-links, and unresolved links with asset extensions (png/pdf/β¦) β a real note namedpic.png.mdorDrawing.excalidraw.mdstays linkable; only references to assets that don't exist are skipped. - Still unresolved β a ghost node.
Labels prefer frontmatter title:, then the first alias, then the file stem.
The one rule: the finder chooses, the keyboard drives. hjkl pan and
s/d zoom whatever is selected; f and b are how you get to a file;
focus a terminal and every key goes to the agent until Ctrl+Q.
| Input | Action |
|---|---|
drag empty space / h j k l |
pan (hold to glide) |
mouse wheel / s d |
zoom out / in (the wheel zooms toward the cursor) |
gg |
back out to the whole graph |
G |
zoom to the selection's neighbourhood β what it's connected to, framed |
0 / Home |
reset the zoom, stay where you are |
z |
center on the selection |
| drag a node | move it β pins to the cursor, the simulation responds live |
| hover | highlight the node's neighborhood, dim everything else; nearby labels fade in around the cursor |
| hover + linger | metadata + full preview popup for any node β note as markdown, text asset raw, image large, folder stats + listing, ghost referencers; on a compact terminal card, its full live screen |
w |
toggle web (cited-URL) nodes β hidden means hidden from view only, the layout never reflows |
F |
toggle folder nodes β hidden, they leave the physics as well: the Contains spine goes with them and the notes re-settle on their links alone. Persisted per vault |
, or β (bottom-right) |
settings β see below |
? |
every keybinding, in the settings window |
f, / or Ctrl+F |
find anything β see below |
b |
browse the folder you're in, in the same list |
Navigation is built around one list. It floats over the middle of the window with its results stacked underneath, filling everything below the prompt β telescope style, so your eye stays near the center of the screen (β finder position moves the prompt up for more rows at once, or down for a more central one) β while the pane on the right previews whatever is highlighted. It has two sources, not two surfaces:
Find (f, / or Ctrl+F) searches everything in the vault: note
names, aliases, paths, the text inside every file, and the live agent
terminals. Names, aliases and paths match fuzzily (apbn finds
agent-protocol-benchmark.md); file content matches literally β every
word you type has to appear on the same line, case-insensitively unless
you type a capital. Content is never indexed: a worker thread streams the
vault per query and stops the moment you type another character, so
nothing goes stale under agents that rewrite notes while you search. With
nothing typed, it lists what changed last β the 30 most recently
edited files, newest first, which is usually the question you had.
Browse (b) lists one folder's entries instead, in tree order, and
typing filters that folder β the same keys, the same preview, scoped.
Enter walks into a directory, Backspace on an empty filter walks back
out, Shift+Enter takes the folder itself. Tab swaps between the two
sources and keeps what you typed: a filter that found nothing here is
usually what you wanted to search the whole vault for.
The preview is the same one for both, and the same one a selected node
gets β there is no second place to look at a file. Notes render as a
reading view: Obsidian markdown (wikilinks, callouts, tags, embeds)
drawn in a bundled text face (Inter) with a real heading scale and prose
wrapped at a book-ish line length, centered when the pane is wider. A
content hit shows the file's raw lines with every match highlighted
and the hit scrolled into view; r switches any note between rendered
markdown and source. A search rides out vault reloads: agents saving notes underneath
you re-scan in the background without emptying the list, moving your
cursor, or blinking the preview. On the canvas, matching nodes stay lit
and the highlighted result glides into view as you arrow down β into
the band above the prompt, so it never hides behind it β and it opens
up while you look at it: a terminal card expands, a note becomes its
preview card, an image its picture, however far out you are standing.
The pane opens at a quarter of the window and then keeps whatever width you drag it to (per vault, across restarts).
| Input | Action |
|---|---|
| type | filter β names first, then paths, then terminals, then content hits (with the matching line and its number) |
β β / Ctrl+P Ctrl+N |
move through the list |
PageUp PageDown / Ctrl+U Ctrl+D |
half-page jumps |
Enter / click |
take it β select it, frame it, and drop back into the graph on it (a terminal card lands focused and centered, at your current zoom); while browsing, Enter on a folder goes into it |
Shift+Enter |
take a folder itself instead of entering it |
Backspace |
browsing, with an empty filter: up one folder |
Tab |
swap find β browse, keeping the query |
Ctrl+Enter |
open the file in $VISUAL/$EDITOR at the matched line |
Esc |
close the list, keeping the selection |
Note previews render Obsidian-flavored: callouts (> [!warning], any
case, title and fold marker included) get their own colour and icon,
==highlights== read as emphasis, %%comments%% stay hidden, #tags
render as chips, and trailing ^block-ids don't clutter the text.
Inline $\delta = 2$ and display $$β¦$$ math are typeset, not
spelled out: \frac stacks around a rule, \sqrt draws its bar over
what it covers, \left(β¦\right) grows to its content, a display \sum
takes its limits above and below, and variables are set in real math
italics β as are \mathcal, \mathbf, \mathfrak and \mathbb, which
Unicode has as real letterforms. Spacing is TeX's table rather than the
author's spaces, so $a+b=c$ reads as an equation however it was typed. Greek, operators, \begin{aligned} rows, matrices and cases
come along; display math gets a centered line of its own. There is no
TeX engine in the stack, so anything unrecognized stays verbatim rather
than vanishing.
[[wikilinks]] are real links that jump to their node (ghosts included),
![[image embeds]] and relative image paths render inline, relative
markdown links to vault files jump too, footnote-style citations
([^raw/2026-β¦.md] β a wiki convention: sources cited by path) become
links showing the cited note's title, and external links open your
browser. Every camera jump β tree walks, search, link clicks β glides
instead of snapping, and never changes your zoom.
| Input | Action |
|---|---|
p |
up to the parent folder |
b |
browse this folder in the finder β the list, scoped |
r |
read the preview as source (line numbers) or as markdown |
] / [ |
walk a highlight through the connections strip (children βΈ, outgoing β, incoming β); Enter follows it |
Enter / double-click |
open the file in $VISUAL/$EDITOR (terminal editors get a new terminal window; set $TERMINAL to choose which); dirs open in the file manager |
e |
edit in the graph: the file β or folder, as the editor's picker β opens in your terminal editor inside a live tg_edit card, tethered to the node itself (also on right-click); the card dies when the editor exits |
t |
new terminal card at this node's folder β focused, ready to type, and placed where you are looking |
a |
launch the default agent (, settings) at this node's folder; its card appears focused and in view |
Esc |
dismiss whatever is transient first β the finder, then the settings window, then the link or card cursor β then deselect, back to camera mode |
| Input | Action |
|---|---|
Tab / Shift+Tab |
step through every card in a stable order β each expands where it sits, at your zoom; Enter goes in |
| hover + linger on a compact card | peek: the full live screen pops up at readable size β inspect an agent without zooming, focusing, or pinning |
| click a card | select + focus: it expands to full readable size at any zoom, turns cyan β¨, and the keyboard types into the agent |
| Ctrl+click a card | π pin it open β expanded at any zoom, several at once, without taking the keyboard; Ctrl+click again unpins. Pins survive restarts |
Ctrl+Q |
let go of everything β card focus, cursor, selection, open overlays β so the next key reaches the graph |
| click away | release focus back to the graph in one gesture β a click on a node also selects it, a click on empty space just releases (your selection stays) |
| double-click a card | fly the camera into it (zoom + center) |
| drag a card | arrange it (it stays put, following its anchor node) |
drag the corner grip (tg_ cards, full view) |
natively resize the terminal β the tmux session itself changes size and the card follows |
| right-click a card | Attach in terminal⦠(a real terminal window on that session) / Kill terminal (confirm submenu), plus the anchor folder's creation actions |
One centered window, sectioned down the left, everything applying the
moment you change it β the canvas stays live behind it so a slider can be
judged against the graph it's changing. Type in the filter box to find a
setting by name, by section, or by a word from its explanation ("dwell"
finds the hover delay). A row that's off its default grows a βΊ.
| Section | What's in it |
|---|---|
| appearance | theme, label density, node size, how far unrelated nodes fade, canvas thumbnails and text previews |
| motion | layout spread, freeze the layout, camera glide, zoom speed |
| previews | hover popups on/off, the dwell before one opens, the picker's follow delay |
| search | whether file contents are scanned at all, and the per-file size ceiling |
| tools | editor, terminal and file-manager commands |
| agents | the default agent, and extra commands to allow |
| keys | every keybinding (? opens straight here) |
The tools section matters more than it looks: a viewer started from a
desktop entry or an IDE inherits an environment you never set, so
$VISUAL/$EDITOR/$TERMINAL may simply not be there. Set them here and
they win; leave them blank and the environment is used exactly as before.
Preferences are per user, in ~/.config/text-graph/config (or
$XDG_CONFIG_HOME) β theme and editor follow you between vaults, while
camera, card arrangement and pins stay with the vault. It's a plain
key<TAB>value file, safe to hand-edit and safe to symlink into a
dotfiles repo (saves resolve the link instead of replacing it); values out
of range are clamped on load, and keys a newer version wrote are carried
through untouched. Anything an older build stored per vault (theme,
default agent) migrates across the first time you open that vault.
Context menu for the node under the cursor: New note⦠/ New folder⦠/ New terminal / Launch agent in that folder (a file targets its folder, empty space the vault root, a card its anchor); on a ghost node: write it into a real note. Details in Creating from the graph below.
Edit any file in the vault and the graph updates ~300ms after you save β new links, files, and ghosts appear in place, and existing nodes keep their positions (the layout ripples instead of re-settling). Rebuilds run on a worker thread, so saves never hitch the UI.
When anything is off β parse warnings, unreadable files, ambiguous links, a dead file watcher, a failed reload, a tmux session that won't mirror β a β badge appears in the corner; click it for the health list (entries jump to the affected note).
Vault contents and saved state are treated as untrusted input:
- Markdown extraction reads at most 8 MiB from a note, and rendered note previews read at most 1 MiB. Larger notes remain graph nodes, with a visible truncation notice in the preview.
- Images embedded in Markdown previews accept only canonical regular files
inside the canonical vault, capped at 64 MiB. Authored
file://images, absolute or escaping paths, symlinks out of the vault, devices, and oversized images are neutralized instead of reaching the generic file loader. - Filesystem operations retain native paths rather than reconstructing them from display text. On Unix, distinct non-UTF-8 filenames remain distinct and can still be searched, previewed, edited, and passed to tmux.
- View-state input is size-bounded and parsed in linear time. Saves use a private create-only temporary file followed by rename; Unix saves also use no-follow, directory-relative operations. An unreadable user config is reported rather than treated as missing and overwritten by migration.
- Terminal events use a bounded queue and a fixed per-frame processing budget. Discovery, attach retries, and launch/kill work are also bounded or moved off the UI thread, so a noisy or unhealthy tmux server cannot monopolize a frame.
Creation helpers also refuse overwrites, symlinked destination directories,
and subtrees such as target/ or node_modules/ that the scanner deliberately
prunes.
What it runs, and what it doesn't: text-graph spawns the processes you ask it
to β your $EDITOR, your terminal emulator, the agent you pick from the
right-click menu β and talks to tmux over its control protocol. It makes no
network requests of its own: nothing phones home, and a URL cited in a note
becomes a node without ever being fetched (opening one hands it to your
browser). Writes are limited to the hidden .text-graph/ state directory, the
file or folder you explicitly create, your own ~/.config/text-graph/config,
and the skill file when you run protocol --install. Your notes are never
edited.
Right-click is the creation surface; everything lands relative to the node you clicked:
- New noteβ¦ β type a name (
.mdis implied);sub/path/namecreates the intermediate folders too. The note is created empty, then selected and framed as soon as the reload picks it up. Writing content stays in your editor. - New folderβ¦ β created on disk immediately; it shows up in the graph once it holds a note (empty dirs are deliberately pruned).
- Write a ghost β right-click a hollow node: the referenced-but-missing note is created at the linked path, and every link that pointed at the ghost snaps to the real file.
- Launch agent β one click on Launch starts your default
agent (pi out of the box; change it in β settings), or pick claude /
codex / β¦ from the submenu (the same list that drives discovery). It
starts in a detached
tg_*tmux session cwd'd at that folder; its live card fades in within a couple of seconds, on screen where you are looking β it takes the keyboard the moment it appears, so it never opens off the edge of the view. From then on it's an ordinary card: drag it where you want it and it stays, across restarts. The session is plain tmux and outlives the viewer βtmux attach -t tg_claudeworks from any terminal. Launches resolve the agent against the tmux server's PATH (not just the viewer's β IDE-launched viewers carry stripped ones), and if the command still dies instantly, the status line says so instead of pretending it worked. - New terminal β the same thing with a plain shell (
tg_term): a terminal card at that folder you can type into right in the graph. - Edit here β on a text file (
e, or right-click): your terminal editor opens on it in atg_editsession whose card tethers to the file's own node (the binding rides the session's@tg_anchortmux option, so it survives viewer restarts); the card dies with the editor, and the pane is told it's dark (COLORFGBG) so editors pick their dark theme.
And the reverse, on a card: Attach in terminalβ¦ opens a real terminal window on that session, landed on that pane (mirror and external client coexist β it's all tmux), and Kill terminal ends the pane (and, if it was the last one, the session β the card follows).
Right-click a folder node β Launch agent, or run any terminal agent
(claude, codex, pi, aider, goose, opencode, gemini β extend with
TEXT_GRAPH_AGENTS=name,name) inside tmux with its cwd in the vault:
tmux new-session -s work -c ~/notes claude
Within ~1.5s a live terminal card appears in the graph, tethered to the node
of the folder the agent runs in. Zoomed out it's a summary β a status dot
(green streaming Β· gray idle), name, folder, idle age, and the pane's last
three contentful lines (newest brightest: β³ Deliberatingβ¦, the last
shell output), so a whole fleet reads at a glance β and lingering on a
compact card peeks its full screen without touching anything. Zoom in and
it becomes the full styled screen β colors, cursor, everything, mirrored
in real time. The card under the terminal cursor (or
focused for typing) always renders full-size, whatever the zoom: stand
back and click through your agents to inspect them β or Ctrl+click
several to pin them open (π in the title) and watch a whole fleet at
once. Border colors state the mode β cyan + β¨ = your keyboard is in it,
orange = selected, green = streaming.
Click the card and type β or / finds an agent by name, session, or
folder (Enter lands focused). Cards you launch yourself (agent,
terminal, editor) focus automatically the moment they appear β launch and
just start typing.
While focused, every key goes to the agent β Enter, Esc, arrows,
Shift+Tab, Ctrl chords including Ctrl+C to interrupt β and graph keybinds
suspend; Ctrl+Q or clicking empty space gives you the graph back.
Double-click a card to fly the view into it: the graph zooms to a level
where the terminal is full-size and readable, centered on that card β pan
or zoom back out whenever you like. A card stays up for the pane's whole
lifetime, including while the agent runs long tool calls. Graph-launched
(@tg_owner=text-graph) cards have a grip in the corner: dragging it
resizes the actual
tmux session (resize-window), the TUI reflows natively, and the card
follows β the same thing that happens if you resize it from an attached
terminal. Foreign sessions deliberately have no grip: resizing them would
reflow the real terminal you're viewing them in. Drag cards to
arrange your workspace β the arrangement and your camera survive restarts
(saved to .text-graph/view in the vault), and arrangements are remembered
by session name: relaunch tg_claude tomorrow and its card lands back
where you left it. Watch the card glow while an agent streams, and the
graph ripple as it writes notes.
How it works, and why it's safe:
- The viewer attaches to tmux as a control-mode client (the iTerm2
approach). tmux stays the real terminal: sessions persist when the viewer
closes,
tmux attachfrom any terminal keeps working, and tmux answers all the TUI's terminal queries β the viewer only renders display streams. - Sessions carrying
@tg_owner=text-graph(set on graph launches) always show while their cwd is in the vault; other tmux panes additionally need their foreground command to match the agent list. Once recognized, a pane's identity is sticky for its lifetime (pinned to the pane's root process), so tool calls that putbashin the foreground for minutes don't drop the card. - The viewer never sends size hints to sessions it didn't create, so it can never reflow a session you're viewing in a real terminal.
- No tmux installed β the feature is simply absent; everything else works.
v1 input limits: Ctrl+Q is reserved by the viewer (it releases focus, so
it never reaches the pane), Shift+Enter sends Enter, no mouse-into-terminal,
no in-graph scrollback β attach externally (right-click the card, or
tmux attach -t work) when you need those. Alt chords (Alt+b/f word
motion, Alt+digit args) work, and multiline paste is bracketed-paste
aware β pasting into claude doesn't submit on every newline. (tmux itself
applies the markers from the pane's live mode, so this holds even for
sessions that were already running when the viewer attached.)
Several agents in one vault need to reach each other, and the vault already provides everything that takes: they are all panes on the same tmux server. So there is no bus, no inbox, no daemon β four subcommands an agent runs from inside its own pane:
text-graph roster who else is live, and how long they've been quiet
text-graph send <agent> <message> type a message into another agent's terminal
text-graph peek <agent> [-n N] read the last N lines of their screen
text-graph protocol the conventions, for a newcomer
How does an agent know these exist? A message teaches whoever receives it β it arrives prefixed with who sent it and the command to answer β but the first mover has to be told, so text-graph ships as a skill:
text-graph protocol --install into the vault
text-graph protocol --install --user into ~ (every vault you open)
text-graph protocol --install --to <path> into a harness we haven't met
A harness keeps only the skill's one-line description in context and loads the body when it turns out to matter, so the standing cost is a sentence and the depth β what the vault is as a graph, how to reach the others, the conventions β is there when an agent actually needs it.
Each install writes two copies, because harnesses disagree about where skills
live: .claude/skills/text-graph/SKILL.md (Claude Code) and
.agents/skills/text-graph/SKILL.md (the Agent Skills
standard, which pi and others
discover). Both come from the same compiled-in source and are rewritten
wholesale, so they can't drift; --to adds a third location for anything
else. A vault install also leaves four lines in AGENTS.md, which reaches
harnesses that do no skills at all β pi reads that too.
--user installs once for every vault you open; the in-vault install is for a
vault you hand to someone else, and note that some harnesses only load
project-local skills after you trust the project. Both are idempotent, and
only the text between text-graph's markers in AGENTS.md is ever rewritten.
.claude/ and .agents/ are hidden, so the skill never becomes nodes and
never triggers a reload.
The vault is found from the working directory (nearest .text-graph/, like
git), so none of these take a path. roster names each agent, its session,
how long its pane has been quiet, where it is working and its last line;
addressing accepts the harness name (claude), the session (tg_claude) or
the pane id, and a name matching two sessions fails rather than picking one.
A message arrives as if typed at the other agent's prompt β tmux's own paste
machinery, so multi-line messages don't submit themselves a line at a time β
prefixed with who sent it (from $TMUX_PANE, not from an argument) and one
line saying how to answer. Their harness queues it if it's mid-turn.
The conventions matter more than the commands: chatter goes in terminals, conclusions go in the vault. A message is a nudge; anything that should outlive the session is a note, linked from the notes it concerns, which is also how it becomes a node in the graph. Messages are capped at 8 KiB β past that you are writing a document at someone, and documents are notes.
Two refusals are deliberate: a shell or editor card is listed and peekable but never a send target (a message pasted into a shell would run), and the viewer itself never sends. Only agents and you type into panes.
Force-directed layouts are usually non-reproducible; this one isn't. The simulation is seeded from a deterministic radial layout of the directory tree and integrates with zero randomness, so the same vault settles into the same picture every launch β spatial memory works. Node order, child order, and resolution results are all deterministic too (a build-twice test enforces it).
The layout lives in ~10 constants at the top of src/sim.rs (repulsion,
spring stiffness, rest lengths, gravity, damping). If clusters look too tight
or too spread for your vault, those are the dials.
src/
vault.rs walk + frontmatter/wikilink/URL extraction (per-file, no global state)
filetype.rs extension classification: textual? which icon glyph and color?
mdview.rs Obsidian-flavor rewrite; vault-confined local image loading
resolve.rs Obsidian-style link resolution (stems β aliases β ghosts)
create.rs new note/folder: path validation + create-only fs writes
graph.rs arena: typed nodes, Contains tree, overlay links
layout.rs pure radial layout β the simulation's deterministic seed
sim.rs force simulation (springs, repulsion, gravity, cooling)
state.rs per-vault persistence (.text-graph/view: camera, cards, pins)
config.rs per-user preferences: one registry the file, the settings
window and the clamps are all derived from
stats.rs headless statistics (`stats` subcommand)
thumb.rs [gui feature] image file β downscaled RGBA pixels
tmux.rs tmux control-mode client (protocol parse, %output unescape)
mirror.rs per-pane screens: vt100 parsers behind a TermGrid facade
agents.rs which tmux panes count as agents (allowlist, owner marker, grace) + launch
keys.rs keyboard β tmux commands (key names + raw hex + buffer pastes)
comm.rs agent-to-agent messaging: the roster, addressing and delivery
behind `roster` / `send` / `peek` / `protocol`
highlight.rs [gui feature] syntect source colouring as plain RGB spans
search.rs the picker's engine: fuzzy name/path scoring, literal content
scanning (streamed from disk, never indexed), ranked rows
app/ egui shell, split by concern:
mod.rs = the Viewer struct, theme, shared node geometry,
side panel; canvas.rs = the frame as a pipeline of named
stages (paint order = stacking order); camera.rs = the
worldβscreen transform, rect compensation, glide;
keymap.rs = the keybinding table + dispatcher (guards and
key-repeat rules applied centrally); picker.rs = the list
overlay and its sources (find / browse / recent) over lib
search.rs; navigator.rs = the side pane (THE previewer:
header, bodies, connections strip); terminals.rs = card
state + sync/paint/forwarding/gestures/lifecycle;
actions.rs = right-click menu, create dialog, spawning;
math.rs = the box model rendered math is set with,
reload.rs = watcher/scan-worker substruct + apply +
persistence; images.rs = thumbnail textures, previews.rs =
canvas text previews + hover popup, diag.rs = health
badge, settings.rs = the β window + the key list;
kb_tests/ = the headless state-machine tests, by topic
scripts/
check.sh the full local gate chain (mirrors CI), exit-code gated
assets/
skill.md the agent-facing skill: what a vault is, how agents talk
(compiled in; `protocol` prints it, `--install` writes it)
icons.ttf bundled Nerd Font subset for file-type glyphs (OFL-1.1)
gen-icons-font.sh regenerates it; codepoints mirror src/filetype.rs
reading.ttf bundled Inter subset (OFL-1.1) β the face rendered
markdown reads in; non-Latin falls back to egui's fonts
gen-reading-font.sh regenerates it from an Inter release TTF
set-leading.py bakes the prose leading into a face β egui has no
line-height setting to reach for
math.ttf bundled Noto Sans Math subset (OFL-1.1) β the one face
a math span is drawn in, italics and all
gen-math-font.sh regenerates it; codepoints come from mathtext::glyphs()
examples/
tmux_debug.rs raw control-client event dump β the mirror's debug harness
discovery_probe.rs what discovery + mirrors see for a vault, headless
math_glyphs.rs the characters math spans convert to β what math.ttf holds
fixtures/
vault/ synthetic test vault β every link variant and trap
EXPECTED.md hand-counted ground truth the integration tests assert exactly
gen-stress.sh generates a large flat vault (gitignored) for stress testing
PLAN.md holds the roadmap, including the deferred Phase 2 (MCP server so
agents can read/write the graph, then LLM-assisted ingest).
Rust 1.95 or newer is required. The minimum version is checked in CI. The statistics CLI can be built without the native GUI stack:
cargo run --no-default-features -- stats <vault-path>
scripts/check.sh runs the whole gate chain CI runs β formatting, the
GUI-free layering check, clippy at zero warnings, all tests, and (when the
toolchains are installed) the MSRV build and dependency audit:
scripts/check.sh
The individual gates, for selective runs:
cargo fmt -- --check
cargo check --locked --no-default-features --lib --bin text-graph
cargo +1.95.0 check --locked --all-targets
cargo clippy --locked --all-targets -- -D warnings
cargo test --locked --all-targets
A suite of integration tests runs against a real tmux on a private socket
(skipped without tmux): scripted styled-screen mirroring, the exact typing
path end-to-end, native resize propagation, and agent launching. The
mirror's protocol layer (reply correlation, capture replay, cursor
restore) is additionally unit-tested without tmux, and the keyboard state
machine (modal hjkl, Esc ordering, link walking, the picker) is driven
through a headless egui harness (egui_kittest). Process-level CLI tests cover
help, version, usage errors, fixture statistics, and non-UTF-8 vault paths.
For performance work: cargo run --release --example perf_probe <vault>
times the headless pipeline (scan, build, reload carry-over, simulation
settle, content search) β fixtures/gen-stress.sh N generates large
synthetic vaults β and the β frame statistics setting overlays
per-stage frame times and the repaint rate in the running viewer.
Every line of text-graph was written by an AI agent working from a human's
direction, and contributions written the same way are welcome. CONTRIBUTING.md
has the whole ask; the short version is that scripts/check.sh is the entire
gate, changes land one coherent commit at a time, new behavior comes with a
test named as a sentence, and invariants belong in the compiler or a test
rather than in prose. CLAUDE.md is the map an agent should read before
editing; PLAN.md carries the roadmap and the decisions behind it.
MIT OR Apache-2.0, at your option β see LICENSE-MIT and LICENSE-APACHE.
The three bundled fonts are licensed separately, all under the SIL Open Font
License 1.1: assets/reading.ttf is a Latin subset of Inter, assets/icons.ttf
a 35-glyph subset of JetBrainsMono Nerd Font Propo, and assets/math.ttf a
math subset of the Noto Sans faces. THIRD-PARTY.md says what was subset from
what, and assets/LICENSE-OFL-1.1.txt carries the OFL text with every
copyright notice.
