Skip to content

Repository files navigation

text-graph

CI

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

text-graph showing a research vault of a hundred notes as a dense graph, with three live agent terminal cards tethered to the folders they work in

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.

Install

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.

The graph model

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.

Link resolution

Casefolded throughout; .md suffix optional; alias ([[x|shown]]) and heading/block ([[x#h]], [[x#^id]]) suffixes are stripped before resolving.

  1. A target containing / matches by path-component suffix β€” [[daily/2026-08-14]] finds notes/daily/2026-08-14.md.
  2. A bare name matches file stems first…
  3. …then frontmatter aliases: (or singular alias:) β€” so a note named 2025-0803-1746-transcoders.md with aliases: [SAE] is reachable as [[SAE]], and the alias becomes its label in the graph.
  4. Several candidates β†’ the first in sorted path order wins and the link is flagged ambiguous (visible in stats).
  5. A plain [[pic.png]] or [[data.csv]] (with extension) resolves to the image or asset node if that file exists in the vault.
  6. 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 named pic.png.md or Drawing.excalidraw.md stays linkable; only references to assets that don't exist are skipped.
  7. Still unresolved β†’ a ghost node.

Labels prefer frontmatter title:, then the first alias, then the file stem.

Controls

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.

Camera

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

Finding and browsing

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

A selected node

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

Terminal cards

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

Settings (, or the βš™ badge)

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.

Creating (right-click anywhere)

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).

Safety and bounded work

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.

Creating from the graph

Right-click is the creation surface; everything lands relative to the node you clicked:

  • New note… β€” type a name (.md is implied); sub/path/name creates 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_claude works 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 a tg_edit session whose card tethers to the file's own node (the binding rides the session's @tg_anchor tmux 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).

Agent terminals in the graph

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 attach from 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 put bash in 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.)

Agents talking to each other

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.

Determinism

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).

Feel

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.

Project layout

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).

Development

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.

Contributing

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.

License

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.

About

No description, website, or topics provided.

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages