SLOPSHOPPER

omnigraph

Live typed-graph pane: a node-link graph, Miller-column browser and op feed that follow the graph commands Claude runs. Observe-only; inert where nothing draws…

newpanebandguardcommandtoast
v0.1.0no licenseupdated 2026-10-13SearchingAlpha/claude-dotfiles/mods/omnigraph
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · omnigraph
│ ┃ omnigraph ✕ › fix the failing auth test and add an audit log call │ ┃ ◉ omnigraph · dev.omni 0 ops · +0n +0e · 0 × │ ┃ 1: Graph 2: Browse 3: Feed 4: Queue f: f ⏺ Read(src/auth.ts) │ ┃ waiting for a store ● live ⎿ Read 6 lines │ ┃ ▣ client module ./graph-view.ts ⏺ Update(src/auth.ts) │ ┃ ── browse ─────────────────────────────────… ⎿ Added 2 lines, removed 1 line │ ┃ no store to browse ⏺ Bash(bun test) │ ┃ ── feed ───────────────────────────────────… ⎿ 3 pass, 1 fail │ ┃ no ops yet: the graph commands Claude runs … │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /omni │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · omnigraph
◉ omnigraph · dev.omni 0 ops · +0n +0e · 0 × 1: Graph 2: Browse 3: Feed 4: Queue f: following ops cl waiting for a store ● live ▣ client module ./graph-view.ts ── browse ────────────────────────────────────────────── no store to browse ── feed ──────────────────────────────────────────────── no ops yet: the graph commands Claude runs show up here
README

omnigraph

omnigraph is a live pane over a typed graph while Claude works on it. When Claude runs a graph command (a Bash command your pattern matches, or a tool of an MCP server you name), the mod reads the graph's store before and after, works out which nodes and edges changed, and shows it straight away: a node-link drawing of the touched node's neighbourhood, a Miller-column browser and a feed of every op with its latency and outcome. It also adds a one-line band above the prompt, /omni, and a toast when ops arrive while the pane is closed.

◉ omnigraph · dev.omni · main                                     5 ops · +1n +2e · 1 ×
1: Graph  2: Browse  3: Feed  4: Queue  f: following ops  click the graph to browse with ↑↓←→
Offline editing & sync › contains › Conflict toast hides unsynced edits ● live

     crdt ●···                               ·● Backoff for sync ret…
                   tagged ×2         contains ×6
sync-core ●·············    ·····     ··········◆ Conflict toast hides…
                        ············◉···········● Persist the offline …
                         Offline editing & sync
 Nadia Okonkwo ●····            ·        ··· ··● Presence dots after …
                           ← specifies   targets
     RFC: Offline editing… ●·                     ·● v1.5.0
● Epic  ● Label  ● Actor  ● RFC  ● Issue  ● Release
── browse ──────────────────────────────────────────────────────────────────
Epic 4                       Offline editing & sync         Conflict toast hides unsynce…
  Hard tenant isolation      → contains · Issue 6           ← contains · Epic 1
▶ Offline editing & sync     ▶ Conflict toast hides unsyn…    Offline editing & sync
  Sync observability basel…    Persist the offline edit q…
── feed ────────────────────────────────────────────────────────────────────
13:41:24 ▸ mutate create_issue       +1n +0e    31ms ✓
13:41:24 ▸ mutate add_issue_to_epic  +0n +1e    41ms ✓

The engine is generic: it reads a small documented store format, and an adapter of userConfig options maps another producer's build onto it. A synthetic issue-tracker demo ships in demo/ so the whole thing runs anywhere, and presets/ holds tested adapters for real producers, the SupplyChainAgents ontology build first (presets/README.md).

Contents: Try it in 60 seconds · The pane · How an op is seen · The store format · Options · What it never does · Files · Tests · Limits

Try it in 60 seconds

Needs python3. The pane docks beside the transcript in fullscreen mode ("tui": "fullscreen") from 110 columns (144 or more is comfortable); elsewhere it opens above the prompt.

mkdir -p ~/omni-demo && cd ~/omni-demo
export PATH="<claude-dotfiles>/mods/omnigraph/demo:$PATH"   # puts the demo CLI on PATH as `omnigraph`
omnigraph init                                              # seeds graphs/dev.omni and queries/queries.gq
claude --plugin-dir <claude-dotfiles>/mods/omnigraph        # or plain `claude` once the mod is installed

In the session:

  1. Type /omni. The pane docks on the right, drawn around the busiest node (the offline-sync epic); the band above the prompt reads omnigraph · dev.omni · 31n 41e · 0 ops · ….
  2. Ask Claude: "Using the omnigraph CLI (omnigraph schema lists the ops), create the issue 'Conflict toast hides unsynced edits', add it to the offline-sync epic, assign it to Nadia and make it block the three-way merge issue."
  3. Watch the pane while Claude runs omnigraph mutate create_issue …, omnigraph mutate add_issue_to_epic --store graphs/dev.omni --params '{"epic":"epc-offline-sync","issue":"iss-offline-conflict-toast"}' and the rest: each op lands in the feed (+1n +0e 31ms ✓), the graph jumps to the node it touched (a diamond marks it), and the breadcrumb follows.
  4. Click the graph and browse with the arrow keys; Enter opens a node, Backspace goes back, f follows ops again, 2 opens the browser. Click 3: Feed for every op and its error, 4: Queue for the review queue (or /omni focus and use the digits).

Commands you run yourself with ! (bash mode) do not go through Claude's tools, so they are not ops; ask Claude, or run them in another terminal and use /omni reload.

The pane

| Tab | What it shows | | :- | :- | | 1 Graph | The selected node's neighbourhood: the node in the middle, incoming edges' nodes on the left, outgoing on the right, one group per edge type with its label (contains ×6, ← specifies), a +N more where a group outgrows the rows, and a legend of node labels by colour. Under it the browser's columns and the last four ops. | | 2 Browse | Miller columns: a label's nodes → the selected node's relations grouped by direction, type and target label (→ contains · Issue 6) → the next node's, and so on; a type picker for the first column, h back, and the selection's properties with the source lines its path:line property names. | | 3 Feed | The last 50 ops on this root, every session's: time, op, +nodes +edges (~1n when records changed, ·n ·e when there was nothing to diff), latency (bg for a command that went to the background), ✓/✗/…, and the error under a failure. | | 4 Queue | Nodes in a review status (proposed), open conflicts first; export writes a patch file per node (see below) and applies nothing. |

The header reads store · branch and this session's totals since it started or was cleared: ops, net +n +e, and failures (×). Ops other sessions on the same root recorded are in the feed but not in these totals. A warning line under the breadcrumb names a store file it could not read, lines it could not parse, or the mod's last internal error (/omni reload clears that). The breadcrumb is the browse path, ● live while the pane follows ops. Following stops when you browse and starts again with f.

The graph is a Client region: a click on it hands it the keys, then ↑↓←→ move the selection to the nearest node that way, Enter (or a second click) draws the graph around it, Backspace goes back one, f follows ops. Esc gives the keys back. In the VS Code extension and the mobile app, which have no Client, the graph is drawn as text; VS Code's integrated terminal is the terminal surface and draws the graph. The client's props carry names, labels and edge types cut to 64 cells (the drawing shows 32 at most), as the engine refuses a Client whose props pass 100,000 characters and puts its own placeholder in place of the whole pane; a store whose ids alone would pass that has its graph drawn as text.

/omni [graph|browse|feed|queue|focus|close|clear|reload] opens the pane (on a tab, or holding the keyboard), closes it, clears this root's feed, or reads the store again. /omni runs mid-turn, and opens the pane without taking the keyboard, so you keep typing to Claude while it follows the work. For the tab hotkeys (1-4, f, h) the pane needs the keyboard: /omni focus, Ctrl+X Tab, or a click on the pane, as the interface docs list them (in a scripted terminal, /omni focus worked; a click pressed the tab buttons and gave the graph the keys, but did not visibly focus the pane). While the graph holds the keys, 1-4 switch tabs too. When a skill or command of yours is already called omni (a ~/.claude/skills/omni/ folder, say: a personal skill's folder name is its command too), Claude Code refuses to register the mod's /omni and the mod passes /omni on, so yours runs; the band still opens the pane, whose warning line says /omni not registered. In claude -p, where the mod is inert, /omni is passed on as well.

The band shows omnigraph · store · branch · Nn Ee · totals · last op while a store is present or the feed has ops, with an open button. When ops arrive and the pane is not showing, a toast says how many (at most one every 20 s).

The same stores and feed are on shepherd's board in a browser (/shepherd, the Graph tab), every root's at once and with the whole width of a screen: the dashboard. It reads the files this mod reads and its $.store, and runs nothing.

How an op is seen

sequenceDiagram
  participant C as Claude
  participant M as omnigraph (tool.call observer)
  participant T as Bash / MCP tool
  participant S as store files
  C->>M: tool.call Bash "omnigraph mutate add_issue_to_epic …"
  Note over M: pattern test only, nothing else before next
  M->>T: next(e)
  T->>S: writes nodes.jsonl, edges.jsonl, manifest.json, ops.jsonl
  T-->>M: result (ran, refused or failed)
  M->>S: stat the three files; read them only when one changed
  Note over M: diff against this store's last read: +n +e, touched nodes, focus
  M->>S: the ops log's new lines, when it grew
  M->>M: feed ($.state, then $.store), follow, toast
  M-->>C: the same result, unchanged
  • A Bash command is an op when cmdRegex matches it; the first capture group that matched names the op (mutate add_issue_to_epic). The default needs the CLI as the command: at the start of a line or after ;, &&, | or (, past shell keywords (do, then, else, if, while, {, !), wrappers with their flags (env, time, timeout 30, nohup, exec, command, nice -n 5, sudo -u me, xargs -n1), VAR=value words, bash -c ', a folder, or a python3 interpreter and its flags (-u, -X dev). So for i in a b; do omnigraph mutate …; done is an op, and echo omnigraph init or a one-line commit message that names it is not. A command Claude Code's own check found read-only (cat, grep, sed -n) is never one. An MCP tool is an op when its name starts with mcpPrefix (the rest names it).
  • The pattern reads at most the first 16 KB of a command and, past that, its last 16 KB from a line start (a long heredoc, then the graph command). Every run in the default stops at a separator ([^\s;&|()]*, [ \t]+), so a 120 KB command costs about a millisecond. Write your own the same way: a \S* or \s* before a literal makes the engine rescan the rest of the command after every ;, and the time grows with the square of its length (12 s for 120 KB of ;a, which stalled the session's hooks; within the 16 KB slices, about half a second).
  • The store is the store option under the session root, or the command's --store <dir> (storeFlag) under its working directory and a leading cd <dir> && or (cd <dir> &&. A flag only the shell can spell ("$STORE", a backtick, a glob, ~user) is not guessed: the op is feed-only (·n ·e). A --store folder with no store in it does not replace the store the pane shows. The mod reads no environment variable for it: the demo CLI's OMNIGRAPH_STORE (its tests set it) moves the CLI's default store but not the mod's, so pass --store with it.
  • After the call the mod stats the store's three files and reads them only when one changed. Each store keeps its last read (the last four stores), so the diff is against that store's own read before: net nodes and edges, changed records, the touched ids, and where to look. That is the first new (else changed) node, drawn around its busiest neighbour; else a new edge, drawn around its busier end with the other highlighted. An op that adds or changes more than five nodes does not move the view.
  • With an ops log (opsFile), the mod reads the lines it gained since its last read: those after the last line it read then, so a log the producer trims at the top to a fixed length works too. It takes the newest that started while the call ran and whose op the command names, for its op name, ms and error. A call that wrote no record gets none: an older record never names it. The tool's own outcome decides ✓/✗; a record can only add the producer's reason, or turn a zero exit into ✗ when it says ok: false. Without a record, latency is the tool call's wall time.
  • A Bash call that runs in the background (run_in_background, a timeout, Ctrl+B) is recorded as started (bg …) without reading the store; the next op's diff carries what it did. One the engine refused, or that failed to start, is a failure (✗), not a running op.
  • Ops are recorded one at a time, in the order their calls settled, so overlapping calls (parallel tool calls, subagents) each get a feed entry and their own ops-log record. The first one recorded carries the store changes of both.
  • The feed is kept per session root in $.store, shared by the sessions on that root, and copied back after /clear, /resume and /branch. The feeds of the 40 roots used last are kept (each worktree is a root).
  • When the session root moves, the pane takes that root's feed and its configured store at once, so the first op there has a diff. Claude Code raises no CwdChanged for /cd, so the mod looks after /cd, after the EnterWorktree and ExitWorktree tools, on CwdChanged, and at each turn's start for anything else; where the root did not move, that costs one $.session.root() read. /cd was checked in a live session; the worktree tools only through the test kit.

The store format

A store is a folder (omnigraph/1):

| File | What it holds | | :- | :- | | manifest.json | A JSON object. Read: the version (versionKey, default version), name (the header's store name), and an open-conflicts list (conflictsKey). Counts (nodes, edges, labels, edge_types, status) are for humans; the mod counts the records itself. Optional: without it the version is a hash of the records. | | nodes.jsonl | One node per line: {"id": "iss-1", "label": "Issue", "props": {"name": "…", "status": "open", "source": "docs/x.md:12"}} | | edges.jsonl | One edge per line: {"from": "epc-1", "type": "contains", "to": "iss-1", "props": {}}; a repeated edge counts once | | ops.jsonl | Optional, appended per op (trimming its oldest lines is fine): {"t": <ms epoch when the op started>, "kind": "mutate", "op": "create_issue", "ok": true, "ms": 31, "error": "…"}. Log failures too (a bad argument, a refusal), so the feed shows the producer's reason. |

Write the manifest last, and each file whole (write a temp file, rename it), so a reader never sees half a store. demo/omni.py is a reference writer. A byte-order mark is skipped, and control characters in names, labels, types and statuses are drawn as spaces (the patch keeps the record's own id).

Another producer's build works with options only: nodeKeys and edgeKeys rename the record keys (uid kind . reads a node's properties off the record itself), versionKey names the manifest's version key, nameKeys the display-name properties, statusKey and sourceKey the review status and the path:line source. A line that does not parse is counted in the header's warning, not fatal. A producer you use often gets a preset in presets/: its options in one file, a test of its pattern and shape, and preset.py block to print the settings.

Options

userConfig values live under "pluginConfigs": { "omnigraph@claude-dotfiles": { "options": { … } } } in user settings (omnigraph@inline for a --plugin-dir copy), or claude plugin configure omnigraph@claude-dotfiles. Its --values-stdin drops empty values (2.1.295), so a whole preset goes in as the block presets/preset.py block prints.

| Option | Default | What it does | | :- | :- | :- | | store | graphs/dev.omni | The store folder, relative to the session root, or absolute | | storeFlag | --store | A flag whose value names the store a command writes; empty: always store | | storeLabel | empty | The header's store name; empty: the manifest's name, else the folder's | | cmdRegex | the demo CLI's mutate/query/init, as the command | A JavaScript regular expression for graph commands; anchor yours to the command word and stop every run at a separator ([^\s;&\|()]*, [ \t]+, never \S*) as the default does. Empty: no Bash ops | | mcpPrefix | empty | MCP tool-name prefix of graph ops, e.g. mcp__graph__ | | manifestFile, nodesFile, edgesFile, opsFile | manifest.json, nodes.jsonl, edges.jsonl, ops.jsonl | The store's file names; opsFile empty: none | | versionKey | version | The manifest's version key | | nodeKeys, edgeKeys | id label props, from type to | The record keys, in that order | | nameKeys | name title | Properties tried for a node's name | | statusKey, sourceKey, conflictsKey | status, source, open_conflicts | Review status, path:line source, manifest list of open conflicts; empty turns one off | | queueStatus, acceptStatus, owner | proposed, accepted, empty | What the Queue lists, and what a patch sets | | patchDir | empty | Where exports go (one folder per store); empty: omnigraph/patches under the Claude Code configuration folder | | band, toast | true, true | The band above the prompt; the new-ops toast |

What it never does

  • It never changes what Claude does. Every hook passes its event on and returns the engine's answer unchanged; nothing refuses, rewrites or answers a tool call. Commands and tool calls that are not graph ops cost one regular-expression test over at most 32 KB of the command; /cd, the worktree tools and each turn's start cost one $.session.root() read while the root stays put.
  • In a session where nothing draws (claude -p, cron, the SDK) it registers nothing and reads nothing until a client attaches.
  • It reads the store, the store's ops log, the root's .git/HEAD and, in Browse, the source lines a node names, only when the path lands under the session root (spelled and with links resolved). It writes only its own $.store (the feed), errors.log in its folder under the Claude Code configuration folder (its own failures, the last 200 lines), and, when you press export in the Queue tab, a patch file under patchDir. It never writes the store or the repository: applying a patch is yours (or a script of the producer's that validates it).
  • A patch is the record's id, the accepted status and the owner, as YAML. The comment lines hold no line break of any kind, whatever the store's names say: no \n or \r, and no NEL, U+2028 or U+2029, which YAML 1.1 readers such as PyYAML also take for line breaks. The quoted values escape those three (and DEL, C1, U+FFFE, U+FFFF), so the id reads back as the store holds it. The file is <id>.yaml when the id is safe in a file name, else <id-slug>-<hash>.yaml:
  # omnigraph patch for dev.omni 4be52b68f527, exported 2026-10-09T13:41:24.000Z
  # was: proposed (Issue) "Prorated invoices on plan change"
  - id: "iss-bill-proration"
    status: "accepted"

Files

| Path | What it holds | | :- | :- | | .claude-plugin/plugin.json | Name, version, the types contract and the options | | hooks/hooks.json | "modules": ["./register.ts"] | | hooks/register.ts | The hooks: going live, the tool.call observers, /omni, /clear, a moved root (/cd, the worktree tools, CwdChanged, turn.start), the pane, the graph client's messages, the band; every $ call is here | | hooks/graphstore.ts | The store format and adapter, the diff, naming ops, the ops-log record, the feed's words; no $ | | hooks/text.ts | Cleaning control characters, display width in cells (wide characters take two), cutting to a width | | hooks/ego.ts, hooks/canvas.ts | A node's neighbourhood picked from the store, laid out on a character grid, and the arrow-key and click rules | | hooks/graph-view.ts | The graph's Client module: draws the layout, posts keys and clicks to register.ts | | hooks/views.ts | The pane's and the band's trees | | types/index.d.ts | The $.state contract and the op and store shapes | | demo/omni.py, demo/omnigraph | The synthetic issue-tracker CLI (init, schema, mutate, query, status) and its PATH wrapper | | demo/test_omni.py | The CLI's tests, and the check that tests/demo-store.ts matches what the CLI writes | | presets/ | Adapter presets (sca.json and its how-to sca.md), preset.py (print one as settings, check a real build with it), the real-build test it runs, and their Python tests: presets/README.md | | tests/ | engine.test.ts (store, adapter, diff, ops log, ring, layout, browser), omnigraph.test.ts (the hooks through the test kit), hardening.test.ts (failures, overlap, background, store resolution, hostile data, its own failures), presets.test.ts (each preset's pattern and options, and the sca preset on a made-up build of its shape), fixtures.ts (the stub world), demo-store.ts and presets.ts (generated) |

Tests

mods/check.sh omnigraph                                        # validate, 98 tests, probe, headless load, tsc
python3 -m unittest discover -s mods/omnigraph/demo -p 'test_*.py'
python3 -m unittest discover -s mods/omnigraph/presets -p 'test_*.py'
python3 mods/omnigraph/demo/test_omni.py --regen               # after changing the demo CLI
python3 mods/omnigraph/presets/test_presets.py --regen         # after changing a preset
harnesses/tools/moddev.sh omnigraph                            # live, with the debug log in mods/.debug/

The debug log has one line per op with the mod's own cost (mutate create_issue recorded in 16ms). In a live session on the demo store that was 8-16 ms per op after the tool answered. An op on a store whose files did not change only stats them. A changed store is read and parsed again before the tool's result goes back to Claude, so the cost grows with the store: in the test kit, a real build of about 1,000 nodes and 2,000 edges took 8-9 ms for an unchanged op and 17-19 ms for a changed one (presets/preset.py check); a 20,000-node, 43,000-edge store (4.5 MiB) about 60-75 ms to parse and 150-250 ms for a changed op. A hub with 20,000 neighbours, the busiest node and so the first one shown, took 16 ms for its graph, 52 ms for its browser column and 86 ms for the whole Graph tab. A 120 KB Bash command full of separators took the pattern about a millisecond; live, its tool.call settled 6 ms after the built-in diff mod's. Tested with Claude Code 2.1.295.

Limits

  • One op per Bash call: a command that runs two graph commands is one op, named by the first match, with both diffs together. A pattern match inside a quoted string after a separator (git commit -m "fix; omnigraph init") is still an op, with no change, and so is a line of a heredoc body or a multi-line string that starts with the CLI (cat > notes.md <<'EOF' with an omnigraph init line, a commit message's second paragraph).
  • A graph command the pattern does not see (a wrapper it does not know, such as uv run; a line continuation before the CLI's arguments; one in the middle of a command longer than 32 KB) is no op, and its change lands on the next op's diff.
  • Overlapping calls on one store share a diff: the first recorded carries both changes, the other shows +0n +0e. A change another session or a background command made shows in the next op's diff, or after /omni reload.
  • The first op on a store the mod has not read yet (a new --store) has no diff (·n ·e); after that it has one. A moved root's configured store is read when the root moves.
  • Without an ops log, latency includes any time the permission prompt waited for you.
  • Ops are seen in the sessions that load the mod.
  • A store file over 4 MiB is not read: the pane keeps the last read, the op has no diff, and the header says so.
  • The Queue only exports patches; there is no accept button yet.
Source 8 files
hooks/register.ts 1034 lines
1// omnigraph: a live pane over a typed graph store while Claude works on it.
2//
3// It follows mods/CLAUDE.md: inert where nothing draws (claude -p, cron, the
4// SDK); observe-only (every hook on an engine event passes it on with next and
5// returns what came back, none can refuse anything); a tool.call observer does
6// nothing before next and reads the store only after a matched graph command;
7// what the drawings read sits in $.state; the pane opens only when the person
8// asks; files are written only by a Queue export press and the mod's own error
9// log, both under the mod's folder.
10import { atom, read, update } from 'claude-code'
11import type { Elements, EngineInterface, On, OmniOp, PluginOptions, ToolCallResult } from 'claude-code'
12
13import { egoOf, propsFit } from './ego'
14import {
15  adapterOf,
16  branchOfHead,
17  degreeOf,
18  diffGraphs,
19  emptyGraph,
20  errorLineOf,
21  feedOf,
22  fit,
23  focusOf,
24  gitDirOf,
25  infoOf,
26  isBackgroundResult,
27  isUnder,
28  joinPath,
29  NO_TEXTS,
30  opNameOf,
31  opRecordOf,
32  parseGraph,
33  patchOf,
34  plural,
35  pushRing,
36  slugOf,
37  sourceRefOf,
38  storeDirOf,
39  storeNameOf,
40  totalsOf,
41  touchedOf,
42} from './graphstore'
43import type { Adapter, Graph, OpRecord, StoreTexts } from './graphstore'
44import { clean } from './text'
45import { bandText, graphTabRows, paneOf, TABS } from './views'
46import type { Actions, Tab } from './views'
47
48const PANE = 'omnigraph'
49/** At most one "new ops" toast this often while the pane is closed. */
50const TOAST_GAP_MS = 20_000
51/** A store file, or an ops log, past this is not read (the $.fs.read cap). */
52const FILE_MAX = 4 * 1024 * 1024
53/** How many stores' last reads are kept, so ops that alternate between stores each get a diff. */
54const BASELINES_MAX = 4
55/** How many roots keep a feed in $.store; the ones idle longest go first. */
56const FEEDS_MAX = 40
57/** The error log keeps this many lines. */
58const ERROR_LOG_LINES = 200
59/** How many ops-log lines no op took are kept per store, for an overlapping op to find its own. */
60const PENDING_MAX = 20
61
62// `plugin` and `key` stay string literals and each atom sits in a const:
63// `claude plugin validate` reads them off the source and holds them to
64// types/index.d.ts.
65const feed = atom({ plugin: 'omnigraph', key: 'feed' } as const, [])
66const since = atom({ plugin: 'omnigraph', key: 'since' } as const, 0)
67const info = atom({ plugin: 'omnigraph', key: 'info' } as const, null)
68const graphRev = atom({ plugin: 'omnigraph', key: 'graphRev' } as const, 0)
69const tab = atom({ plugin: 'omnigraph', key: 'tab' } as const, 'graph')
70const trail = atom({ plugin: 'omnigraph', key: 'trail' } as const, [])
71const center = atom({ plugin: 'omnigraph', key: 'center' } as const, null)
72const isFollowing = atom({ plugin: 'omnigraph', key: 'isFollowing' } as const, true)
73const browseLabel = atom({ plugin: 'omnigraph', key: 'browseLabel' } as const, null)
74const queuePage = atom({ plugin: 'omnigraph', key: 'queuePage' } as const, 0)
75const notice = atom({ plugin: 'omnigraph', key: 'notice' } as const, null)
76
77// Module state, lost on a reload and read again from the store: the graph is
78// too big for $.state, so the drawings read graphRev to redraw when it changes.
79let adapter: Adapter = adapterOf({})
80/** The store the pane shows. */
81let graph: Graph = emptyGraph('')
82/** The last reads of the stores the ops touched, by folder, with the stat stamp each was read at. */
83const baselines = new Map<string, { graph: Graph; stamp: string }>()
84/**
85 * How far each store's ops log was read: its stat, and once read its line
86 * count and last line (lines null: not read yet), so a log trimmed to a fixed
87 * length still shows which lines are new.
88 */
89const logCursors = new Map<string, { size: number; mtimeMs: number; lines: number | null; last: string | null }>()
90/** Ops-log lines read but taken by no op yet (an overlapping op's), per store folder. */
91const pendingLines = new Map<string, string[]>()
92/** The op being recorded: the next one waits for it, so two overlapping ops neither share a diff nor lose a feed entry. */
93let queue: Promise<void> = Promise.resolve()
94/** True once the session has a surface; until then every hook only passes its event on. */
95let isLive = false
96/** True once /omni is the mod's: registered in a live session. */
97let isRegistered = false
98let sessionId: string | null = null
99/** The session root the feed atom was read for. */
100let rootSeen = ''
101/** The mod's last internal error, shown in the header until /omni reload. */
102let internalError: string | null = null
103let lastToastAt = -Infinity
104let unseen = 0
105let isToastOn = true
106/** The graph region's size at the last pane draw: a ui.message answer lays the client out the same. */
107const lastEgoSize = { columns: 60, rows: 12 }
108let sourceCache: { key: string; text: string | null } = { key: '', text: null }
109
110const feedKey = (root: string): string => `feed:${root}`
111
112const messageOf = (error: unknown): string => clean(error instanceof Error ? error.message : String(error)).slice(0, 300)
113
114/** The mod's own folder under the Claude Code configuration folder; null without a home. */
115async function ownDirOf($: EngineInterface): Promise<string | null> {
116  const home = (await $.env.get('HOME')) ?? null
117  const config = (await $.env.get('CLAUDE_CONFIG_DIR')) || (home === null ? null : `${home}/.claude`)
118
119  return config === null ? null : `${config}/omnigraph`
120}
121
122/**
123 * An internal failure: to the debug log, the header, and (once live)
124 * errors.log in the mod's folder. The mod fails open: the call it was
125 * watching already has its answer.
126 */
127async function logError($: EngineInterface, what: string, error: unknown): Promise<void> {
128  const line = `${what}: ${messageOf(error)}`
129
130  $.ui.log(line, { to: 'debug' })
131  internalError = line
132  try {
133    await update($, info, was => (was === null ? was : { ...was, problem: [was.problem, line].filter(note => note !== null).join('; ') }))
134    const dir = isLive ? await ownDirOf($) : null
135    if (dir !== null) {
136      const was = (await $.fs.read(`${dir}/errors.log`).catch(() => '')) ?? ''
137      const at = new Date(await $.clock.now()).toISOString()
138      const lines = `${was}${at} ${line}\n`.split('\n').filter(entry => entry !== '')
139      await $.fs.write(`${dir}/errors.log`, `${lines.slice(-ERROR_LOG_LINES).join('\n')}\n`)
140    }
141  } catch (again) {
142    $.ui.log(`omnigraph errors.log not written: ${messageOf(again)}`, { to: 'debug' })
143  }
144}
145
146// ------------------------------------------------------------- the store
147
148type StoreRead = {
149  /** The texts, or null when the stamp was the known one (nothing changed) or a file is over the cap and there is a read to keep. */
150  texts: StoreTexts | null
151  stamp: string
152  problem: string | null
153  isPartial: boolean
154}
155
156/**
157 * Stats the store's three files, then reads them unless the stamp is the
158 * `known` one. A file over the cap is not read: with `canKeep` nothing is,
159 * so the caller keeps its last read instead of seeing the store emptied.
160 */
161async function readStore($: EngineInterface, dir: string, known: string | null, canKeep: boolean): Promise<StoreRead> {
162  const names = [adapter.manifestFile, adapter.nodesFile, adapter.edgesFile]
163  const stats = await Promise.all(names.map(name => (name === '' ? Promise.resolve(null) : $.fs.stat(`${dir}/${name}`).catch(() => null))))
164  const stamp = stats.map(stat => (stat === null ? '-' : `${stat.mtimeMs}:${stat.size}`)).join('|')
165  const big = names.filter((name, i) => (stats[i]?.size ?? 0) > FILE_MAX)
166  const problem = big.length > 0 ? `${big.join(', ')} over 4 MiB, not read` : null
167
168  if (stamp === known || (big.length > 0 && canKeep)) {
169    return { texts: null, stamp, problem, isPartial: big.length > 0 }
170  }
171
172  const texts = await Promise.all(
173    names.map((name, i) => (stats[i] === null || stats[i]?.kind !== 'file' || big.includes(name) ? Promise.resolve(null) : $.fs.read(`${dir}/${name}`).catch(() => null))),
174  )
175
176  return {
177    texts: { manifest: texts[0] ?? null, nodes: texts[1] ?? null, edges: texts[2] ?? null },
178    stamp,
179    problem,
180    isPartial: big.length > 0,
181  }
182}
183
184/** A store read: the last read of that folder before it (null: none), this one, and whether it differs. */
185type GraphRead = { before: Graph | null; after: Graph; changed: boolean; isPartial: boolean; problem: string | null }
186
187/** Reads the store at `dir` again when its files changed (or `force`), and keeps it as that folder's baseline. */
188async function readGraph($: EngineInterface, dir: string, force: boolean): Promise<GraphRead> {
189  const cached = baselines.get(dir)
190  const found = await readStore($, dir, force || cached === undefined ? null : cached.stamp, cached !== undefined)
191
192  if (!logCursors.has(dir)) {
193    await markOpsLog($, dir)
194  }
195  if (found.texts === null && cached !== undefined) {
196    return { before: cached.graph, after: cached.graph, changed: false, isPartial: found.isPartial, problem: found.problem }
197  }
198
199  const after = parseGraph(dir, found.texts ?? NO_TEXTS, adapter)
200
201  baselines.delete(dir)
202  baselines.set(dir, { graph: after, stamp: found.stamp })
203  // The oldest go first, never the one on screen.
204  for (const key of baselines.keys()) {
205    if (baselines.size <= BASELINES_MAX) {
206      break
207    }
208    if (key !== graph.dir && key !== dir) {
209      baselines.delete(key)
210    }
211  }
212
213  return { before: cached?.graph ?? null, after, changed: true, isPartial: found.isPartial, problem: found.problem }
214}
215
216/** The branch the session root is on, from .git/HEAD (a worktree's .git file points at its own). */
217async function branchOf($: EngineInterface, root: string): Promise<string | null> {
218  let dir = root
219
220  // Up to the filesystem's root: a /cd deep into a checkout still names its branch.
221  for (let depth = 0; depth < 64 && dir !== ''; depth++) {
222    const dotGit = await $.fs.stat(`${dir}/.git`).catch(() => null)
223    if (dotGit !== null) {
224      const gitDir = dotGit.kind === 'dir' ? `${dir}/.git` : gitDirOf((await $.fs.read(`${dir}/.git`).catch(() => '')) ?? '')
225      if (gitDir === null) {
226        return null
227      }
228      const head = await $.fs.read(`${joinPath(dir, gitDir)}/HEAD`).catch(() => null)
229
230      return head === null ? null : branchOfHead(head)
231    }
232    dir = dir.replace(/\/[^/]*$/, '')
233  }
234
235  return null
236}
237
238/** Makes `next` the graph the drawings read, with the header's facts; starts at the busiest node when nothing is selected. */
239async function show($: EngineInterface, next: Graph, problem: string | null): Promise<void> {
240  const isNew = next !== graph
241
242  graph = next
243  const branch = await branchOf($, await $.session.root())
244  await update($, info, () => infoOf(graph, adapter, branch, [problem, internalError]))
245
246  if (isNew) {
247    await update($, graphRev, rev => rev + 1)
248    sourceCache = { key: '', text: null }
249  }
250
251  // Nothing selected yet, or the selection is gone: start at the busiest node.
252  const path = await read($, trail)
253  if (graph.nodes.size > 0 && (path.length === 0 || !path.every(id => graph.nodes.has(id)))) {
254    let hub: string | null = null
255    for (const id of graph.nodes.keys()) {
256      if (hub === null || degreeOf(graph, id) > degreeOf(graph, hub)) {
257        hub = id
258      }
259    }
260    if (hub !== null) {
261      await update($, trail, () => [hub as string])
262      await update($, center, () => hub)
263    }
264  }
265}
266
267/** The configured store under the current root. */
268async function configuredDirOf($: EngineInterface): Promise<string> {
269  return joinPath(await $.session.root(), adapter.store, (await $.env.get('HOME')) ?? null)
270}
271
272/**
273 * Reads the store again for /omni reload, /clear and a root that moved. A
274 * moved root gets its feed and its configured store; otherwise the pane keeps
275 * the store it shows (a --store one) while that is there and under the root,
276 * else it too gets the configured one.
277 */
278async function reload($: EngineInterface): Promise<void> {
279  const root = await $.session.root()
280  const hasMoved = root !== rootSeen
281
282  if (hasMoved) {
283    await loadFeed($)
284  }
285  internalError = null
286  const dir = !hasMoved && graph.isPresent && graph.dir !== '' && isUnder(graph.dir, root) ? graph.dir : await configuredDirOf($)
287  const found = await readGraph($, dir, true)
288  await show($, found.after, found.problem)
289}
290
291/**
292 * After a /cd, a worktree tool, a shell cd and at each turn's start: when the
293 * session root moved, the pane takes that root's feed and store, read now so
294 * the first op there has a diff. Costs one $.session.root() otherwise; never
295 * throws, as it runs after the event's own answer.
296 */
297async function followRoot($: EngineInterface): Promise<void> {
298  try {
299    if (isLive && (await $.session.root()) !== rootSeen) {
300      await reload($)
301    }
302  } catch (error) {
303    await logError($, 'following the session root', error)
304  }
305}
306
307async function loadFeed($: EngineInterface): Promise<void> {
308  const root = await $.session.root()
309  const stored = feedOf(await $.store.get(feedKey(root)))
310
311  rootSeen = root
312  await update($, feed, () => stored)
313}
314
315/** Keeps the feeds of the FEEDS_MAX roots used last (each worktree is a root); the store is 4 MiB for them all. */
316async function pruneFeeds($: EngineInterface): Promise<void> {
317  const keys = (await $.store.keys()).filter(key => key.startsWith('feed:'))
318
319  if (keys.length <= FEEDS_MAX) {
320    return
321  }
322
323  const ages = await Promise.all(
324    keys.map(async key => {
325      const ops = feedOf(await $.store.get(key))
326      return { key, t: ops[ops.length - 1]?.t ?? 0 }
327    }),
328  )
329  const current = feedKey(await $.session.root())
330  for (const { key } of ages.sort((x, y) => x.t - y.t).slice(0, keys.length - FEEDS_MAX)) {
331    if (key !== current) {
332      await $.store.delete(key)
333    }
334  }
335}
336
337/** What the session shows once it has somewhere to draw. `$` may be passed to a function at the top level of this file, and nowhere else. */
338async function goLive($: EngineInterface): Promise<void> {
339  // The prompt and its band may have drawn already, while the mod was inert.
340  $.ui.invalidate('ui.render')
341
342  try {
343    const now = await $.clock.now()
344    sessionId = await $.session.id()
345    await update($, since, was => was || now)
346    await loadFeed($)
347    const found = await readGraph($, await configuredDirOf($), true)
348    await show($, found.after, found.problem)
349  } catch (error) {
350    await logError($, 'reading the store', error)
351  }
352
353  try {
354    await pruneFeeds($)
355  } catch (error) {
356    await logError($, 'pruning old feeds', error)
357  }
358
359  // Last, and caught: register throws for a taken name (a skill or command of
360  // the person's called omni), and a hook that throws is skipped whole.
361  try {
362    await $.command.register({
363      name: 'omni',
364      description: 'Open the omnigraph pane: the graph, a browser, the op feed and a review queue',
365      argumentHint: '[graph|browse|feed|queue|focus|close|clear|reload]',
366      immediate: true,
367    })
368    isRegistered = true
369  } catch (error) {
370    await logError($, '/omni not registered', error)
371  }
372}
373
374// --------------------------------------------------------------- the ops
375
376/** A matched call: when it started and when its result came back, what it was, and that result. */
377type Seen = {
378  t0: number
379  t1: number
380  name: string
381  command: string | null
382  source: OmniOp['source']
383  result: ToolCallResult
384  /** The command went to the background: it has not run yet. */
385  isBackground: boolean
386}
387
388/** Notes where the ops log ends when a store is first read, so the records already in it never stand in for an op's. */
389async function markOpsLog($: EngineInterface, dir: string): Promise<void> {
390  if (adapter.opsFile === '') {
391    return
392  }
393
394  const stat = await $.fs.stat(`${dir}/${adapter.opsFile}`).catch(() => null)
395
396  logCursors.set(dir, stat === null ? { size: 0, mtimeMs: 0, lines: 0, last: null } : { size: stat.size, mtimeMs: stat.mtimeMs, lines: null, last: null })
397}
398
399/**
400 * The lines the ops log gained since it was last read: those after the line
401 * it ended with then (where an append left it, else wherever a trim moved
402 * it), everything when that line is gone, only the last one when the log was
403 * never read.
404 */
405async function freshOpLines($: EngineInterface, dir: string): Promise<string[]> {
406  const path = `${dir}/${adapter.opsFile}`
407  const stat = await $.fs.stat(path).catch(() => null)
408  const was = logCursors.get(dir)
409
410  if (stat === null || stat.kind !== 'file') {
411    logCursors.set(dir, { size: 0, mtimeMs: 0, lines: 0, last: null })
412    return []
413  }
414  if (was !== undefined && stat.size === was.size && stat.mtimeMs === was.mtimeMs) {
415    return []
416  }
417  if (stat.size > FILE_MAX) {
418    logCursors.set(dir, { size: stat.size, mtimeMs: stat.mtimeMs, lines: null, last: null })
419    return []
420  }
421
422  const text = await $.fs.read(path).catch(() => null)
423  if (text === null) {
424    return []
425  }
426  const lines = text.split('\n').filter(line => line.trim() !== '')
427  logCursors.set(dir, { size: stat.size, mtimeMs: stat.mtimeMs, lines: lines.length, last: lines[lines.length - 1] ?? null })
428
429  if (was === undefined || was.lines === null) {
430    return lines.slice(-1)
431  }
432  if (was.last === null) {
433    return lines
434  }
435  // Appended to: the old last line is where it was. Trimmed at the top: it moved up.
436  if (lines[was.lines - 1] === was.last) {
437    return lines.slice(was.lines)
438  }
439  const at = lines.lastIndexOf(was.last)
440
441  return at >= 0 ? lines.slice(at + 1) : lines
442}
443
444/**
445 * The producer's record of this call: a line the ops log gained since it was
446 * last read, or one an overlapping op left, that fits the call; else null.
447 */
448async function newOpRecord($: EngineInterface, dir: string, seen: Seen): Promise<OpRecord | null> {
449  if (adapter.opsFile === '') {
450    return null
451  }
452
453  const lines = [...(pendingLines.get(dir) ?? []), ...(await freshOpLines($, dir))].slice(-PENDING_MAX)
454  const found = opRecordOf(lines, seen.t0, seen.t1, seen.command)
455
456  pendingLines.set(dir, found === null ? lines : lines.filter((line, i) => i !== found.index))
457
458  return found
459}
460
461/**
462 * Records ops one at a time in the order their calls settled: two that
463 * overlap (parallel calls, subagents) would otherwise read and write the feed
464 * and the store's baseline over each other. A wait that outruns the hook's
465 * budget still leaves the call its answer: the engine's catch replays it.
466 */
467async function recordInOrder($: EngineInterface, seen: Seen): Promise<void> {
468  const before = queue
469  let done = (): void => undefined
470  queue = new Promise<void>(resolve => {
471    done = resolve
472  })
473
474  try {
475    await before
476    await recordOp($, seen)
477  } catch (error) {
478    await logError($, `recording ${seen.name}`, error)
479  } finally {
480    done()
481  }
482}
483
484/** After a matched call: diffs its store against that store's last read, logs the op, follows it, toasts it. */
485async function recordOp($: EngineInterface, seen: Seen): Promise<void> {
486  const root = await $.session.root()
487  const isRefused = seen.result.deny !== undefined
488  const isFailed = isRefused || seen.result.isError === true
489  let found: GraphRead | null = null
490  let logged: OpRecord | null = null
491
492  if (root !== rootSeen) {
493    await loadFeed($)
494  }
495
496  // A background command has not run yet: the next read of its store diffs what it did.
497  if (!seen.isBackground) {
498    const home = (await $.env.get('HOME')) ?? null
499    const dir = storeDirOf(root, await $.session.cwd(), seen.command, adapter, home)
500
501    if (dir !== null) {
502      found = await readGraph($, dir, false)
503      // A store the op's command named but that is not there stays off the pane.
504      if (found.after.isPresent || dir === graph.dir) {
505        await show($, found.after, found.problem)
506      }
507      if (!isRefused && found.after.isPresent) {
508        logged = await newOpRecord($, dir, seen)
509      }
510    }
511  }
512
513  const before = found?.before ?? null
514  const after = found?.after ?? null
515  // A delta needs this store's last read, and a read of all of it now.
516  const isCounted = after !== null && after.isPresent && before !== null && found?.isPartial !== true
517  const diff = isCounted && found?.changed === true ? diffGraphs(before, after) : null
518  // The tool's own failure always wins: a record can only add the producer's reason.
519  const ok = !isFailed && (logged?.ok ?? true)
520  const op: OmniOp = {
521    t: seen.t0,
522    op: logged?.op ?? seen.name,
523    source: seen.source,
524    store: after?.isPresent ? storeNameOf(after, adapter) : null,
525    ok,
526    ms: logged?.ms ?? Math.max(0, seen.t1 - seen.t0),
527    dNodes: isCounted ? after.nodes.size - before.nodes.size : null,
528    dEdges: isCounted ? after.edges.length - before.edges.length : null,
529    touched: diff === null ? [] : touchedOf(diff),
530    focus: diff === null ? null : focusOf(diff, after as Graph),
531    error: ok ? null : (logged?.error ?? (isRefused ? 'refused' : errorLineOf(seen.result.isError === true ? seen.result.text : undefined)) ?? 'the ops log says it failed'),
532    version: after?.version ?? null,
533  }
534
535  if (sessionId !== null) {
536    op.session = sessionId
537  }
538  if (diff !== null && diff.changedNodes.length > 0) {
539    op.changed = diff.changedNodes.length
540  }
541  if (seen.isBackground) {
542    op.isBackground = true
543  }
544
545  // The store is shared by every session on this root: read it again right
546  // before the write. The pane gets the op first, so a write that fails (the
547  // store is full) loses only the copy.
548  const stored = pushRing(feedOf(await $.store.get(feedKey(root))), op)
549  await update($, feed, () => stored)
550  try {
551    await $.store.set(feedKey(root), stored)
552  } catch (error) {
553    await logError($, 'keeping the feed in $.store', error)
554  }
555
556  if (op.focus !== null && graph === after && (await read($, isFollowing))) {
557    await follow($, op.focus)
558  }
559
560  const now = await $.clock.now()
561  if (!(await $.ui.panes()).some(pane => pane.id === PANE && pane.isShown)) {
562    unseen++
563    if (isToastOn && now - lastToastAt >= TOAST_GAP_MS) {
564      lastToastAt = now
565      $.ui.toast(`${plural(unseen, 'new op')}, last ${op.op} ${op.isBackground ? '… bg' : op.ok ? '✓' : '✗'} · /omni`)
566    }
567  }
568
569  // The mod's own cost of the op, after the tool answered: the debug log has it.
570  $.ui.log(`${op.op} recorded in ${Math.round((await $.clock.now()) - seen.t1)}ms`, { to: 'debug' })
571}
572
573/** Moves the graph and the trail to an op's nodes, keeping the trail up to its hub when the hub is on it. */
574async function follow($: EngineInterface, focus: NonNullable<OmniOp['focus']>): Promise<void> {
575  const path = await read($, trail)
576  const at = path.indexOf(focus.center)
577  const base = at >= 0 ? path.slice(0, at + 1) : [focus.center]
578
579  await update($, trail, () => (focus.selected === focus.center ? base : [...base, focus.selected]))
580  await update($, center, () => focus.center)
581  await update($, browseLabel, () => null)
582}
583
584// ------------------------------------------------------- browsing the graph
585
586/** The trail with `id` selected next to the graph's centre (the centre itself ends it). */
587function trailTo(path: readonly string[], hub: string | null, id: string): string[] {
588  const at = hub === null ? -1 : path.lastIndexOf(hub)
589  const base = at >= 0 ? path.slice(0, at + 1) : hub === null ? [] : [hub]
590
591  return id === hub ? base : [...base, id]
592}
593
594async function selectNode($: EngineInterface, id: string): Promise<void> {
595  const hub = await read($, center)
596
597  await update($, isFollowing, () => false)
598  await update($, trail, path => trailTo(path, hub, id))
599}
600
601async function openNode($: EngineInterface, id: string): Promise<void> {
602  const hub = await read($, center)
603
604  await update($, isFollowing, () => false)
605  await update($, trail, path => (path[path.length - 1] === id ? path : trailTo(path, hub, id)))
606  await update($, center, () => id)
607}
608
609async function goBack($: EngineInterface): Promise<void> {
610  const path = await read($, trail)
611  const hub = await read($, center)
612  const at = hub === null ? -1 : path.lastIndexOf(hub)
613
614  await update($, isFollowing, () => false)
615  if (at > 0) {
616    // Up one: the old centre stays selected, drawn around the node before it.
617    await update($, trail, () => path.slice(0, at + 1))
618    await update($, center, () => path[at - 1])
619  } else if (path.length > 1) {
620    await update($, trail, () => path.slice(0, -1))
621    await update($, center, () => (path.length > 2 ? path[path.length - 3] : path[0]))
622  }
623}
624
625async function toggleFollow($: EngineInterface): Promise<void> {
626  const was = await read($, isFollowing)
627
628  await update($, isFollowing, () => !was)
629  if (!was) {
630    const last = [...(await read($, feed))].reverse().find(op => op.focus !== null)
631    if (last?.focus && graph.nodes.has(last.focus.center)) {
632      await follow($, last.focus)
633    }
634  }
635}
636
637async function exportPatch($: EngineInterface, id: string): Promise<void> {
638  const node = graph.nodes.get(id)
639
640  if (node === undefined) {
641    return
642  }
643
644  try {
645    const root = await $.session.root()
646    const home = (await $.env.get('HOME')) ?? null
647    const own = await ownDirOf($)
648    const base = adapter.patchDir !== '' ? joinPath(root, adapter.patchDir, home) : own === null ? null : `${own}/patches`
649    if (base === null) {
650      await update($, notice, () => 'no patch folder: set the patchDir option')
651      return
652    }
653    const name = storeNameOf(graph, adapter)
654    const path = `${base}/${slugOf(name, 'store')}/${slugOf(id)}.yaml`
655    await $.fs.write(path, patchOf(node, adapter, name, graph.version, new Date(await $.clock.now()).toISOString()))
656    await update($, notice, () => `wrote ${path}`)
657    $.ui.toast(`patch for ${fit(node.name, 40)} written`)
658  } catch (error) {
659    await update($, notice, () => `export failed: ${messageOf(error)}`)
660  }
661}
662
663/** What the pane's buttons do, each bound to `$` here. */
664function actionsOf($: EngineInterface): Actions {
665  return {
666    tab: next => {
667      update($, tab, () => next).catch(() => undefined)
668    },
669    follow: () => {
670      toggleFollow($).catch(() => undefined)
671    },
672    pick: path => {
673      update($, isFollowing, () => false)
674        .then(() => update($, trail, () => path))
675        .then(() => update($, center, () => (path.length >= 2 ? path[path.length - 2] : (path[0] ?? null))))
676        .catch(() => undefined)
677    },
678    label: label => {
679      update($, isFollowing, () => false)
680        .then(() => update($, browseLabel, () => label))
681        .catch(() => undefined)
682    },
683    back: () => {
684      goBack($).catch(() => undefined)
685    },
686    page: page => {
687      update($, queuePage, () => page).catch(() => undefined)
688    },
689    exportPatch: id => {
690      exportPatch($, id).catch(() => undefined)
691    },
692  }
693}
694
695/** Where a path really lands: every link followed; null when it leads nowhere. */
696async function realPathOf($: EngineInterface, path: string): Promise<string | null> {
697  const stat = await $.fs.stat(path, { resolve: true }).catch(() => null)
698
699  return stat?.realPath ?? null
700}
701
702/** The selection's source lines (sourceKey's path:line under the root), cached per node. */
703async function sourceOf($: EngineInterface, id: string | undefined): Promise<{ path: string; line: number; text: string } | null> {
704  const node = id === undefined ? undefined : graph.nodes.get(id)
705  const ref = node === undefined ? null : sourceRefOf(node, adapter)
706
707  if (ref === null) {
708    return null
709  }
710
711  const key = `${ref.path}:${ref.line}`
712  if (sourceCache.key !== key) {
713    const root = await $.session.root()
714    const path = joinPath(root, ref.path)
715    // The path comes from the store's data: read only what lands under the
716    // session root, spelled (no `..` out) and resolved (no link out).
717    let text: string | null = null
718    if (isUnder(path, root) && path !== root) {
719      const [real, realRoot] = await Promise.all([realPathOf($, path), realPathOf($, root)])
720      if (real !== null && realRoot !== null && isUnder(real, realRoot) && real !== realRoot) {
721        text = await $.fs.read(path).catch(() => null)
722      }
723    }
724    const start = Math.max(0, ref.line - 3)
725    sourceCache = { key, text: text === null ? null : text.split('\n').slice(start, start + 8).join('\n') }
726  }
727
728  return sourceCache.text === null ? null : { ...ref, text: sourceCache.text }
729}
730
731// ------------------------------------------------------------------ hooks
732
733export function register(on: On, options: PluginOptions) {
734  adapter = adapterOf(options)
735  isToastOn = options.toast !== false
736  const isBandOn = options.band !== false
737  // A reload runs register again: start from nothing, as a fresh module does.
738  isLive = false
739  isRegistered = false
740  graph = emptyGraph('')
741  baselines.clear()
742  logCursors.clear()
743  pendingLines.clear()
744  queue = Promise.resolve()
745  sessionId = null
746  rootSeen = ''
747  internalError = null
748  lastToastAt = -Infinity
749  unseen = 0
750  sourceCache = { key: '', text: null }
751
752  on('session.start', async ($, e, next) => {
753    if ((await $.session.surfaces()).length === 0) {
754      return next(e)
755    }
756
757    isLive = true
758    await goLive($)
759
760    return next(e)
761  })
762
763  // A session that started with nothing to draw on goes live when a client attaches.
764  on('session.attach', async ($, e, next) => {
765    if (!isLive) {
766      isLive = true
767      await goLive($)
768    }
769
770    return next(e)
771  })
772
773  // /clear, /resume and /branch reset $.state: copy the feed back from $.store.
774  on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
775    const result = await next(e)
776
777    if (isLive) {
778      const now = await $.clock.now()
779      sessionId = await $.session.id()
780      await update($, since, () => now)
781      await loadFeed($)
782      await reload($)
783    }
784
785    return result
786  }).catch(($, e, next) => next(e))
787
788  // The session root moves on /cd, the worktree tools and a host's directory
789  // change: the pane follows it to that root's store and feed. Claude Code
790  // raises no CwdChanged for /cd, so each of these looks; turn.start catches
791  // whatever moved it otherwise before the turn's first tool call.
792  on('command.run', { command: 'cd' }, async ($, e, next) => {
793    const result = await next(e)
794    await followRoot($)
795
796    return result
797  }).catch(($, e, next) => next(e))
798
799  on('tool.call', { tool: ['EnterWorktree', 'ExitWorktree'] }, async ($, e, next) => {
800    const result = await next(e)
801    await followRoot($)
802
803    return result
804  }).catch(($, e, next) => next(e))
805
806  on('classic.CwdChanged', async ($, e, next) => {
807    const result = await next(e)
808    await followRoot($)
809
810    return result
811  }).catch(($, e, next) => next(e))
812
813  on('turn.start', async ($, e, next) => {
814    await followRoot($)
815
816    return next(e)
817  })
818
819  // Observe only: a Bash command the pattern matches is a graph op. Nothing
820  // but the pattern test runs before next; the store is read after it.
821  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
822    const name = isLive ? opNameOf(e.command, adapter.cmdRegex) : null
823
824    if (name === null) {
825      return next(e)
826    }
827
828    const t0 = await $.clock.now()
829    const result = await next(e)
830    const t1 = await $.clock.now()
831
832    // Claude Code's own check found the command only reads (`cat`, `grep`,
833    // `echo` with the CLI's name in it): not a graph op.
834    if (result.isReadOnly === true) {
835      return result
836    }
837
838    // Refused, or failed to start: it never went to the background, it failed.
839    const isBackground = result.deny === undefined && result.isError !== true && (e.run_in_background === true || isBackgroundResult(result.result))
840    await recordInOrder($, { t0, t1, name, command: e.command, source: 'bash', result, isBackground })
841
842    return result
843  }).catch(($, e, next) => next(e))
844
845  // Observe only: an MCP tool under the configured prefix is a graph op too.
846  on('tool.call', { tool: /^mcp__/ }, async ($, e, next) => {
847    const tool = e.tool as string
848
849    if (!isLive || adapter.mcpPrefix === '' || !tool.startsWith(adapter.mcpPrefix)) {
850      return next(e)
851    }
852
853    const t0 = await $.clock.now()
854    const result = await next(e)
855    const t1 = await $.clock.now()
856
857    await recordInOrder($, { t0, t1, name: tool.slice(adapter.mcpPrefix.length) || tool, command: null, source: 'mcp', result, isBackground: false })
858
859    return result
860  }).catch(($, e, next) => next(e))
861
862  // Answers /omni only once it registered the name in a live session. A skill
863  // or command of the person's called omni (register refused the name), and
864  // `claude -p "/omni"` where nothing draws, get the event passed on: an
865  // answer without next would run no command at all. A gate, so caught.
866  on('command.run', { command: 'omni' }, async ($, e, next) => {
867    if (!isLive || !isRegistered) {
868      return next(e)
869    }
870
871    const arg = e.args.trim().toLowerCase()
872    const wanted = TABS.find(entry => entry.tab === arg)
873
874    if (arg === 'close') {
875      await $.ui.close({ id: PANE })
876      return {}
877    }
878    if (arg === 'clear') {
879      // The pane first: a $.store that cannot take the write loses only the copy.
880      await update($, feed, () => [])
881      try {
882        await $.store.set(feedKey(await $.session.root()), [])
883      } catch (error) {
884        await logError($, 'clearing the feed in $.store', error)
885        return { text: `feed cleared in this pane only: $.store refused the write (${messageOf(error)})` }
886      }
887      return { text: 'feed cleared for this root' }
888    }
889    if (arg === 'reload') {
890      await reload($)
891    } else if (wanted !== undefined) {
892      await update($, tab, () => wanted.tab)
893    } else if (arg !== '' && arg !== 'focus') {
894      return { text: 'usage: /omni [graph|browse|feed|queue|focus|close|clear|reload]' }
895    }
896
897    unseen = 0
898    // Unfocused unless asked: the prompt keeps the keyboard while the pane
899    // follows Claude's work; `/omni focus` hands the pane the keys.
900    const pane = { id: PANE, title: 'omnigraph' }
901    const opened = await $.ui.open(arg === 'focus' ? { ...pane, focus: true } : pane)
902
903    return opened.isPlaced ? {} : { text: `the pane is waiting: ${opened.reason}` }
904  }).catch(($, e, next) => next(e))
905
906  on('ui.render', { component: 'Pane', requestId: 'omnigraph' }, async ($, e) => {
907    const el = $.ui.resolve(e)
908    await read($, graphRev)
909    const columns = Math.max(24, e.props.bodyColumns)
910    const rows = Math.max(12, e.props.scroll.bodyRows)
911    const ops = await read($, feed)
912    const path = await read($, trail)
913    const hub = await read($, center)
914    const following = await read($, isFollowing)
915    const current = await read($, tab)
916    const split = graphTabRows(rows, ops.length)
917    const lastOp = ops[ops.length - 1]
918    const ego = egoOf(graph, hub ?? path[0] ?? null, path[path.length - 1] ?? null, lastOp?.touched ?? [], columns, split.graph, following)
919    lastEgoSize.columns = columns
920    lastEgoSize.rows = split.graph
921    // Terminal and desktop draw a Client; elsewhere the table completes one to
922    // an empty fragment, so the surface decides (the cast only types the table).
923    // Props past the engine's cap would cost the whole pane: text then.
924    const graphClient =
925      current === 'graph' && (e.surface === 'terminal' || e.surface === 'desktop') && propsFit(ego)
926        ? (el as Elements['terminal']).Client({ key: 'graph', module: './graph-view.ts', props: ego, width: columns, height: split.graph })
927        : null
928
929    unseen = 0
930
931    return paneOf(el, {
932      surface: e.surface,
933      columns,
934      rows,
935      info: await read($, info),
936      totals: totalsOf(ops, await read($, since), sessionId),
937      feed: ops,
938      tab: current as Tab,
939      trail: path,
940      isFollowing: following,
941      browseLabel: await read($, browseLabel),
942      queuePage: await read($, queuePage),
943      notice: await read($, notice),
944      graph,
945      a: adapter,
946      graphClient,
947      ego,
948      graphRows: split.graph,
949      source: current === 'browse' ? await sourceOf($, path[path.length - 1]) : null,
950      act: actionsOf($),
951    })
952  })
953
954  // What the graph client posts: an arrow key, a click, Enter, Backspace. The
955  // data comes from code, so it is checked; the answer hands the client its
956  // next props at once, before the pane's own redraw.
957  on('ui.message', { component: 'Pane', element: 'graph' }, async ($, e, next) => {
958    const answer = await next(e)
959    const data = e.data as { kind?: unknown; id?: unknown } | null
960    const kind = typeof data?.kind === 'string' ? data.kind : ''
961    const id = typeof data?.id === 'string' && graph.nodes.has(data.id) ? data.id : null
962
963    if (kind === 'select' && id !== null) {
964      await selectNode($, id)
965    } else if (kind === 'open' && id !== null) {
966      await openNode($, id)
967    } else if (kind === 'back') {
968      await goBack($)
969    } else if (kind === 'follow') {
970      await toggleFollow($)
971    } else if (kind === 'browse') {
972      await update($, tab, () => 'browse')
973      return answer
974    } else if (kind === 'tab') {
975      const wanted = TABS.find(entry => entry.hotkey === (e.data as { tab?: unknown } | null)?.tab)
976      if (wanted !== undefined) {
977        await update($, tab, () => wanted.tab)
978      }
979      return answer
980    } else {
981      return answer
982    }
983
984    const path = await read($, trail)
985    const ops = await read($, feed)
986    const props = egoOf(graph, (await read($, center)) ?? path[0] ?? null, path[path.length - 1] ?? null, ops[ops.length - 1]?.touched ?? [], lastEgoSize.columns, lastEgoSize.rows, await read($, isFollowing))
987
988    // Too big for a Client: the pane's own redraw draws the graph as text.
989    return propsFit(props) ? { ...answer, props } : answer
990  })
991
992  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
993    if (!isLive || !isBandOn || e.props.hasSurvey) {
994      return next(e)
995    }
996
997    const current = await read($, info)
998    const ops = await read($, feed)
999
1000    if (!current?.isPresent && ops.length === 0) {
1001      return next(e)
1002    }
1003
1004    const { Box, Text, Button } = $.ui.resolve(e)
1005    const line = bandText(current, totalsOf(ops, await read($, since), sessionId), ops[ops.length - 1])
1006    // What the mods after this one draw stays, under this line.
1007    const below = await next(e)
1008
1009    return Box({
1010      flexDirection: 'column',
1011      children: [
1012        Box({
1013          flexDirection: 'row',
1014          columnGap: 1,
1015          children: [
1016            Text({ dimColor: true, wrap: 'truncate-end', children: [Text({ color: 'claude', children: ['◉ '] }), fit(line, Math.max(10, e.props.bodyColumns - 9))] }),
1017            Button({
1018              key: 'open',
1019              label: 'open',
1020              plain: true,
1021              dimColor: true,
1022              onPress: () => {
1023                unseen = 0
1024                $.ui.open({ id: PANE, title: 'omnigraph' }).catch(() => undefined)
1025              },
1026            }),
1027          ],
1028        }),
1029        below,
1030      ],
1031    })
1032  })
1033}
1034
hooks/ego.ts 217 lines
1// One node's neighbourhood, picked out of the store for the graph client: its
2// neighbours grouped by edge type and direction, incoming on the left and
3// outgoing on the right (balanced when one side is crowded), each group cut to
4// the rows there are with a `+N more` for the rest. No `$`, no I/O.
5import { colorOf } from './canvas'
6import type { EgoGroup, EgoNode, EgoProps } from './canvas'
7import { byName, compareText, humanType, nameOf } from './graphstore'
8import type { Graph } from './graphstore'
9import { clean, cut } from './text'
10
11type Draft = { type: string; dir: 'out' | 'in'; ids: string[]; side: 'left' | 'right' }
12
13/**
14 * What a name, a label or a group title keeps in the props, in cells: the
15 * drawing shows 32 at most, and the engine refuses a Client whose props
16 * serialize past 100,000 characters (a paragraph for a name would do it).
17 */
18export const PROP_TEXT_MAX = 64
19
20const short = (text: string): string => cut(text, PROP_TEXT_MAX)
21
22/** Past this many serialized characters the pane draws the graph as text (the engine's cap is 100,000). */
23const PROPS_MAX = 90_000
24
25/** Whether the props stay under the engine's cap for a Client, which ids of any length could still break. */
26export function propsFit(props: EgoProps): boolean {
27  return JSON.stringify(props).length <= PROPS_MAX
28}
29
30/** The props the graph client draws for `center`, with `selected` highlighted. */
31export function egoOf(
32  graph: Graph,
33  center: string | null,
34  selected: string | null,
35  touched: readonly string[],
36  columns: number,
37  rows: number,
38  isFollowing: boolean,
39): EgoProps {
40  const base = { columns, rows, isFollowing, legend: [], groups: [], nodes: [] }
41
42  if (!graph.isPresent) {
43    return { ...base, center: '', selected: '', empty: `no store at ${clean(graph.dir)}` }
44  }
45  if (center === null || !graph.nodes.has(center)) {
46    return { ...base, center: '', selected: '', empty: graph.nodes.size === 0 ? 'the store has no nodes yet' : 'pick a node in Browse' }
47  }
48
49  const colorFor = (label: string) => colorOf(Math.max(0, graph.labels.indexOf(label)))
50  const touchedSet = new Set(touched)
51  const drafts = new Map<string, Draft>()
52  const shown = new Set<string>([center])
53
54  for (const edge of graph.out.get(center) ?? []) {
55    addTo(drafts, 'out', edge.type, edge.to, center)
56  }
57  for (const edge of graph.into.get(center) ?? []) {
58    addTo(drafts, 'in', edge.type, edge.from, center)
59  }
60
61  const groups = [...drafts.values()].sort((a, b) => b.ids.length - a.ids.length || compareText(a.type, b.type))
62  const graphRows = rows >= 6 ? rows - 1 : rows
63  const capacity = Math.max(1, graphRows)
64
65  // Incoming left, outgoing right; a group crosses over when its side is full
66  // and the other is not, or when that evens the two out.
67  const load = { left: 0, right: 0 }
68  for (const group of groups) {
69    const natural = group.dir === 'in' ? 'left' : 'right'
70    const other = natural === 'left' ? 'right' : 'left'
71    const size = group.ids.length
72    const crosses =
73      (load[natural] + size > capacity && load[other] + size <= capacity) || load[natural] - load[other] >= size + 2
74    group.side = crosses ? other : natural
75    load[group.side] += size
76  }
77
78  const nodes: EgoNode[] = []
79  // The colour goes by the label as the store spells it; the props carry it cut.
80  const legend = new Map<string, { label: string; color: string }>()
81  const nodeOf = (id: string, side: EgoNode['side'], group: number, isSelected: boolean): EgoNode => {
82    const label = graph.nodes.get(id)?.label ?? '?'
83    const color = colorFor(label)
84    if (!legend.has(label)) {
85      legend.set(label, { label: short(label), color })
86    }
87
88    return { id, name: short(nameOf(graph, id)), label: short(label), color, side, group, isSelected, isTouched: touchedSet.has(id), more: 0 }
89  }
90
91  nodes.push(nodeOf(center, 'center', -1, selected === center || selected === null))
92
93  const outGroups: EgoGroup[] = []
94
95  for (const side of ['left', 'right'] as const) {
96    const mine = groups.filter(group => group.side === side)
97    const slots = allot(
98      mine.map(group => group.ids.length),
99      capacity,
100    )
101
102    mine.forEach((group, i) => {
103      const room = slots[i] ?? 0
104      if (room <= 0) {
105        return
106      }
107      const index = outGroups.length
108      const type = short(group.type)
109      outGroups.push({
110        type,
111        dir: group.dir,
112        count: group.ids.length,
113        side,
114        title: `${group.dir === 'in' ? '← ' : ''}${short(humanType(group.type))}${group.ids.length > 1 ? ` ×${group.ids.length}` : ''}`,
115      })
116      // The selection and the op's nodes stay in view when the group is cut.
117      const fits = group.ids.length <= room
118      const kept = fits ? group.ids : byName(graph, group.ids, id => rank(id, selected, touchedSet)).slice(0, Math.max(0, room - 1))
119      const keep = byName(graph, kept)
120      for (const id of keep) {
121        nodes.push(nodeOf(id, side, index, id === selected))
122      }
123      if (!fits) {
124        nodes.push({
125          id: `more:${group.dir}:${type}`,
126          name: '',
127          label: '',
128          color: '#808080',
129          side,
130          group: index,
131          isSelected: false,
132          isTouched: false,
133          more: group.ids.length - keep.length,
134        })
135      }
136    })
137  }
138
139  return {
140    ...base,
141    center,
142    selected: selected !== null && nodes.some(node => node.id === selected) ? selected : center,
143    nodes,
144    groups: outGroups,
145    legend: [...legend.values()],
146    empty: null,
147  }
148
149  function addTo(map: Map<string, Draft>, dir: 'out' | 'in', type: string, id: string, self: string): void {
150    if (id === self || shown.has(id)) {
151      return
152    }
153    shown.add(id)
154    const key = `${dir}\u0000${type}`
155    const draft = map.get(key)
156    if (draft) {
157      draft.ids.push(id)
158    } else {
159      map.set(key, { type, dir, ids: [id], side: dir === 'in' ? 'left' : 'right' })
160    }
161  }
162}
163
164function rank(id: string, selected: string | null, touched: Set<string>): number {
165  return id === selected ? 0 : touched.has(id) ? 1 : 2
166}
167
168/**
169 * Splits `capacity` rows over groups of these sizes: all of each when they fit,
170 * else a share by size, one row at least while rows last.
171 */
172export function allot(sizes: readonly number[], capacity: number): number[] {
173  const total = sizes.reduce((sum, size) => sum + size, 0)
174
175  if (total <= capacity) {
176    return [...sizes]
177  }
178
179  const slots = sizes.map(() => 0)
180  let left = capacity
181
182  // One row each, biggest groups first, while rows last.
183  sizes
184    .map((size, i) => ({ size, i }))
185    .sort((a, b) => b.size - a.size)
186    .forEach(({ i }) => {
187      if (left > 0) {
188        slots[i] = 1
189        left--
190      }
191    })
192
193  // Then the rest by size, one row at a time to the group shown least.
194  while (left > 0) {
195    let pick = -1
196    let worst = Infinity
197    sizes.forEach((size, i) => {
198      const given = slots[i] ?? 0
199      if (given === 0 || given >= size) {
200        return
201      }
202      const share = given / size
203      if (share < worst) {
204        worst = share
205        pick = i
206      }
207    })
208    if (pick < 0) {
209      break
210    }
211    slots[pick] = (slots[pick] ?? 0) + 1
212    left--
213  }
214
215  return slots
216}
217
hooks/graphstore.ts 914 lines
1// omnigraph's pure half: the store format and the adapter that maps another
2// producer's build onto it, reading a store, diffing two reads, naming the op a
3// command or tool call is, and the words the header, band and feed draw.
4// No `$` and no I/O here: register.ts reads, this file judges.
5import type { OmniInfo, OmniOp, PluginOptions } from 'claude-code'
6
7import { clean, cut, cutStart, hasControl, padCells } from './text'
8
9/** The feed keeps this many ops per root. */
10export const FEED_MAX = 50
11
12/** How many touched ids an op keeps. */
13const TOUCHED_MAX = 12
14
15/** An op that adds or changes more nodes than this is a bulk load: the graph does not jump to it. */
16const BULK_NODES = 5
17
18/** What an op keeps of its name and of its error. */
19const OP_MAX = 80
20const ERROR_MAX = 200
21
22/** An ops-log record counts for a call it started within this much of (the two clocks may differ a little). */
23export const RECORD_SKEW_MS = 1000
24
25// The defaults plugin.json declares, for a run that hands no options (tests).
26// The CLI must be the command: at the start or after a separator, past shell
27// keywords (`do`, `then`, `{`, `!`), wrappers (env, time, timeout, nohup, exec,
28// command, nice, sudo, xargs, with their flags), VAR=value words, `bash -c '`,
29// a folder, or a python interpreter and its flags. No part of it runs on past
30// a separator (`[^\s;&|()]` and `[ \t]`, never `\S` or `\s`), so a long
31// command costs one pass: a `\S*` would rescan the rest after every `;`.
32const DEFAULT_CMD = String.raw`(?:^|[\n;&|(])[ \t]*(?:(?:do|then|else|if|elif|while|until|\{|!)[ \t]+)*(?:(?:timeout|time|env|nohup|exec|command|nice|sudo|xargs)(?:[ \t]+-[^\s;&|()]+(?:[ \t]+[^-\s;&|()][^\s;&|()]*)?)*(?:[ \t]+\d[\w.]*)?[ \t]+|[A-Za-z_]\w*=[^\s;&|()]*[ \t]+|(?:ba|z)?sh[ \t]+-[a-z]*c[ \t]+['"])*(?:(?:[^\s;&|()]*/)?python3?(?:\.\d+)?[ \t]+(?:-[XW][ \t]*[^\s;&|()]+[ \t]+|-\w+[ \t]+)*)?(?:[^\s;&|()]*/)?(?:omnigraph|omni\.py)[ \t]+((?:mutate|query)[ \t]+[\w.-]+|init)\b`
33
34/** How much of a command a pattern reads: its head, and its last lines past that. */
35const MATCH_MAX = 16 * 1024
36
37/** The userConfig options, read once: what the store is called and how its records are spelled. */
38export type Adapter = {
39  store: string
40  storeFlag: string
41  storeLabel: string
42  cmdRegex: RegExp | null
43  mcpPrefix: string
44  manifestFile: string
45  nodesFile: string
46  edgesFile: string
47  opsFile: string
48  versionKey: string
49  idKey: string
50  labelKey: string
51  /** '.' reads a node's properties off the record itself. */
52  propsKey: string
53  fromKey: string
54  typeKey: string
55  toKey: string
56  nameKeys: string[]
57  statusKey: string
58  sourceKey: string
59  conflictsKey: string
60  queueStatus: string[]
61  acceptStatus: string
62  owner: string
63  patchDir: string
64  /** Why an option could not be used (a cmdRegex that does not compile). */
65  problem: string | null
66}
67
68const textOpt = (options: PluginOptions, key: string, fallback: string): string => {
69  const value = options[key]
70
71  return typeof value === 'string' ? value.trim() : fallback
72}
73
74const wordsOf = (text: string): string[] => text.split(/\s+/).filter(word => word !== '')
75
76export function adapterOf(options: PluginOptions): Adapter {
77  const opt = (key: string, fallback: string) => textOpt(options, key, fallback)
78  const [idKey = 'id', labelKey = 'label', propsKey = 'props'] = wordsOf(opt('nodeKeys', 'id label props'))
79  const [fromKey = 'from', typeKey = 'type', toKey = 'to'] = wordsOf(opt('edgeKeys', 'from type to'))
80  const source = opt('cmdRegex', DEFAULT_CMD)
81  let cmdRegex: RegExp | null = null
82  let problem: string | null = null
83
84  try {
85    cmdRegex = source === '' ? null : new RegExp(source)
86  } catch (error) {
87    problem = `cmdRegex does not compile: ${String(error)}`
88  }
89
90  return {
91    store: opt('store', 'graphs/dev.omni') || 'graphs/dev.omni',
92    storeFlag: opt('storeFlag', '--store'),
93    storeLabel: opt('storeLabel', ''),
94    cmdRegex,
95    mcpPrefix: opt('mcpPrefix', ''),
96    manifestFile: opt('manifestFile', 'manifest.json'),
97    nodesFile: opt('nodesFile', 'nodes.jsonl') || 'nodes.jsonl',
98    edgesFile: opt('edgesFile', 'edges.jsonl') || 'edges.jsonl',
99    opsFile: opt('opsFile', 'ops.jsonl'),
100    versionKey: opt('versionKey', 'version') || 'version',
101    idKey,
102    labelKey,
103    propsKey,
104    fromKey,
105    typeKey,
106    toKey,
107    nameKeys: wordsOf(opt('nameKeys', 'name title')),
108    statusKey: opt('statusKey', 'status'),
109    sourceKey: opt('sourceKey', 'source'),
110    conflictsKey: opt('conflictsKey', 'open_conflicts'),
111    queueStatus: wordsOf(opt('queueStatus', 'proposed')),
112    acceptStatus: opt('acceptStatus', 'accepted') || 'accepted',
113    owner: opt('owner', ''),
114    patchDir: opt('patchDir', ''),
115    problem,
116  }
117}
118
119// ------------------------------------------------------------------ the graph
120
121/**
122 * One node. `id` is the record's own (edges and patches name it); `label`,
123 * `name` and `status` are cleaned of control characters for drawing.
124 */
125export type GNode = {
126  id: string
127  label: string
128  name: string
129  status: string | null
130  props: Record<string, unknown>
131  /** The record's line as read: a changed line is a changed node. */
132  line: string
133}
134
135export type GEdge = { from: string; type: string; to: string; key: string }
136
137export type Graph = {
138  dir: string
139  version: string | null
140  manifest: Record<string, unknown> | null
141  nodes: Map<string, GNode>
142  edges: GEdge[]
143  out: Map<string, GEdge[]>
144  into: Map<string, GEdge[]>
145  /** Labels by name, and each label's node ids by node name. */
146  labels: string[]
147  byLabel: Map<string, string[]>
148  /** Lines that were not a record this adapter reads. */
149  bad: number
150  isPresent: boolean
151}
152
153export type StoreTexts = { manifest: string | null; nodes: string | null; edges: string | null }
154
155export const NO_TEXTS: StoreTexts = { manifest: null, nodes: null, edges: null }
156
157const isRecord = (value: unknown): value is Record<string, unknown> =>
158  typeof value === 'object' && value !== null && !Array.isArray(value)
159
160const idOf = (value: unknown): string | null =>
161  typeof value === 'string' && value !== '' ? value : typeof value === 'number' ? String(value) : null
162
163/** A control character or a line separator, or white space at either end: what shownOf has to change. */
164const NEEDS_CLEANING = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]|^\s|\s$/
165
166/** A string field as a drawing shows it: control characters gone, trimmed; null when empty or not a string. */
167const shownOf = (value: unknown): string | null => {
168  if (typeof value !== 'string' || value === '') {
169    return null
170  }
171  // Most fields are clean already: one test before building a new string.
172  if (!NEEDS_CLEANING.test(value)) {
173    return value
174  }
175
176  const text = clean(value).trim()
177
178  return text === '' ? null : text
179}
180
181/** shownOf for values a store repeats (labels, edge types), each worked out once. */
182function shownOnce(cache: Map<string, string | null>, value: unknown): string | null {
183  if (typeof value !== 'string') {
184    return shownOf(value)
185  }
186
187  let shown = cache.get(value)
188  if (shown === undefined) {
189    shown = shownOf(value)
190    cache.set(value, shown)
191  }
192
193  return shown
194}
195
196/** A file's text without the byte-order mark an editor may have put first. */
197const unmarked = (text: string | null): string | null => (text !== null && text.charCodeAt(0) === 0xfeff ? text.slice(1) : text)
198
199/** FNV-1a over the text, 8 hex digits: a version for a store whose manifest names none. */
200export function hashOf(text: string): string {
201  let hash = 0x811c9dc5
202
203  for (let i = 0; i < text.length; i++) {
204    hash ^= text.charCodeAt(i)
205    hash = Math.imul(hash, 0x01000193) >>> 0
206  }
207
208  return hash.toString(16).padStart(8, '0')
209}
210
211/** Case-blind order with code points breaking ties: cheaper than localeCompare on a big label. */
212export function compareText(x: string, y: string): number {
213  const a = x.toLowerCase()
214  const b = y.toLowerCase()
215
216  return a < b ? -1 : a > b ? 1 : x < y ? -1 : x > y ? 1 : 0
217}
218
219export function emptyGraph(dir: string): Graph {
220  return parseGraph(dir, NO_TEXTS, adapterOf({}))
221}
222
223const push = <K, V>(map: Map<K, V[]>, key: K, value: V) => {
224  const list = map.get(key)
225
226  if (list) {
227    list.push(value)
228  } else {
229    map.set(key, [value])
230  }
231}
232
233export function parseGraph(dir: string, raw: StoreTexts, a: Adapter): Graph {
234  const texts = { manifest: unmarked(raw.manifest), nodes: unmarked(raw.nodes), edges: unmarked(raw.edges) }
235  let manifest: Record<string, unknown> | null = null
236  let bad = 0
237
238  if (texts.manifest !== null) {
239    try {
240      const parsed: unknown = JSON.parse(texts.manifest)
241      manifest = isRecord(parsed) ? parsed : null
242    } catch {
243      manifest = null
244    }
245    if (manifest === null) {
246      bad++
247    }
248  }
249
250  const nodes = new Map<string, GNode>()
251  const repeated = new Map<string, string | null>()
252
253  for (const line of (texts.nodes ?? '').split('\n')) {
254    if (line.trim() === '') {
255      continue
256    }
257    try {
258      const record: unknown = JSON.parse(line)
259      const id = isRecord(record) ? idOf(record[a.idKey]) : null
260      if (!isRecord(record) || id === null) {
261        bad++
262        continue
263      }
264      const props = a.propsKey === '.' ? record : isRecord(record[a.propsKey]) ? (record[a.propsKey] as Record<string, unknown>) : {}
265      let name: string | null = null
266      for (let k = 0; k < a.nameKeys.length && name === null; k++) {
267        name = shownOf(props[a.nameKeys[k] ?? ''])
268      }
269      const status = a.statusKey === '' ? null : shownOf(props[a.statusKey])
270      nodes.set(id, { id, label: shownOnce(repeated, record[a.labelKey]) ?? '?', name: name ?? shownOf(id) ?? '?', status, props, line })
271    } catch {
272      bad++
273    }
274  }
275
276  const edges: GEdge[] = []
277  const out = new Map<string, GEdge[]>()
278  const into = new Map<string, GEdge[]>()
279  const seen = new Set<string>()
280
281  for (const line of (texts.edges ?? '').split('\n')) {
282    if (line.trim() === '') {
283      continue
284    }
285    try {
286      const record: unknown = JSON.parse(line)
287      const from = isRecord(record) ? idOf(record[a.fromKey]) : null
288      const to = isRecord(record) ? idOf(record[a.toKey]) : null
289      const stated = isRecord(record) ? idOf(record[a.typeKey]) : null
290      if (from === null || to === null || stated === null) {
291        bad++
292        continue
293      }
294      const type = shownOnce(repeated, stated) ?? '?'
295      const key = `${from}\u0000${type}\u0000${to}`
296      if (seen.has(key)) {
297        continue
298      }
299      seen.add(key)
300      const edge = { from, type, to, key }
301      edges.push(edge)
302      push(out, from, edge)
303      push(into, to, edge)
304    } catch {
305      bad++
306    }
307  }
308
309  // Each label's ids by name, case-blind: the sort keys made once, not per comparison.
310  const keyed = new Map<string, { id: string; key: string; name: string }[]>()
311
312  for (const node of nodes.values()) {
313    push(keyed, node.label, { id: node.id, key: node.name.toLowerCase(), name: node.name })
314  }
315
316  const byLabel = new Map<string, string[]>()
317  for (const [label, entries] of keyed) {
318    entries.sort((x, y) => (x.key < y.key ? -1 : x.key > y.key ? 1 : x.name < y.name ? -1 : x.name > y.name ? 1 : x.id < y.id ? -1 : x.id > y.id ? 1 : 0))
319    byLabel.set(
320      label,
321      entries.map(entry => entry.id),
322    )
323  }
324
325  const stated = manifest?.[a.versionKey]
326  const version =
327    typeof stated === 'string' || typeof stated === 'number'
328      ? shownOf(String(stated))
329      : texts.nodes !== null || texts.edges !== null
330        ? hashOf(`${texts.nodes ?? ''}\u0000${texts.edges ?? ''}`)
331        : null
332
333  return {
334    dir,
335    version,
336    manifest,
337    nodes,
338    edges,
339    out,
340    into,
341    // Code-point order, not the locale's: a label's colour is its place here.
342    labels: [...byLabel.keys()].sort((x, y) => (x < y ? -1 : x > y ? 1 : 0)),
343    byLabel,
344    bad,
345    isPresent: texts.manifest !== null || texts.nodes !== null,
346  }
347}
348
349export function nameOf(graph: Graph, id: string): string {
350  return graph.nodes.get(id)?.name ?? clean(id)
351}
352
353/**
354 * `ids` in case-blind name order (code points break ties), after `first` when
355 * given: the keys made once, not per comparison, as a hub can have thousands.
356 */
357export function byName(graph: Graph, ids: Iterable<string>, first: (id: string) => number = () => 0): string[] {
358  const keyed: { id: string; rank: number; key: string; name: string }[] = []
359
360  for (const id of ids) {
361    const name = nameOf(graph, id)
362    keyed.push({ id, rank: first(id), key: name.toLowerCase(), name })
363  }
364  keyed.sort((x, y) => x.rank - y.rank || (x.key < y.key ? -1 : x.key > y.key ? 1 : x.name < y.name ? -1 : x.name > y.name ? 1 : 0))
365
366  return keyed.map(entry => entry.id)
367}
368
369export function degreeOf(graph: Graph, id: string): number {
370  return (graph.out.get(id)?.length ?? 0) + (graph.into.get(id)?.length ?? 0)
371}
372
373// ------------------------------------------------------------------- the diff
374
375export type GraphDiff = {
376  addedNodes: string[]
377  removedNodes: string[]
378  changedNodes: string[]
379  addedEdges: GEdge[]
380  removedEdges: GEdge[]
381}
382
383export function diffGraphs(before: Graph, after: Graph): GraphDiff {
384  const addedNodes: string[] = []
385  const changedNodes: string[] = []
386
387  for (const [id, node] of after.nodes) {
388    const was = before.nodes.get(id)
389    if (was === undefined) {
390      addedNodes.push(id)
391    } else if (was.line !== node.line) {
392      changedNodes.push(id)
393    }
394  }
395
396  const removedNodes = [...before.nodes.keys()].filter(id => !after.nodes.has(id))
397  const beforeKeys = new Set(before.edges.map(edge => edge.key))
398  const afterKeys = new Set(after.edges.map(edge => edge.key))
399
400  return {
401    addedNodes,
402    removedNodes,
403    changedNodes,
404    addedEdges: after.edges.filter(edge => !beforeKeys.has(edge.key)),
405    removedEdges: before.edges.filter(edge => !afterKeys.has(edge.key)),
406  }
407}
408
409export function touchedOf(diff: GraphDiff): string[] {
410  const ids = new Set<string>([...diff.addedNodes, ...diff.changedNodes])
411
412  for (const edge of [...diff.addedEdges, ...diff.removedEdges]) {
413    ids.add(edge.from)
414    ids.add(edge.to)
415  }
416
417  return [...ids].slice(0, TOUCHED_MAX)
418}
419
420/**
421 * Where the graph goes after an op: the first new (else changed) node in file
422 * order, drawn around its busiest neighbour; else the first new edge, drawn
423 * around its busier end with the other one highlighted (an epic and the issue
424 * just added to it).
425 */
426export function focusOf(diff: GraphDiff, graph: Graph): OmniOp['focus'] {
427  // A bulk load or rebuild (a seed, a compile that rewrote many records) is not one place to go.
428  if (diff.addedNodes.length + diff.changedNodes.length > BULK_NODES) {
429    return null
430  }
431
432  const isThere = (id: string) => graph.nodes.has(id)
433  const fresh = diff.addedNodes.find(isThere) ?? diff.changedNodes.find(isThere)
434
435  if (fresh !== undefined) {
436    const around = [...(graph.out.get(fresh) ?? []).map(edge => edge.to), ...(graph.into.get(fresh) ?? []).map(edge => edge.from)]
437      .filter(id => id !== fresh && isThere(id))
438      .sort((x, y) => degreeOf(graph, y) - degreeOf(graph, x))
439
440    return { center: around[0] ?? fresh, selected: fresh }
441  }
442
443  const edge = [...diff.addedEdges, ...diff.removedEdges].find(e => isThere(e.from) && isThere(e.to) && e.from !== e.to)
444
445  if (edge === undefined) {
446    return null
447  }
448
449  return degreeOf(graph, edge.from) >= degreeOf(graph, edge.to)
450    ? { center: edge.from, selected: edge.to }
451    : { center: edge.to, selected: edge.from }
452}
453
454// ------------------------------------------------------------ naming the ops
455
456const squeeze = (text: string, max = 60): string => clean(text).replace(/\s+/g, ' ').trim().slice(0, max)
457
458/** `text` cut to `max` characters, back to a space, tab or newline so no word is cut in two. */
459function headOf(text: string, max: number): string {
460  for (let i = max; i > 0; i--) {
461    const c = text.charCodeAt(i)
462    if (c === 32 || c === 9 || c === 10) {
463      return text.slice(0, i)
464    }
465  }
466
467  return text.slice(0, max)
468}
469
470/**
471 * The op a Bash command is: the first capture group that matched, else the
472 * match; null when it is not one. The pattern reads at most MATCH_MAX of a
473 * long command's head and as much of its last lines (a heredoc, then the
474 * graph command): a pattern that backtracks costs a bounded time, not one
475 * that grows with the square of the command.
476 */
477export function opNameOf(command: string, pattern: RegExp | null): string | null {
478  if (pattern === null) {
479    return null
480  }
481
482  let match: RegExpExecArray | null
483
484  if (command.length <= MATCH_MAX) {
485    match = pattern.exec(command)
486  } else {
487    match = pattern.exec(headOf(command, MATCH_MAX))
488    // The tail starts at a newline, so a pattern anchored at `^` does not take a cut line for a command.
489    const tail = command.slice(-MATCH_MAX)
490    const at = tail.indexOf('\n')
491    if (match === null && at >= 0) {
492      match = pattern.exec(tail.slice(at))
493    }
494  }
495
496  if (match === null) {
497    return null
498  }
499
500  for (const group of match.slice(1)) {
501    if (group) {
502      return squeeze(group) || 'op'
503    }
504  }
505
506  return squeeze(match[0]) || 'op'
507}
508
509const unquote = (word: string): string =>
510  (word.startsWith('"') && word.endsWith('"')) || (word.startsWith("'") && word.endsWith("'")) ? word.slice(1, -1) : word
511
512const escapeRe = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
513
514/** A path the mod cannot spell out itself: a variable, a command substitution, a glob, another user's home. */
515const isUnresolvable = (path: string): boolean => /[$`*?]/.test(path) || /^~[^/]/.test(path)
516
517/** The value of `--store dir` or `--store=dir` in a command, unquoted; null when absent. */
518export function storeFlagOf(command: string, flag: string): string | null {
519  if (flag === '') {
520    return null
521  }
522
523  const match = new RegExp(`(?:^|\\s)${escapeRe(flag)}(?:=|\\s+)("[^"]*"|'[^']*'|[^\\s;&|)]+)`).exec(command)
524
525  return match?.[1] === undefined ? null : unquote(match[1])
526}
527
528/** The folder of a leading `cd <dir> &&` (or `;`, or inside a `(`), unquoted; null when the command does not start with one. */
529export function cdPrefixOf(command: string): string | null {
530  // No two white-space runs side by side: a command that opens with a long one stays one pass.
531  const match = /^\s*(?:\(\s*)?cd\s+("[^"]*"|'[^']*'|[^\s;&|()]+)\s*(?:&&|;)/.exec(command)
532
533  return match?.[1] === undefined ? null : unquote(match[1])
534}
535
536/** `rel` under `base`, `.` and `..` folded; an absolute `rel` (or `~/...` with a home) stands alone. */
537export function joinPath(base: string, rel: string, home: string | null = null): string {
538  const expanded = home !== null && (rel === '~' || rel.startsWith('~/')) ? home + rel.slice(1) : rel
539  const isAbsolute = expanded.startsWith('/') || /^[A-Za-z]:[\\/]/.test(expanded)
540  const full = isAbsolute ? expanded : `${base}/${expanded}`
541  const kept: string[] = []
542
543  for (const part of full.split(/[\\/]+/)) {
544    if (part === '' || part === '.') {
545      continue
546    }
547    if (part === '..') {
548      if (kept.length > 0 && !/^[A-Za-z]:$/.test(kept[kept.length - 1] ?? '')) {
549        kept.pop()
550      }
551      continue
552    }
553    kept.push(part)
554  }
555
556  const joined = kept.join('/')
557
558  return full.startsWith('/') ? `/${joined}` : joined
559}
560
561/** True when `path` is `dir` or under it. */
562export function isUnder(path: string, dir: string): boolean {
563  return path === dir || path.startsWith(dir.endsWith('/') ? dir : `${dir}/`)
564}
565
566/**
567 * Where a command's store is: its store flag under its cwd (and leading cd),
568 * else the configured store under the root; null when the flag or the cd
569 * names a path only the shell can spell (`"$STORE"`, a glob).
570 */
571export function storeDirOf(root: string, cwd: string, command: string | null, a: Adapter, home: string | null): string | null {
572  const flagged = command === null ? null : storeFlagOf(command, a.storeFlag)
573
574  if (flagged === null) {
575    return joinPath(root, a.store, home)
576  }
577
578  const cd = command === null ? null : cdPrefixOf(command)
579
580  if (isUnresolvable(flagged) || (cd !== null && isUnresolvable(cd))) {
581    return null
582  }
583
584  return joinPath(cd === null ? cwd : joinPath(cwd, cd, home), flagged, home)
585}
586
587export function baseName(path: string): string {
588  const parts = path.split(/[\\/]+/).filter(part => part !== '')
589
590  return parts[parts.length - 1] ?? path
591}
592
593/** The store's name: the storeLabel option, the manifest's name, or the folder's name. */
594export function storeNameOf(graph: Graph, a: Adapter): string {
595  return clean(a.storeLabel) || shownOf(graph.manifest?.name) || clean(baseName(graph.dir))
596}
597
598/** A producer's record of one op, and its place in the lines it was picked from. */
599export type OpRecord = { op: string | null; ok: boolean | null; ms: number | null; error: string | null; index: number }
600
601/**
602 * The producer's own record of a call, from the ops-log lines written since
603 * the mod last read the log (and those no op took yet): the newest one that
604 * started while the call ran (`from`..`to`, give or take RECORD_SKEW_MS) and,
605 * for a command, whose op the command names. Null when none is: the call wrote
606 * no record (it failed before logging, or ran something else), and an older
607 * record never stands in.
608 */
609export function opRecordOf(lines: readonly string[], from: number, to: number, command: string | null): OpRecord | null {
610  const words = command === null ? null : new Set(command.split(/[^\w.-]+/))
611
612  for (let i = lines.length - 1; i >= 0; i--) {
613    let record: unknown
614    try {
615      record = JSON.parse(lines[i] ?? '')
616    } catch {
617      continue
618    }
619    if (!isRecord(record) || typeof record.t !== 'number' || record.t < from - RECORD_SKEW_MS || record.t > to + RECORD_SKEW_MS) {
620      continue
621    }
622    const op = typeof record.op === 'string' ? record.op : null
623    if (words !== null && (op === null || !words.has(op))) {
624      continue
625    }
626    const kind = typeof record.kind === 'string' ? record.kind : null
627    const named = op === null ? null : kind === 'mutate' || kind === 'query' ? `${kind} ${op}` : op
628
629    return {
630      op: named === null ? null : squeeze(named, OP_MAX) || null,
631      ok: typeof record.ok === 'boolean' ? record.ok : null,
632      ms: typeof record.ms === 'number' && record.ms >= 0 ? Math.round(record.ms) : null,
633      error: typeof record.error === 'string' ? squeeze(record.error, ERROR_MAX) || null : null,
634      index: i,
635    }
636  }
637
638  return null
639}
640
641/** The first line of a failed tool's error text, kept short. */
642export function errorLineOf(text: string | undefined): string | null {
643  const line = (text ?? '').split('\n').find(part => part.trim() !== '' && !/^error: exit code/i.test(part.trim()))
644
645  return line === undefined ? null : squeeze(line, 120) || null
646}
647
648/** Whether a Bash call went to the background (asked, on a timeout, or Ctrl+B): its result names the task. */
649export function isBackgroundResult(result: unknown): boolean {
650  return isRecord(result) && typeof result.backgroundTaskId === 'string' && result.backgroundTaskId !== ''
651}
652
653// --------------------------------------------------------------- the feed
654
655export function pushRing<T>(list: readonly T[], item: T, max = FEED_MAX): T[] {
656  return [...list, item].slice(-max)
657}
658
659const numberOr = <T>(value: unknown, fallback: T): number | T => (typeof value === 'number' && Number.isFinite(value) ? value : fallback)
660
661/** One stored op made safe to draw (another session or an older build may have written it); null when it is not one. */
662export function opOf(value: unknown): OmniOp | null {
663  if (!isRecord(value) || typeof value.t !== 'number' || typeof value.op !== 'string' || typeof value.ok !== 'boolean') {
664    return null
665  }
666
667  const focus = isRecord(value.focus) ? value.focus : null
668  const op: OmniOp = {
669    t: value.t,
670    op: squeeze(value.op, OP_MAX) || 'op',
671    source: value.source === 'mcp' ? 'mcp' : 'bash',
672    store: typeof value.store === 'string' ? clean(value.store) : null,
673    ok: value.ok,
674    ms: Math.max(0, numberOr(value.ms, 0)),
675    dNodes: numberOr(value.dNodes, null),
676    dEdges: numberOr(value.dEdges, null),
677    touched: Array.isArray(value.touched) ? value.touched.filter((id): id is string => typeof id === 'string').slice(0, TOUCHED_MAX) : [],
678    focus:
679      focus !== null && typeof focus.center === 'string' && typeof focus.selected === 'string' ? { center: focus.center, selected: focus.selected } : null,
680    error: typeof value.error === 'string' ? squeeze(value.error, ERROR_MAX) : null,
681    version: typeof value.version === 'string' ? clean(value.version) : null,
682  }
683
684  if (typeof value.session === 'string') {
685    op.session = value.session
686  }
687  if (value.isBackground === true) {
688    op.isBackground = true
689  }
690  if (typeof value.changed === 'number' && value.changed > 0) {
691    op.changed = value.changed
692  }
693
694  return op
695}
696
697export function feedOf(value: unknown): OmniOp[] {
698  return Array.isArray(value) ? value.map(opOf).filter((op): op is OmniOp => op !== null).slice(-FEED_MAX) : []
699}
700
701export type Totals = { ops: number; dNodes: number; dEdges: number; errors: number }
702
703/** The ops since `since` that `session` recorded (all of them when it is null): the feed holds every session's on the root. */
704export function totalsOf(feed: readonly OmniOp[], since: number, session: string | null = null): Totals {
705  const totals = { ops: 0, dNodes: 0, dEdges: 0, errors: 0 }
706
707  for (const op of feed) {
708    if (op.t < since || (session !== null && op.session !== session)) {
709      continue
710    }
711    totals.ops++
712    totals.dNodes += op.dNodes ?? 0
713    totals.dEdges += op.dEdges ?? 0
714    totals.errors += op.ok ? 0 : 1
715  }
716
717  return totals
718}
719
720// ---------------------------------------------------------------- the words
721
722export const plural = (n: number, word: string): string => `${n} ${word}${n === 1 ? '' : 's'}`
723
724/** `+3n`, `-2e`, `+0n`; a dot when there was no store to diff. */
725export function deltaOf(n: number | null, unit: string): string {
726  if (n === null) {
727    return `·${unit}`
728  }
729
730  return `${n < 0 ? '-' : '+'}${Math.abs(n)}${unit}`
731}
732
733export function msOf(ms: number): string {
734  if (ms < 1000) {
735    return `${Math.round(ms)}ms`
736  }
737  if (ms < 60_000) {
738    return `${(ms / 1000).toFixed(1)}s`
739  }
740
741  return `${Math.round(ms / 60_000)}m`
742}
743
744const pad2 = (n: number): string => String(n).padStart(2, '0')
745
746/** HH:MM:SS, local time. */
747export function clockOf(ms: number): string {
748  const d = new Date(ms)
749
750  return `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`
751}
752
753/** An edge type as the views say it: `owned_by` and `OWNED_BY` both read `owned by`. */
754export function humanType(type: string): string {
755  const spaced = clean(type).replace(/_+/g, ' ').trim()
756
757  return spaced === spaced.toUpperCase() ? spaced.toLowerCase() : spaced
758}
759
760/** `text` cut to `width` cells with an ellipsis (a wide character takes two). */
761export const fit = cut
762
763/** `text` cut from the start, keeping its end: `…c › Offline editing`. */
764export const fitStart = cutStart
765
766export const padEnd = padCells
767
768/** The header's right half: `8 ops · +3n +2e · 0 ×`. */
769export function totalsLine(totals: Totals): string {
770  return `${plural(totals.ops, 'op')} · ${deltaOf(totals.dNodes, 'n')} ${deltaOf(totals.dEdges, 'e')} · ${totals.errors} ×`
771}
772
773/** One feed row: `16:13:53 ▸ mutate create_issue  +1n +0e  652ms ✓`; `~1n` when it changed nodes, `bg …` when it went to the background. */
774export function feedRow(op: OmniOp, opWidth: number): { time: string; op: string; delta: string; ms: string; mark: string } {
775  return {
776    time: clockOf(op.t),
777    op: padEnd(fit(op.op, opWidth), opWidth),
778    delta: `${deltaOf(op.dNodes, 'n')} ${deltaOf(op.dEdges, 'e')}${op.changed ? ` ~${op.changed}n` : ''}`,
779    ms: (op.isBackground ? 'bg' : msOf(op.ms)).padStart(6),
780    mark: op.isBackground ? '…' : op.ok ? '✓' : '✗',
781  }
782}
783
784// ---------------------------------------------------------------- the store
785
786export function queueOf(graph: Graph, a: Adapter): string[] {
787  if (a.queueStatus.length === 0) {
788    return []
789  }
790
791  const wanted = new Set(a.queueStatus)
792  const ids: string[] = []
793
794  for (const label of graph.labels) {
795    for (const id of graph.byLabel.get(label) ?? []) {
796      const status = graph.nodes.get(id)?.status
797      if (status !== null && status !== undefined && wanted.has(status)) {
798        ids.push(id)
799      }
800    }
801  }
802
803  return ids
804}
805
806export function conflictsOf(graph: Graph, a: Adapter): string[] {
807  const listed = a.conflictsKey === '' ? undefined : graph.manifest?.[a.conflictsKey]
808
809  return Array.isArray(listed) ? listed.filter((id): id is string => typeof id === 'string') : []
810}
811
812export function infoOf(graph: Graph, a: Adapter, branch: string | null, problems: readonly (string | null)[]): OmniInfo {
813  const notes = [...problems, a.problem, graph.bad > 0 ? `${plural(graph.bad, 'line')} not read` : null].filter(
814    (note): note is string => note !== null && note !== '',
815  )
816
817  return {
818    dir: graph.dir,
819    name: storeNameOf(graph, a),
820    branch,
821    version: graph.version,
822    nodes: graph.nodes.size,
823    edges: graph.edges.length,
824    labels: graph.labels.length,
825    isPresent: graph.isPresent,
826    queued: queueOf(graph, a).length,
827    conflicts: conflictsOf(graph, a).length,
828    problem: notes.length > 0 ? clean(notes.join('; ')) : null,
829  }
830}
831
832/** `ref: refs/heads/main` reads `main`; a detached HEAD its short hash. */
833export function branchOfHead(text: string): string | null {
834  const head = text.trim()
835  const ref = /^ref:\s*refs\/heads\/(.+)$/.exec(head)
836
837  if (ref?.[1] !== undefined) {
838    return clean(ref[1])
839  }
840
841  return /^[0-9a-f]{7,64}$/i.test(head) ? head.slice(0, 7) : null
842}
843
844/** The folder a worktree's `.git` file points at (`gitdir: <path>`). */
845export function gitDirOf(text: string): string | null {
846  const match = /^gitdir:\s*(.+)$/m.exec(text)
847
848  return match?.[1] === undefined ? null : match[1].trim()
849}
850
851/** A node's first `path:line` source, as the sourceKey property holds it (a string or a list). */
852export function sourceRefOf(node: GNode, a: Adapter): { path: string; line: number } | null {
853  if (a.sourceKey === '') {
854    return null
855  }
856
857  const value = node.props[a.sourceKey]
858  const first = Array.isArray(value) ? value.find(item => typeof item === 'string') : value
859
860  if (typeof first !== 'string' || hasControl(first)) {
861    return null
862  }
863
864  const match = /^(.+?):(\d+)(?:-\d+)?$/.exec(first.trim())
865
866  return match?.[1] !== undefined ? { path: match[1], line: Number(match[2]) } : { path: first.trim(), line: 1 }
867}
868
869/**
870 * A file name for an id: what is safe in a path, the rest dashed, never `.`,
871 * `..` or a hidden name; with a hash of the id when it had to change, so `a:b`
872 * and `a-b` do not overwrite one another.
873 */
874export function slugOf(id: string, fallback = 'node'): string {
875  const safe = id.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^[-.]+|-+$/g, '').slice(0, 120)
876
877  return safe === id ? safe : `${(safe || fallback).slice(0, 111)}-${hashOf(id)}`
878}
879
880/**
881 * A YAML double-quoted string: JSON's escapes, and `\u` escapes for what JSON
882 * leaves raw but YAML may not hold raw (DEL and C1, U+FFFE and U+FFFF) or
883 * reads as a line break inside the quotes (NEL, U+2028, U+2029), so the value
884 * reads back as the record holds it.
885 */
886const yamlQuoted = (text: string): string =>
887  JSON.stringify(text).replace(/[\u007f-\u009f\u2028\u2029\ufffe\uffff]/g, ch => `\\u${ch.charCodeAt(0).toString(16).padStart(4, '0')}`)
888
889/** A YAML mapping key: as it is when plain, quoted otherwise. */
890const yamlKey = (key: string): string => (/^[A-Za-z_][A-Za-z0-9_-]*$/.test(key) ? key : yamlQuoted(key))
891
892/** A YAML comment line: no line break of any kind (clean takes U+2028 and U+2029 too), nothing a reader refuses. */
893const yamlComment = (text: string): string => clean(text).replace(/[\ufffe\uffff]/g, ' ')
894
895/**
896 * The overlay a Queue export writes: the record's id, the accepted status and
897 * the owner, as YAML. Values are quoted and escaped, and the comment lines
898 * hold no line break, so a name or a label cannot add records of its own.
899 */
900export function patchOf(node: GNode, a: Adapter, storeName: string, version: string | null, at: string): string {
901  const lines = [
902    yamlComment(`# omnigraph patch for ${storeName}${version === null ? '' : ` ${version}`}, exported ${at}`),
903    yamlComment(`# was: ${node.status ?? 'no status'} (${node.label}) ${JSON.stringify(node.name)}`),
904    `- id: ${yamlQuoted(node.id)}`,
905    `  ${yamlKey(a.statusKey || 'status')}: ${yamlQuoted(a.acceptStatus)}`,
906  ]
907
908  if (a.owner !== '') {
909    lines.push(`  owner: ${yamlQuoted(a.owner)}`)
910  }
911
912  return `${lines.join('\n')}\n`
913}
914
hooks/text.ts 181 lines
1// Text as the terminal draws it: the control characters a drawing may not
2// hold, how many cells a string takes (a CJK character or an emoji two, a
3// combining mark none) and cutting to a width. No `$`, no I/O: the hooks module
4// and the graph client both import it.
5
6/**
7 * C0 and C1 controls and DEL, tab and newline included, and the Unicode line
8 * and paragraph separators: the engine refuses a tree that holds most of
9 * them, a newline breaks a row, and YAML 1.1 (PyYAML) reads U+2028 and U+2029
10 * as line breaks too.
11 */
12const CONTROL = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g
13
14/** The same, but for tab and newline: what a block of source lines may keep. */
15const CONTROL_IN_LINES = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f\u2028\u2029]+/g
16
17/** Whether `text` holds a character `clean` would change. */
18export const hasControl = (text: string): boolean => /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/.test(text)
19
20/** `text` with each run of control characters as one space: what a one-line label may hold. */
21export function clean(text: string): string {
22  return text.replace(CONTROL, ' ')
23}
24
25/** `text` with its line breaks made `\n` and every other control character but tab a space. */
26export function cleanLines(text: string): string {
27  return text.replace(/\r\n?/g, '\n').replace(CONTROL_IN_LINES, ' ')
28}
29
30// East Asian Wide and Fullwidth, and the emoji drawn two cells wide: the common
31// ranges, not the whole Unicode table.
32const WIDE: readonly (readonly [number, number])[] = [
33  [0x1100, 0x115f],
34  [0x231a, 0x231b],
35  [0x2329, 0x232a],
36  [0x23e9, 0x23ec],
37  [0x23f0, 0x23f3],
38  [0x25fd, 0x25fe],
39  [0x2614, 0x2615],
40  [0x2648, 0x2653],
41  [0x267f, 0x267f],
42  [0x2693, 0x2693],
43  [0x26a1, 0x26a1],
44  [0x26aa, 0x26ab],
45  [0x26bd, 0x26be],
46  [0x26c4, 0x26c5],
47  [0x26ce, 0x26ce],
48  [0x26d4, 0x26d4],
49  [0x26ea, 0x26ea],
50  [0x26f2, 0x26f5],
51  [0x26fa, 0x26fd],
52  [0x2705, 0x2705],
53  [0x270a, 0x270b],
54  [0x2728, 0x2728],
55  [0x274c, 0x274e],
56  [0x2753, 0x2757],
57  [0x2795, 0x2797],
58  [0x27b0, 0x27bf],
59  [0x2b1b, 0x2b1c],
60  [0x2b50, 0x2b55],
61  [0x2e80, 0x303e],
62  [0x3041, 0x33ff],
63  [0x3400, 0x4dbf],
64  [0x4e00, 0x9fff],
65  [0xa000, 0xa4cf],
66  [0xa960, 0xa97f],
67  [0xac00, 0xd7a3],
68  [0xf900, 0xfaff],
69  [0xfe10, 0xfe19],
70  [0xfe30, 0xfe6f],
71  [0xff00, 0xff60],
72  [0xffe0, 0xffe6],
73  [0x16fe0, 0x16fe4],
74  [0x17000, 0x18cff],
75  [0x1b000, 0x1b2ff],
76  [0x1f004, 0x1f004],
77  [0x1f0cf, 0x1f0cf],
78  [0x1f18e, 0x1f18e],
79  [0x1f191, 0x1f19a],
80  [0x1f200, 0x1f251],
81  [0x1f300, 0x1f64f],
82  [0x1f680, 0x1f6ff],
83  [0x1f7e0, 0x1f7eb],
84  [0x1f90c, 0x1f9ff],
85  [0x1fa70, 0x1faff],
86  [0x20000, 0x3fffd],
87]
88
89const isZeroWidth = (cp: number): boolean =>
90  (cp >= 0x300 && cp <= 0x36f) ||
91  (cp >= 0x1ab0 && cp <= 0x1aff) ||
92  (cp >= 0x1dc0 && cp <= 0x1dff) ||
93  (cp >= 0x200b && cp <= 0x200f) ||
94  (cp >= 0x20d0 && cp <= 0x20ff) ||
95  (cp >= 0xfe00 && cp <= 0xfe0f) ||
96  (cp >= 0xe0100 && cp <= 0xe01ef)
97
98/** The cells one code point takes: 0, 1 or 2. */
99export function charWidth(cp: number): number {
100  if (cp < 0x300) {
101    return 1
102  }
103  if (isZeroWidth(cp)) {
104    return 0
105  }
106
107  return WIDE.some(([lo, hi]) => cp >= lo && cp <= hi) ? 2 : 1
108}
109
110const widthOfChar = (ch: string): number => charWidth(ch.codePointAt(0) ?? 0)
111
112/** The cells `text` takes on one row. */
113export function widthOf(text: string): number {
114  // Printable ASCII, the usual case, is one cell a character.
115  if (/^[\x20-\x7e]*$/.test(text)) {
116    return text.length
117  }
118
119  let width = 0
120  for (const ch of text) {
121    width += widthOfChar(ch)
122  }
123
124  return width
125}
126
127/** `text` cut to `width` cells, an ellipsis last when it was cut. */
128export function cut(text: string, width: number): string {
129  if (width <= 0) {
130    return ''
131  }
132  if (widthOf(text) <= width) {
133    return text
134  }
135
136  let kept = ''
137  let used = 0
138  for (const ch of text) {
139    const w = widthOfChar(ch)
140    if (used + w > width - 1) {
141      break
142    }
143    kept += ch
144    used += w
145  }
146
147  return `${kept}…`
148}
149
150/** `text` cut from the start to `width` cells, keeping its end: `…c › Offline editing`. */
151export function cutStart(text: string, width: number): string {
152  if (width <= 0) {
153    return ''
154  }
155  if (widthOf(text) <= width) {
156    return text
157  }
158
159  const chars = [...text]
160  let kept = ''
161  let used = 0
162  for (let i = chars.length - 1; i >= 0; i--) {
163    const ch = chars[i] ?? ''
164    const w = widthOfChar(ch)
165    if (used + w > width - 1) {
166      break
167    }
168    kept = ch + kept
169    used += w
170  }
171
172  return `…${kept}`
173}
174
175/** `text` padded with spaces to `width` cells. */
176export function padCells(text: string, width: number): string {
177  const length = widthOf(text)
178
179  return length >= width ? text : text + ' '.repeat(width - length)
180}
181
hooks/views.ts 569 lines
1// The pane's and the band's trees: header, tabs, breadcrumb, the Miller-column
2// browser, the op feed, the review queue and a text graph for the surfaces
3// with no Client. Built from the element table `$.ui.resolve(e)` handed out and
4// a model of plain data and callbacks; no `$` here (register.ts binds it).
5import type { Elements, OmniInfo, OmniOp, RenderElement, RenderSurface } from 'claude-code'
6
7import type { EgoProps } from './canvas'
8import {
9  byName,
10  conflictsOf,
11  deltaOf,
12  feedRow,
13  fit,
14  fitStart,
15  humanType,
16  msOf,
17  nameOf,
18  padEnd,
19  plural,
20  queueOf,
21  totalsLine,
22} from './graphstore'
23import type { Adapter, Graph, Totals } from './graphstore'
24import { clean, cleanLines, widthOf } from './text'
25
26export type Tab = 'graph' | 'browse' | 'feed' | 'queue'
27
28export const TABS: readonly { tab: Tab; label: string; hotkey: string }[] = [
29  { tab: 'graph', label: 'Graph', hotkey: '1' },
30  { tab: 'browse', label: 'Browse', hotkey: '2' },
31  { tab: 'feed', label: 'Feed', hotkey: '3' },
32  { tab: 'queue', label: 'Queue', hotkey: '4' },
33]
34
35type Table = Elements[RenderSurface]
36
37/** What a press asks for; register.ts binds each to `$`. */
38export type Actions = {
39  tab: (tab: Tab) => void
40  follow: () => void
41  /** A new browse path, its last node the selection. */
42  pick: (trail: string[]) => void
43  label: (label: string) => void
44  back: () => void
45  page: (page: number) => void
46  exportPatch: (id: string) => void
47}
48
49export type PaneModel = {
50  surface: RenderSurface
51  columns: number
52  rows: number
53  info: OmniInfo | null
54  totals: Totals
55  feed: readonly OmniOp[]
56  tab: Tab
57  trail: readonly string[]
58  isFollowing: boolean
59  browseLabel: string | null
60  queuePage: number
61  notice: string | null
62  graph: Graph
63  a: Adapter
64  /** The graph client, built where its module path is read (register.ts); null where the surface has none. */
65  graphClient: RenderElement | null
66  ego: EgoProps
67  /** The rows the graph region takes. */
68  graphRows: number
69  /** The selection's source lines, when its node names a readable path:line. */
70  source: { path: string; line: number; text: string } | null
71  act: Actions
72}
73
74const clamp = (n: number, lo: number, hi: number): number => Math.max(lo, Math.min(hi, n))
75
76/** How the Graph tab splits its rows: the graph, the Miller columns, the feed strip. */
77export function graphTabRows(rows: number, feedLength: number): { graph: number; miller: number; feed: number } {
78  const feed = clamp(feedLength, 1, 4)
79  const left = Math.max(6, rows - 3 - feed - 1)
80  const miller = left >= 22 ? clamp(Math.floor(left * 0.38), 7, 12) : 0
81  const graph = clamp(left - miller - (miller > 0 ? 1 : 0), 6, 30)
82
83  return { graph, miller, feed }
84}
85
86// ------------------------------------------------------------------ the pane
87
88export function paneOf(el: Table, m: PaneModel): RenderElement {
89  const { Box } = el
90  const body: RenderElement[] = [headerOf(el, m), tabsOf(el, m), crumbOf(el, m)]
91
92  if (m.info?.problem) {
93    body.push(el.Text({ color: 'warning', wrap: 'truncate-end', children: [fit(m.info.problem, m.columns)] }))
94  }
95
96  if (m.tab === 'graph') {
97    const split = graphTabRows(m.rows, m.feed.length)
98    body.push(graphOf(el, m))
99    if (split.miller > 0) {
100      body.push(ruleOf(el, m.columns, 'browse'))
101      body.push(millerOf(el, m, split.miller))
102    }
103    body.push(ruleOf(el, m.columns, 'feed'))
104    body.push(...feedRowsOf(el, m, split.feed, false))
105  } else if (m.tab === 'browse') {
106    const detail = detailOf(el, m)
107    body.push(browseBarOf(el, m))
108    body.push(millerOf(el, m, clamp(m.rows - 4 - detail.rows, 6, 40)))
109    body.push(...detail.elements)
110  } else if (m.tab === 'feed') {
111    body.push(...feedRowsOf(el, m, Math.max(3, m.rows - 3), true))
112  } else {
113    body.push(...queueTabOf(el, m))
114  }
115
116  return Box({ flexDirection: 'column', children: body })
117}
118
119function headerOf(el: Table, m: PaneModel): RenderElement {
120  const { Box, Text } = el
121  const where = [m.info?.name ?? 'no store', m.info?.branch].filter((part): part is string => !!part).join(' · ')
122  const right = totalsLine(m.totals)
123  const leftWidth = Math.max(10, m.columns - widthOf(right) - 2)
124
125  return Box({
126    flexDirection: 'row',
127    justifyContent: 'space-between',
128    children: [
129      Text({
130        wrap: 'truncate-end',
131        children: [Text({ color: 'claude', children: ['◉ '] }), Text({ bold: true, children: ['omnigraph'] }), fit(` · ${where}`, leftWidth - 11)],
132      }),
133      Text({
134        children: [
135          `${plural(m.totals.ops, 'op')} · ${deltaOf(m.totals.dNodes, 'n')} ${deltaOf(m.totals.dEdges, 'e')} · `,
136          Text({ ...(m.totals.errors > 0 ? { color: 'error' } : { dimColor: true }), children: [`${m.totals.errors} ×`] }),
137        ],
138      }),
139    ],
140  })
141}
142
143function tabsOf(el: Table, m: PaneModel): RenderElement {
144  const { Box, Button, Text } = el
145  const hint =
146    m.tab === 'graph'
147      ? 'click the graph to browse with ↑↓←→'
148      : m.tab === 'browse'
149        ? 'Enter opens · h goes back'
150        : m.tab === 'feed'
151          ? `${plural(m.feed.length, 'op')} kept`
152          : 'export writes a patch; nothing is applied'
153
154  return Box({
155    flexDirection: 'row',
156    columnGap: 2,
157    children: [
158      ...TABS.map(({ tab, label, hotkey }) =>
159        Button({ key: `tab-${tab}`, label, hotkey, plain: true, dimColor: m.tab !== tab, onPress: () => m.act.tab(tab) }),
160      ),
161      Button({
162        key: 'follow',
163        label: m.isFollowing ? 'following ops' : 'follow ops',
164        hotkey: 'f',
165        plain: true,
166        dimColor: !m.isFollowing,
167        onPress: () => m.act.follow(),
168      }),
169      Text({ dimColor: true, wrap: 'truncate-end', children: [hint] }),
170    ],
171  })
172}
173
174/** The browse path as one line: `Offline editing & sync › contains › Conflict toast hides unsynced edits`. */
175export function crumbText(graph: Graph, trail: readonly string[]): string {
176  const parts: string[] = []
177
178  trail.forEach((id, i) => {
179    const before = trail[i - 1]
180    if (before !== undefined) {
181      parts.push(relationWord(graph, before, id))
182    }
183    parts.push(nameOf(graph, id))
184  })
185
186  return parts.join(' › ')
187}
188
189function relationWord(graph: Graph, from: string, to: string): string {
190  const out = (graph.out.get(from) ?? []).find(edge => edge.to === to)
191
192  if (out) {
193    return humanType(out.type)
194  }
195
196  const back = (graph.into.get(from) ?? []).find(edge => edge.from === to)
197
198  return back ? `← ${humanType(back.type)}` : '·'
199}
200
201function crumbOf(el: Table, m: PaneModel): RenderElement {
202  const { Text } = el
203  const mark = m.isFollowing ? ' ● live' : ' ○ browsing'
204  const text = m.trail.length > 0 ? crumbText(m.graph, m.trail) : m.info?.isPresent ? 'nothing selected yet' : 'waiting for a store'
205
206  return Text({
207    wrap: 'truncate-end',
208    children: [
209      fitStart(text, Math.max(8, m.columns - widthOf(mark))),
210      Text({ ...(m.isFollowing ? { color: 'success' } : { dimColor: true }), children: [mark] }),
211    ],
212  })
213}
214
215function ruleOf(el: Table, columns: number, title: string): RenderElement {
216  return el.Text({ dimColor: true, wrap: 'truncate-end', children: [`── ${title} ${'─'.repeat(Math.max(0, columns - title.length - 4))}`] })
217}
218
219// ----------------------------------------------------------------- the graph
220
221function graphOf(el: Table, m: PaneModel): RenderElement {
222  if (m.graphClient !== null) {
223    return m.graphClient
224  }
225
226  return el.Box({ flexDirection: 'column', children: graphTextOf(m.ego, m.columns, m.graphRows).map(line => el.Text({ wrap: 'truncate-end', children: [line] })) })
227}
228
229/** The graph as lines of text, for a surface with no Client: the centre, then a line per group. */
230export function graphTextOf(ego: EgoProps, columns: number, rows: number): string[] {
231  if (ego.empty !== null) {
232    return [fit(ego.empty, columns)]
233  }
234
235  const center = ego.nodes.find(node => node.side === 'center')
236  const lines = [fit(`◉ ${center?.name ?? ego.center}`, columns)]
237
238  ego.groups.forEach((group, index) => {
239    const members = ego.nodes.filter(node => node.group === index)
240    const names = members.map(node => (node.more > 0 ? `+${node.more} more` : `${node.isSelected ? '▶ ' : ''}${node.name}`))
241    lines.push(fit(`  ${group.dir === 'in' ? '←' : '→'} ${group.title.replace(/^← /, '')}: ${names.join(', ')}`, columns))
242  })
243
244  return lines.slice(0, Math.max(1, rows))
245}
246
247// ------------------------------------------------------------ the browser
248
249type Line =
250  | { kind: 'head'; text: string }
251  | { kind: 'item'; key: string; id: string; name: string; isOnTrail: boolean; onPress: () => void }
252
253type Column = { title: string; lines: Line[] }
254
255/** The browser's columns: one label's nodes, then each trail node's relations. */
256export function columnsOf(m: Pick<PaneModel, 'graph' | 'trail' | 'browseLabel' | 'act'>): Column[] {
257  const { graph, trail } = m
258  const first = trail[0]
259  const label = m.browseLabel ?? (first !== undefined ? graph.nodes.get(first)?.label : undefined) ?? graph.labels[0]
260
261  if (label === undefined) {
262    return []
263  }
264
265  const ids = graph.byLabel.get(label) ?? []
266  const columns: Column[] = [
267    {
268      title: `${label} ${ids.length}`,
269      lines: ids.map(id => ({
270        kind: 'item',
271        key: `m0:${id}`,
272        id,
273        name: nameOf(graph, id),
274        isOnTrail: id === first,
275        onPress: () => m.act.pick([id]),
276      })),
277    },
278  ]
279
280  // A first column of another label than the trail's starts a new trail.
281  const isOwnTrail = first !== undefined && graph.nodes.get(first)?.label === label
282
283  if (!isOwnTrail) {
284    return columns
285  }
286
287  trail.forEach((id, k) => {
288    const next = trail[k + 1]
289    const groups = new Map<string, { head: string; ids: Set<string> }>()
290
291    for (const edge of graph.out.get(id) ?? []) {
292      const key = `→\u0000${edge.type}\u0000${graph.nodes.get(edge.to)?.label ?? '?'}`
293      addLine(groups, key, `→ ${humanType(edge.type)} · ${graph.nodes.get(edge.to)?.label ?? '?'}`, edge.to)
294    }
295    for (const edge of graph.into.get(id) ?? []) {
296      const key = `←\u0000${edge.type}\u0000${graph.nodes.get(edge.from)?.label ?? '?'}`
297      addLine(groups, key, `← ${humanType(edge.type)} · ${graph.nodes.get(edge.from)?.label ?? '?'}`, edge.from)
298    }
299
300    const lines: Line[] = []
301    let g = 0
302    for (const group of groups.values()) {
303      lines.push({ kind: 'head', text: `${group.head} ${group.ids.size}` })
304      for (const target of byName(graph, group.ids)) {
305        lines.push({
306          kind: 'item',
307          key: `m${k + 1}:${g}:${target}`,
308          id: target,
309          name: nameOf(graph, target),
310          isOnTrail: target === next,
311          onPress: () => m.act.pick([...trail.slice(0, k + 1), target]),
312        })
313      }
314      g++
315    }
316    if (lines.length === 0) {
317      lines.push({ kind: 'head', text: 'no relations' })
318    }
319    columns.push({ title: nameOf(graph, id), lines })
320  })
321
322  return columns
323}
324
325/** Adds `id` to its group once: a Set, as a hub's group can hold thousands. */
326function addLine(groups: Map<string, { head: string; ids: Set<string> }>, key: string, head: string, id: string): void {
327  const group = groups.get(key)
328
329  if (group) {
330    group.ids.add(id)
331  } else {
332    groups.set(key, { head, ids: new Set([id]) })
333  }
334}
335
336/** The lines of a column that fit in `rows`, kept around the trail's line, with `↑ n more` / `↓ n more`. */
337export function windowOf(lines: readonly Line[], rows: number): { lines: Line[]; above: number; below: number } {
338  if (lines.length <= rows) {
339    return { lines: [...lines], above: 0, below: 0 }
340  }
341
342  const room = Math.max(1, rows - 2)
343  const on = lines.findIndex(line => line.kind === 'item' && line.isOnTrail)
344  const start = clamp((on < 0 ? 0 : on) - Math.floor(room / 2), 0, lines.length - room)
345
346  return { lines: lines.slice(start, start + room), above: start, below: lines.length - start - room }
347}
348
349function millerOf(el: Table, m: PaneModel, rows: number): RenderElement {
350  const { Box, Button, Text } = el
351  const all = columnsOf(m)
352
353  if (all.length === 0) {
354    return Text({ dimColor: true, children: [m.info?.isPresent ? 'the store has no nodes' : 'no store to browse'] })
355  }
356
357  const count = clamp(Math.floor((m.columns + 1) / 24), 1, 4)
358  const shown = all.slice(-count)
359  const width = Math.max(10, Math.floor((m.columns - (shown.length - 1)) / shown.length))
360  const itemRows = Math.max(1, rows - 1)
361
362  return Box({
363    flexDirection: 'row',
364    columnGap: 1,
365    children: shown.map((column, c) => {
366      const view = windowOf(column.lines, itemRows)
367      const children: RenderElement[] = [Text({ bold: true, wrap: 'truncate-end', children: [fit(column.title, width)] })]
368
369      if (view.above > 0) {
370        children.push(Text({ dimColor: true, children: [`↑ ${view.above} more`] }))
371      }
372      for (const line of view.lines) {
373        if (line.kind === 'head') {
374          children.push(Text({ dimColor: true, wrap: 'truncate-end', children: [fit(line.text, width)] }))
375        } else {
376          children.push(
377            Button({
378              key: line.key,
379              label: fit(`${line.isOnTrail ? '▶ ' : '  '}${line.name}`, width),
380              plain: true,
381              dimColor: !line.isOnTrail && c < shown.length - 1,
382              onPress: line.onPress,
383            }),
384          )
385        }
386      }
387      if (view.below > 0) {
388        children.push(Text({ dimColor: true, children: [`↓ ${view.below} more`] }))
389      }
390
391      return Box({ key: `col-${c}`, flexDirection: 'column', width, children })
392    }),
393  })
394}
395
396function browseBarOf(el: Table, m: PaneModel): RenderElement {
397  const { Box, Button, Text } = el
398  const first = m.trail[0]
399  const current = m.browseLabel ?? (first !== undefined ? m.graph.nodes.get(first)?.label : undefined) ?? m.graph.labels[0] ?? ''
400  const children: RenderElement[] = []
401
402  // Mobile has no Select (its table completes one to an empty fragment): the
403  // surface decides, and the cast only types the table for it.
404  if (m.surface !== 'mobile' && m.graph.labels.length > 0) {
405    children.push(
406      (el as Elements['terminal']).Select({
407        key: 'label',
408        label: 'type',
409        value: current,
410        options: m.graph.labels.map(label => ({ value: label, label: `${label} ${m.graph.byLabel.get(label)?.length ?? 0}` })),
411        onSelect: value => m.act.label(value),
412      }),
413    )
414  } else {
415    children.push(Text({ dimColor: true, children: [`type ${current}`] }))
416  }
417  children.push(Button({ key: 'back', label: 'back', hotkey: 'h', plain: true, dimColor: true, onPress: () => m.act.back() }))
418
419  return Box({ flexDirection: 'row', columnGap: 2, children })
420}
421
422function detailOf(el: Table, m: PaneModel): { elements: RenderElement[]; rows: number } {
423  const { Text } = el
424  const id = m.trail[m.trail.length - 1]
425  const node = id === undefined ? undefined : m.graph.nodes.get(id)
426
427  if (id === undefined || node === undefined) {
428    return { elements: [], rows: 0 }
429  }
430
431  const skip = new Set([...m.a.nameKeys, m.a.statusKey, m.a.sourceKey])
432  const props = Object.entries(node.props)
433    .filter(([key]) => !skip.has(key))
434    .slice(0, 6)
435    .map(([key, value]) => fit(clean(`${key}: ${typeof value === 'string' ? value : JSON.stringify(value)}`), m.columns))
436  const elements: RenderElement[] = [
437    ruleOf(el, m.columns, 'detail'),
438    Text({ bold: true, wrap: 'truncate-end', children: [fit(node.name, m.columns)] }),
439    Text({ dimColor: true, wrap: 'truncate-end', children: [fit(clean(`${node.id} · ${node.label}${node.status ? ` · ${node.status}` : ''}`), m.columns)] }),
440    ...props.map(line => Text({ wrap: 'truncate-end', children: [line] })),
441  ]
442  let rows = 3 + props.length
443
444  if (m.source !== null) {
445    elements.push(Text({ dimColor: true, wrap: 'truncate-end', children: [fit(clean(`${m.source.path}:${m.source.line}`), m.columns)] }))
446    elements.push(el.Code({ source: cleanLines(m.source.text), path: m.source.path, startLine: Math.max(1, m.source.line - 2), wrap: 'truncate-end' }))
447    rows += 1 + m.source.text.split('\n').length
448  }
449
450  return { elements, rows }
451}
452
453// ------------------------------------------------------------------ the feed
454
455function feedRowsOf(el: Table, m: PaneModel, rows: number, isFull: boolean): RenderElement[] {
456  const { Text } = el
457
458  if (m.feed.length === 0) {
459    return [Text({ dimColor: true, wrap: 'truncate-end', children: [fit('no ops yet: the graph commands Claude runs show up here', m.columns)] })]
460  }
461
462  const lines: RenderElement[] = []
463  const recent = m.feed.slice(-Math.max(1, rows))
464  // As wide as the longest op shown, so the deltas line up close to it, and
465  // the deltas as wide as the widest (`~1n` adds to some).
466  const opWidth = clamp(Math.max(...recent.map(op => widthOf(op.op))), 12, Math.max(12, m.columns - 30))
467  const shown = recent.map(op => feedRow(op, opWidth))
468  const deltaWidth = Math.max(...shown.map(row => widthOf(row.delta)))
469
470  for (const [i, op] of recent.entries()) {
471    const row = shown[i] ?? feedRow(op, opWidth)
472    lines.push(
473      Text({
474        wrap: 'truncate-end',
475        children: [
476          Text({ dimColor: true, children: [row.time] }),
477          Text({ color: 'claude', children: [' ▸ '] }),
478          row.op,
479          `  ${padEnd(row.delta, deltaWidth)}  `,
480          Text({ dimColor: true, children: [row.ms] }),
481          ' ',
482          Text({ ...(op.isBackground ? { dimColor: true } : { color: op.ok ? 'success' : 'error' }), children: [row.mark] }),
483        ],
484      }),
485    )
486    if (isFull && !op.ok && op.error) {
487      lines.push(Text({ color: 'error', dimColor: true, wrap: 'truncate-end', children: [fit(`         ${op.error}`, m.columns)] }))
488    }
489  }
490
491  return isFull ? lines.slice(-Math.max(1, rows)) : lines
492}
493
494// ----------------------------------------------------------------- the queue
495
496/** How many queued nodes one Queue page lists. */
497export function queuePageSize(rows: number, conflicts: number): number {
498  return Math.max(3, rows - 6 - (conflicts > 0 ? Math.min(conflicts, 5) + 1 : 0))
499}
500
501function queueTabOf(el: Table, m: PaneModel): RenderElement[] {
502  const { Box, Button, Text } = el
503  const queued = queueOf(m.graph, m.a)
504  const conflicts = conflictsOf(m.graph, m.a)
505  const size = queuePageSize(m.rows, conflicts.length)
506  const pages = Math.max(1, Math.ceil(queued.length / size))
507  const page = clamp(m.queuePage, 0, pages - 1)
508  const lines: RenderElement[] = []
509
510  if (m.notice !== null) {
511    lines.push(Text({ color: 'success', wrap: 'truncate-end', children: [fit(clean(m.notice), m.columns)] }))
512  }
513  if (conflicts.length > 0) {
514    lines.push(Text({ color: 'warning', wrap: 'truncate-end', children: [fit(`${plural(conflicts.length, 'open conflict')}: settle these before accepting what they name`, m.columns)] }))
515    for (const id of conflicts.slice(0, 5)) {
516      lines.push(Text({ wrap: 'truncate-end', children: [fit(clean(`  ! ${nameOf(m.graph, id)}  ${id}`), m.columns)] }))
517    }
518  }
519  if (queued.length === 0) {
520    lines.push(Text({ dimColor: true, children: [`no node is ${m.a.queueStatus.join(' or ') || 'queued'}`] }))
521
522    return lines
523  }
524
525  lines.push(Text({ dimColor: true, wrap: 'truncate-end', children: [fit(`${queued.length} ${m.a.queueStatus.join('/')} · export writes one patch file each (status: ${m.a.acceptStatus})`, m.columns)] }))
526  for (const id of queued.slice(page * size, page * size + size)) {
527    const node = m.graph.nodes.get(id)
528    lines.push(
529      Box({
530        flexDirection: 'row',
531        columnGap: 1,
532        children: [
533          Button({ key: `x:${id}`, label: 'export', onPress: () => m.act.exportPatch(id) }),
534          Text({ wrap: 'truncate-end', children: [Text({ dimColor: true, children: [`${node?.label ?? '?'} · `] }), fit(node?.name ?? clean(id), Math.max(8, m.columns - 24))] }),
535        ],
536      }),
537    )
538  }
539  lines.push(
540    Box({
541      flexDirection: 'row',
542      columnGap: 2,
543      children: [
544        Button({ key: 'prev', label: 'prev', hotkey: 'p', plain: true, dimColor: page === 0, onPress: () => m.act.page(Math.max(0, page - 1)) }),
545        Button({ key: 'next', label: 'next', hotkey: 'n', plain: true, dimColor: page >= pages - 1, onPress: () => m.act.page(Math.min(pages - 1, page + 1)) }),
546        Text({ dimColor: true, children: [`page ${page + 1}/${pages}`] }),
547      ],
548    }),
549  )
550
551  return lines
552}
553
554// ------------------------------------------------------------------ the band
555
556/** The band's one line: `◉ omnigraph · dev.omni · main · 8 ops · +3n +2e · 0 × · last mutate create_issue ✓ 652ms`. */
557export function bandText(info: OmniInfo | null, totals: Totals, last: OmniOp | undefined): string {
558  const parts = [
559    'omnigraph',
560    info?.name,
561    info?.branch,
562    info?.isPresent ? `${info.nodes}n ${info.edges}e` : undefined,
563    totalsLine(totals),
564    last ? `last ${last.op} ${last.isBackground ? '… bg' : `${last.ok ? '✓' : '✗'} ${msOf(last.ms)}`}` : undefined,
565  ]
566
567  return parts.filter((part): part is string => !!part).join(' · ')
568}
569
hooks/graph-view.ts 73 lines
1// The graph region of the pane: a Client surface module. It runs on the
2// drawing thread with no `$`: it lays the props out (canvas.ts), draws the
3// rows, and posts what the person did (an arrow key, a click, Enter) to the
4// hooks module, whose ui.message hook moves the selection and answers with the
5// next props.
6import type { ClientModule, RenderElement } from 'claude-code'
7
8import { hitTest, layoutEgo, stepFrom } from './canvas'
9import type { EgoProps, Run, Step } from './canvas'
10
11const STEPS: readonly string[] = ['up', 'down', 'left', 'right']
12const TAB_KEYS: readonly string[] = ['1', '2', '3', '4']
13
14const GraphView: ClientModule<EgoProps> = (props, surface) => {
15  const { Box, Text } = surface.elements
16  // 0 before the first layout: draw for the size the hooks module asked for.
17  const columns = surface.columns > 0 ? surface.columns : props.columns
18  const rows = surface.rows > 0 ? surface.rows : props.rows
19  const layout = layoutEgo(props, columns, rows)
20
21  surface.onKey(event => {
22    if (STEPS.includes(event.key)) {
23      const next = stepFrom(layout, props.selected, event.key as Step)
24      if (next !== null && next !== props.selected) {
25        surface.post({ kind: 'select', id: next })
26      }
27    } else if (event.key === 'return' || event.key === 'enter') {
28      surface.post({ kind: 'open', id: props.selected })
29    } else if (event.key === 'backspace' || event.key === 'h') {
30      surface.post({ kind: 'back' })
31    } else if (event.key === 'f') {
32      surface.post({ kind: 'follow' })
33    } else if (TAB_KEYS.includes(event.key)) {
34      // The client holds the keys after a click: the pane's tab digits still switch tabs.
35      surface.post({ kind: 'tab', tab: event.key })
36    }
37  })
38
39  surface.onPointer(event => {
40    if (event.type !== 'down') {
41      return
42    }
43    const spot = hitTest(layout, event.x, event.y)
44    if (spot === null) {
45      return
46    }
47    if (spot.isMore) {
48      surface.post({ kind: 'browse', id: props.center })
49    } else {
50      surface.post({ kind: spot.id === props.selected ? 'open' : 'select', id: spot.id })
51    }
52  })
53
54  const runOf = (run: Run): RenderElement | string =>
55    run.style === null
56      ? run.text
57      : Text({
58          ...(run.style.color !== null && { color: run.style.color }),
59          ...(run.style.bold && { bold: true }),
60          ...(run.style.dim && { dimColor: true }),
61          ...(run.style.inverse && { inverse: true }),
62          ...(run.style.italic && { italic: true }),
63          children: [run.text],
64        })
65
66  return Box({
67    flexDirection: 'column',
68    children: layout.rows.map(row => Text({ wrap: 'truncate-end', children: row.map(runOf) })),
69  })
70}
71
72export default GraphView
73
hooks/canvas.ts 395 lines
1// The node-link drawing: one node's neighbourhood laid out on a character grid
2// (the node in the middle, its neighbours on two arcs, grouped by edge type),
3// and the arrow-key and pointer rules that move the selection over it. Plain
4// data in, plain data out: graph-view.ts draws it, the tests read it. Widths
5// are terminal cells: a CJK character or an emoji takes two.
6import { charWidth, cut, widthOf } from './text'
7
8/** Colours for node labels, by the label's place in the store's sorted label list. */
9export const PALETTE = [
10  '#5fafff',
11  '#ffaf5f',
12  '#87d787',
13  '#d787d7',
14  '#ffd75f',
15  '#5fd7d7',
16  '#ff8787',
17  '#afaf5f',
18  '#87afff',
19  '#d7af87',
20  '#af87ff',
21  '#87d7af',
22] as const
23
24export function colorOf(index: number): string {
25  return PALETTE[((index % PALETTE.length) + PALETTE.length) % PALETTE.length] ?? PALETTE[0]
26}
27
28/** One node drawn: the centre, a neighbour, or `+N more` for the rest of its group. */
29export type EgoNode = {
30  id: string
31  name: string
32  label: string
33  color: string
34  side: 'center' | 'left' | 'right'
35  /** Index into EgoProps.groups; -1 for the centre. */
36  group: number
37  isSelected: boolean
38  isTouched: boolean
39  /** Above 0: this is the `+N more` stand-in, `id` the group's key. */
40  more: number
41}
42
43/** The neighbours one edge type and direction leads to. */
44export type EgoGroup = { type: string; dir: 'out' | 'in'; count: number; side: 'left' | 'right'; title: string }
45
46/** What the graph client draws: plain data, the Client's props. */
47export type EgoProps = {
48  center: string
49  selected: string
50  nodes: EgoNode[]
51  groups: EgoGroup[]
52  legend: { label: string; color: string }[]
53  /** The size the hooks module laid it out for, until the client knows its own. */
54  columns: number
55  rows: number
56  isFollowing: boolean
57  /** Drawn instead of a graph: no store, no node. */
58  empty: string | null
59}
60
61export type Style = { color: string | null; bold: boolean; dim: boolean; inverse: boolean; italic: boolean }
62export type Run = { text: string; style: Style | null }
63
64/** Where a node landed: its glyph and the cells of its label, for hits and arrow keys. */
65export type Spot = { id: string; x: number; y: number; x0: number; x1: number; isMore: boolean }
66
67export type Layout = { rows: Run[][]; spots: Spot[] }
68
69/** One cell; the right half of a wide character is a tail with no text of its own. */
70type Cell = { ch: string; style: Style | null; isLabel: boolean; isTail: boolean }
71
72const blank = (): Cell => ({ ch: ' ', style: null, isLabel: false, isTail: false })
73
74const PLAIN: Style = { color: null, bold: false, dim: false, inverse: false, italic: false }
75const DOT: Style = { ...PLAIN, dim: true }
76const EDGE_LABEL: Style = { ...PLAIN, dim: true, italic: true }
77
78const clamp = (n: number, lo: number, hi: number): number => Math.max(lo, Math.min(hi, n))
79
80class Grid {
81  readonly cells: Cell[][]
82
83  constructor(
84    readonly columns: number,
85    readonly rows: number,
86  ) {
87    this.cells = Array.from({ length: rows }, () => Array.from({ length: columns }, blank))
88  }
89
90  /** Puts one character at x, two cells when it is wide; the other half of a wide one it lands on goes blank. */
91  put(x: number, y: number, ch: string, style: Style | null, isLabel: boolean, isWide = false): void {
92    const row = this.cells[y]
93    if (row === undefined || x < 0 || x + (isWide ? 1 : 0) >= this.columns) {
94      return
95    }
96    this.free(row, x)
97    row[x] = { ch, style, isLabel, isTail: false }
98    if (isWide) {
99      this.free(row, x + 1)
100      row[x + 1] = { ch: '', style, isLabel, isTail: true }
101    }
102  }
103
104  private free(row: Cell[], x: number): void {
105    if (row[x]?.isTail && x > 0) {
106      row[x - 1] = blank()
107    } else if (row[x + 1]?.isTail) {
108      row[x + 1] = blank()
109    }
110  }
111
112  /** Writes `text` from x; false (and nothing written) when a label is in the way. */
113  write(x: number, y: number, text: string, style: Style | null, isLabel: boolean, force = false): boolean {
114    if (!force && !this.isFree(x, y, widthOf(text))) {
115      return false
116    }
117
118    let at = x
119    for (const ch of text) {
120      const w = charWidth(ch.codePointAt(0) ?? 0)
121      const before = this.cells[y]?.[at - 1]
122      if (w === 0) {
123        // A combining mark joins the character before it.
124        if (at > x && before !== undefined) {
125          before.ch += ch
126        }
127        continue
128      }
129      if (at + w > this.columns) {
130        break
131      }
132      this.put(at, y, ch, style, isLabel, w === 2)
133      at += w
134    }
135
136    return true
137  }
138
139  isFree(x: number, y: number, width: number): boolean {
140    const row = this.cells[y]
141    if (row === undefined || x < 0 || x + width > this.columns) {
142      return false
143    }
144
145    return row.slice(x, x + width).every(cell => !cell.isLabel)
146  }
147
148  /** A dotted line between two cells, under whatever labels are there. */
149  line(x0: number, y0: number, x1: number, y1: number): void {
150    const dx = Math.abs(x1 - x0)
151    const dy = -Math.abs(y1 - y0)
152    const sx = x0 < x1 ? 1 : -1
153    const sy = y0 < y1 ? 1 : -1
154    let err = dx + dy
155    let x = x0
156    let y = y0
157
158    for (let guard = 0; guard < 2000; guard++) {
159      const cell = this.cells[y]?.[x]
160      if (cell !== undefined && !cell.isLabel && cell.ch === ' ') {
161        this.put(x, y, '·', DOT, false)
162      }
163      if (x === x1 && y === y1) {
164        return
165      }
166      const e2 = 2 * err
167      if (e2 >= dy) {
168        err += dy
169        x += sx
170      }
171      if (e2 <= dx) {
172        err += dx
173        y += sy
174      }
175    }
176  }
177
178  runs(): Run[][] {
179    return this.cells.map(row => {
180      const runs: Run[] = []
181
182      for (const cell of row) {
183        const last = runs[runs.length - 1]
184        if (last !== undefined && sameStyle(last.style, cell.style)) {
185          last.text += cell.ch
186        } else {
187          runs.push({ text: cell.ch, style: cell.style })
188        }
189      }
190
191      return runs
192    })
193  }
194}
195
196function sameStyle(a: Style | null, b: Style | null): boolean {
197  if (a === null || b === null) {
198    return a === b
199  }
200
201  return a.color === b.color && a.bold === b.bold && a.dim === b.dim && a.inverse === b.inverse && a.italic === b.italic
202}
203
204/** The text a node's label shows. */
205export function labelText(node: EgoNode, width: number): string {
206  return node.more > 0 ? `+${node.more} more` : cut(node.name, width)
207}
208
209/** Lays the props out on a grid of `columns` x `rows` cells. */
210export function layoutEgo(props: EgoProps, columns: number, rows: number): Layout {
211  const width = clamp(Math.floor(columns), 1, 400)
212  const height = clamp(Math.floor(rows), 1, 200)
213  const grid = new Grid(width, height)
214  const spots: Spot[] = []
215
216  if (props.empty !== null || props.nodes.length === 0) {
217    grid.write(0, 0, cut(props.empty ?? 'nothing to draw', width), { ...PLAIN, dim: true }, true, true)
218
219    return { rows: grid.runs(), spots }
220  }
221
222  const hasLegend = height >= 6 && props.legend.length > 0
223  const graphRows = hasLegend ? height - 1 : height
224  // The hub's glyph sits in the middle with its name on the row under it, so
225  // every edge starts at one point and the arcs get the width.
226  const hasNameRow = graphRows >= 4
227  const cy = Math.max(0, Math.floor((graphRows - (hasNameRow ? 2 : 1)) / 2))
228  const cx = Math.floor(width / 2)
229  const nameWidth = clamp(Math.floor(width * 0.3), 6, 26)
230  const center = props.nodes.find(node => node.side === 'center') ?? props.nodes[0]
231  if (center === undefined) {
232    return { rows: grid.runs(), spots }
233  }
234  const centerName = cut(center.name, clamp(width - 2, 4, nameWidth + 6))
235  const centerLength = widthOf(centerName)
236  const nameY = hasNameRow ? cy + 1 : cy
237  const nameX0 = hasNameRow ? clamp(cx - Math.floor(centerLength / 2), 0, Math.max(0, width - centerLength)) : cx + 2
238  const taken: { y: number; x0: number; x1: number }[] = [
239    { y: cy, x0: cx - 1, x1: cx + 1 },
240    { y: nameY, x0: nameX0 - 1, x1: nameX0 + centerLength },
241  ]
242  const isOpen = (y: number, x0: number, x1: number) => taken.every(span => span.y !== y || x1 < span.x0 || x0 > span.x1)
243
244  const placed: { node: EgoNode; x: number; y: number; text: string; x0: number }[] = []
245
246  for (const side of ['left', 'right'] as const) {
247    const nodes = props.nodes.filter(node => node.side === side)
248    const n = nodes.length
249
250    nodes.forEach((node, i) => {
251      const text = labelText(node, nameWidth)
252      const length = widthOf(text)
253      // Where the glyph goes on row y: further out towards the middle rows, an arc.
254      const xAt = (y: number) => {
255        const dy = (y - cy) / Math.max(1, graphRows / 2)
256        const arc = 0.25 + 0.75 * Math.sqrt(Math.max(0, 1 - dy * dy))
257        if (side === 'right') {
258          const outer = Math.max(cx + 3, width - 3 - length)
259          return clamp(Math.round(cx + 3 + (outer - cx - 3) * arc), cx + 2, outer)
260        }
261        const outer = Math.min(cx - 3, length + 1)
262        return clamp(Math.round(cx - 3 - (cx - 3 - outer) * arc), outer, cx - 2)
263      }
264      const spanAt = (y: number) => {
265        const x = xAt(y)
266        return side === 'right' ? { x, x0: x + 2, lo: x, hi: x + 1 + length } : { x, x0: x - 1 - length, lo: x - 1 - length, hi: x }
267      }
268      const ideal = n === 1 ? cy : clamp(Math.round(((i + 0.5) * graphRows) / n - 0.5), 0, graphRows - 1)
269
270      // The nearest row to the ideal one where the glyph and the label fit.
271      for (let d = 0; d < graphRows * 2; d++) {
272        const y = ideal + (d % 2 === 0 ? d / 2 : -(d + 1) / 2)
273        if (y < 0 || y >= graphRows) {
274          continue
275        }
276        const span = spanAt(y)
277        if (isOpen(y, span.lo - 1, span.hi + 1)) {
278          taken.push({ y, x0: span.lo, x1: span.hi })
279          placed.push({ node, x: span.x, y, text, x0: span.x0 })
280          return
281        }
282      }
283    })
284  }
285
286  // Edges first, from the hub's glyph to each node's; the labels then write over them.
287  for (const spot of placed) {
288    const isRight = spot.node.side === 'right'
289    grid.line(isRight ? cx + 1 : cx - 1, cy, isRight ? spot.x - 1 : spot.x + 1, spot.y)
290  }
291
292  grid.put(cx, cy, '◉', { ...PLAIN, bold: true, color: center.color }, true)
293  grid.write(nameX0, nameY, centerName, { ...PLAIN, bold: true, inverse: center.isSelected }, true, true)
294  spots.push({ id: center.id, x: cx, y: cy, x0: cx - 1, x1: cx + 1, isMore: false })
295  spots.push({ id: center.id, x: cx, y: nameY, x0: nameX0, x1: nameX0 + centerLength - 1, isMore: false })
296
297  for (const spot of placed) {
298    const { node } = spot
299    const glyph = node.more > 0 ? '…' : node.isTouched ? '◆' : '●'
300    const nameStyle: Style = { ...PLAIN, dim: node.more > 0, bold: node.isTouched, inverse: node.isSelected }
301    const glyphStyle: Style = { ...PLAIN, color: node.color, bold: node.isTouched }
302    const length = widthOf(spot.text)
303
304    grid.put(spot.x, spot.y, glyph, glyphStyle, true)
305    grid.write(spot.x0, spot.y, spot.text, nameStyle, true, true)
306    spots.push({ id: node.id, x: spot.x, y: spot.y, x0: Math.min(spot.x, spot.x0), x1: Math.max(spot.x, spot.x0 + length - 1), isMore: node.more > 0 })
307  }
308
309  // One label per group, near the middle of its middle edge.
310  props.groups.forEach((group, index) => {
311    const members = placed.filter(spot => spot.node.group === index).sort((a, b) => a.y - b.y)
312    const middle = members[Math.floor((members.length - 1) / 2)]
313    if (middle === undefined) {
314      return
315    }
316    const text = cut(group.title, Math.max(4, Math.floor(width / 3)))
317    const length = widthOf(text)
318    const midX = Math.round((cx + middle.x) / 2)
319    const midY = Math.round((cy + middle.y) / 2)
320    const x = clamp(midX - Math.floor(length / 2), 0, Math.max(0, width - length))
321
322    // Off the hub's two rows, with a cell of air on each side.
323    for (const y of [midY, midY - 1, midY + 1, midY - 2, midY + 2]) {
324      const lo = Math.max(0, x - 1)
325      const room = Math.min(width, x + length + 1) - lo
326      if (y >= 0 && y < graphRows && y !== cy && y !== nameY && grid.isFree(lo, y, room) && grid.write(x, y, text, EDGE_LABEL, true)) {
327        break
328      }
329    }
330  })
331
332  if (hasLegend) {
333    let x = 0
334    for (const entry of props.legend) {
335      const text = `● ${entry.label}`
336      const length = widthOf(text)
337      if (x + length > width) {
338        break
339      }
340      grid.put(x, height - 1, '●', { ...PLAIN, color: entry.color }, true)
341      grid.write(x + 1, height - 1, ` ${entry.label}`, { ...PLAIN, dim: true }, true, true)
342      x += length + 2
343    }
344  }
345
346  return { rows: grid.runs(), spots }
347}
348
349export type Step = 'up' | 'down' | 'left' | 'right'
350
351/**
352 * The node an arrow key moves the selection to: the nearest one in a cone that
353 * way (a row counts as two columns, as a cell is twice as tall as wide), else
354 * the nearest one that way at all; null when there is none.
355 */
356export function stepFrom(layout: Layout, from: string, step: Step): string | null {
357  const here = layout.spots.find(spot => spot.id === from) ?? layout.spots[0]
358
359  if (here === undefined) {
360    return null
361  }
362
363  const [ux, uy] = step === 'up' ? [0, -1] : step === 'down' ? [0, 1] : step === 'left' ? [-1, 0] : [1, 0]
364  let best: { id: string; score: number } | null = null
365
366  for (const spot of layout.spots) {
367    if (spot.id === here.id || spot.isMore) {
368      continue
369    }
370    const dx = spot.x - here.x
371    const dy = (spot.y - here.y) * 2
372    const along = dx * ux + dy * uy
373    const across = Math.abs(dx * uy) + Math.abs(dy * ux)
374    if (along <= 0) {
375      continue
376    }
377    const score = Math.hypot(dx, dy) + (across > along ? 1000 : 0)
378    if (best === null || score < best.score) {
379      best = { id: spot.id, score }
380    }
381  }
382
383  return best?.id ?? null
384}
385
386/** The node (or `+N more`) under a cell, or null. */
387export function hitTest(layout: Layout, x: number, y: number): Spot | null {
388  return layout.spots.find(s => s.y === y && x >= s.x0 - 1 && x <= s.x1 + 1) ?? null
389}
390
391/** A drawing's text, row by row: what a test or a log reads. */
392export function textOf(layout: Layout): string[] {
393  return layout.rows.map(row => row.map(run => run.text).join('').replace(/\s+$/, ''))
394}
395
types/index.d.ts 81 lines
1// The omnigraph $.state contract, and the shapes it holds. Every value the
2// module reads or writes with atom/read/update is declared under 'omnigraph';
3// the shapes sit beside it so the hooks files import them from 'claude-code'.
4// No imports and no `export {}`: `claude plugin validate` refuses a contract
5// that exports.
6declare module 'claude-code' {
7  /** One graph op the mod saw: a matched Bash command or MCP tool call, and what it did to the store. */
8  interface OmniOp {
9    /** When the call started, ms since the epoch. */
10    t: number
11    /** The op as the feed names it: `mutate create_issue`, `compile`, an MCP tool's short name. */
12    op: string
13    source: 'bash' | 'mcp'
14    /** The store's name (the header's), or null when the op had no store to diff. */
15    store: string | null
16    ok: boolean
17    /** The producer's own latency when its ops log said, else the call's wall time. */
18    ms: number
19    /** Net node and edge change; null when there was no store to diff. */
20    dNodes: number | null
21    dEdges: number | null
22    /** Nodes the op added, changed or connected, at most twelve. */
23    touched: string[]
24    /** Where the graph goes when it follows ops: the hub, and the node to highlight. */
25    focus: { center: string; selected: string } | null
26    /** Why it failed: the producer's error, `refused`, or the tool's error line. */
27    error: string | null
28    /** The store's version after the op. */
29    version: string | null
30    /** The session that saw it: the header counts only its own session's ops. */
31    session?: string
32    /** Nodes whose record changed, when any did (`~1n` in the feed). */
33    changed?: number
34    /** The command went to the background: it was not diffed, and the next op's diff carries its change. */
35    isBackground?: true
36  }
37
38  /** The store as the header and the band read it. */
39  interface OmniInfo {
40    /** The store folder, absolute. */
41    dir: string
42    name: string
43    branch: string | null
44    version: string | null
45    nodes: number
46    edges: number
47    labels: number
48    /** A manifest or a nodes file is there. */
49    isPresent: boolean
50    /** Nodes in a queue status (the Queue tab), and open conflicts. */
51    queued: number
52    conflicts: number
53    /** Lines that did not parse, why the store could not be read, or the mod's last internal error. */
54    problem: string | null
55  }
56
57  interface PluginState {
58    omnigraph: {
59      /** The last 50 ops on this root, this session's and earlier ones (kept in $.store). */
60      feed: OmniOp[]
61      /** When this session went live (or was cleared): the header counts its ops from here. */
62      since: number
63      info: OmniInfo | null
64      /** Bumped each time the store is read again, so the drawings that read the graph redraw. */
65      graphRev: number
66      tab: 'graph' | 'browse' | 'feed' | 'queue'
67      /** The browse path, oldest first; its last node is the selection. */
68      trail: string[]
69      /** The node the graph is drawn around. */
70      center: string | null
71      /** The graph and the trail jump to each op's nodes. */
72      isFollowing: boolean
73      /** The label the browser's first column lists; null: the first node's on the trail. */
74      browseLabel: string | null
75      queuePage: number
76      /** What the last Queue export wrote, or why it could not. */
77      notice: string | null
78    }
79  }
80}
81