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…

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
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:
/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 · ….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."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.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.
| 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.
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
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).[^\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).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.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.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.$.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).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.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.
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 |
/cd, the worktree tools and each turn's start cost one $.session.root() read while the root stays put.claude -p, cron, the SDK) it registers nothing and reads nothing until a client attaches..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).\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"
| 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) |
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.
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).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.+0n +0e. A change another session or a background command made shows in the next op's diff, or after /omni reload.--store) has no diff (·n ·e); after that it has one. A moved root's configured store is read when the root moves.hooks/register.ts 1034 lines1// 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}
1034hooks/ego.ts 217 lines1// 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}
217hooks/graphstore.ts 914 lines1// 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}
914hooks/text.ts 181 lines1// 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}
181hooks/views.ts 569 lines1// 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}
569hooks/graph-view.ts 73 lines1// 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
73hooks/canvas.ts 395 lines1// 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}
395types/index.d.ts 81 lines1// 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