SLOPSHOPPER

graph-in-pane

Graphviz graphs of scripts, builds and plans in a docked pane

newpaneguardcommandstatusprompt
v0.2.0no licenseupdated 2026-10-26dynnamitt/skill-sets/graph-in-pane
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · graph-in-pane
│ ┃ Graph ✕ › fix the failing auth test and add an audit log call │ ┃ 0: back app › app › /work/app › /work/app │ ┃ no grammar for app ⏺ Read(src/auth.ts) │ ┃ ctrl+x tab to use keys ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /graph-tree │ ⎿ graph-in-pane: no grammar for app │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Graph
0: back app › app › /work/app › /work/app no grammar for app ctrl+x tab to use keys
README

name: graph-in-pane description: Claude Code mod that draws Graphviz graphs of scripts, builds, .dot files and plans as images in a pane docked beside the chat


graph-in-pane

A Claude Code mod (a plugin of function hooks) that shows Graphviz graphs as real images in a pane docked to the right of the chat. It gives "code inspection from 10,000 feet" for bash and TypeScript/JavaScript, and a live box-and-arrow view of a plan while Claude works one out with you. The goal is fewer terminal splits.

Commands

CommandAliasWhat it draws
`/graph-tree <file\dir>`/10kfv (view)The literal structure from tree-sitter: functions, calls, external commands, exits and own-file dependencies. Instant, uses no tokens. A .dot file is drawn as written and redrawn when it changes.
`/graph-flow <file\dir>`/10kfo (overview)A 10,000 ft flow that Claude draws from the tree-sitter skeleton: phases, failure exits, every arrow backed by the code.
/graphReopens the pane, showing the latest plan graph first. /graph off stops plan graphs.

Picking the path

While you type the argument of /graph-tree, /graph-flow or their aliases, the typeahead offers fuzzy-matched files and dirs. src/data/vehimp or ~/entur/hat/veh look under the longest dir the token names; the rest is matched with fzf --filter when fzf is installed, else a built-in matcher. Paths come from git ls-files in a repo, else a 4-level walk.

In the pane

  • Press ctrl+x tab to focus the pane.
  • Digits 1–9 open a numbered box: an own file, or a large function.
  • 0 goes back along the breadcrumb.
  • m pages through more boxes.

Plan graphs

While a planning skill (brainstorming, writing-plans) or plan mode is active, Claude keeps a picture of the plan in the pane.

Tools Claude can call

  • show_graph: draws DOT in the pane.
  • graph_skeleton: returns the tree-sitter skeleton of a file or dir as JSON.

Requirements

Any Linux distro. Each tool is looked up on PATH, then in the Homebrew, ~/.cargo/bin, ~/.local/bin, /usr/local/bin and /usr/bin folders. A path can also be set in the plugin's options (/config).

ToolNeeded forArchFedora / BazziteDebian / Ubuntu
Graphviz dotevery graphpacman -S graphvizbrew install graphviz (or dnf install graphviz)apt install graphviz
ast-grep/graph-tree, /graph-flowpacman -S ast-grepbrew install ast-grepcargo install ast-grep or npm i -g @ast-grep/cli
tree-sitter CLI + Neovim parsersfallback when ast-grep is missingpacman -S tree-sitter-cliin the base imagecargo install tree-sitter-cli
fzf (optional)fzf ranking in the path typeaheadpacman -S fzfbrew install fzf (or dnf install fzf)apt install fzf

Notes:

  • Run ast-grep by its full name. sg is a shadow-utils command on most distros.
  • The fallback finds parser .so files through nvim --headless, or in ${XDG_DATA_HOME:-~/.local/share}/nvim/site/parser.
  • Images need a terminal with the kitty graphics protocol, such as kitty or Ghostty. Other terminals show a text outline of the graph instead.
  • The pane docks on the right in the fullscreen layout from 110 columns. In a narrower window, plan graphs announce themselves on the status line: plan graph updated · /graph.

Install

At the prompt of a Claude Code terminal session:

/plugin install graph-in-pane --marketplace dynnamitt/skill-sets

From a shell:

claude plugin install graph-in-pane --marketplace dynnamitt/skill-sets

To develop it, run claude --plugin-dir ~/code/skill-sets/graph-in-pane. It reloads on save. Run the tests with claude plugin test graph-in-pane.

Design

Source 17 files
hooks/register.tsx 405 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PluginOptions, PromptAutocompleteSuggestion, Register } from 'claude-code'
3
4import type { Hints, Meta, Nav, PlanState, View } from '../types'
5import { flowCheck, redrawDot, type RenderCtx, renderView, resolveTarget, skeletonOf, summary, titleOf, treeView } from './lib/commands'
6import { styleDot } from './lib/dot'
7import { isFailure } from './lib/extract'
8import { nvimParserDirs } from './lib/fallback'
9import {
10  flowRequest,
11  flowSummary,
12  parseShowGraph,
13  RESULT,
14  SHOW_GRAPH,
15  SHOW_GRAPH_DESCRIPTION,
16  SHOW_GRAPH_SCHEMA,
17  SKELETON,
18  SKELETON_DESCRIPTION,
19  SKELETON_SCHEMA,
20  waitingView,
21} from './lib/flow'
22import { type Host, hostOf } from './lib/host'
23import { emptyView, type NavAction, navReduce, topOf } from './lib/nav'
24import { paneTree } from './lib/pane'
25import { isPickToken, listCandidates, nearPaths, notFoundText, PICK_CACHE_MS, pickRoot, rank } from './lib/pick'
26import { isPlanningSkill, PLAN_SECTION_ID, PLAN_TEXT, type PlanEvent, planTransition } from './lib/plan'
27import { installHint, resolveTools, type Tools } from './lib/tools'
28import { langOf, narrow, summarize } from './lib/skeleton'
29
30/** The id of the graph pane. */
31export const PANE_ID = 'graph'
32const DOT_POLL_MS = 1000, DEFAULT_ROWS = 24
33
34// The engine reads `$.state` keys off this file's own consts, so every atom
35// lives here; lib/ stays pure or takes a Host.
36const EMPTY_TOOLS: Tools = {}
37const EMPTY_NAV: Nav = { stack: [] }
38const EMPTY_PLAN: PlanState = { active: false, source: null, latest: null }
39const EMPTY_HINTS: Hints = { dot: 'dot not found (install Graphviz)', astGrep: 'ast-grep not found' }
40const EMPTY_META: Meta = { cwd: '/', sessionTag: 'session', cacheDir: '/tmp/graph-in-pane', gen: 0, parserDirs: [], hints: EMPTY_HINTS }
41
42const toolsAtom = atom({ plugin: 'graph-in-pane', key: 'tools' } as const, EMPTY_TOOLS, { shape: 'tools-1' })
43const navAtom = atom({ plugin: 'graph-in-pane', key: 'nav' } as const, EMPTY_NAV, { shape: 'nav-1' })
44const planAtom = atom({ plugin: 'graph-in-pane', key: 'plan' } as const, EMPTY_PLAN, { shape: 'plan-1' })
45const metaAtom = atom({ plugin: 'graph-in-pane', key: 'meta' } as const, EMPTY_META, { shape: 'meta-1' })
46
47// A root's listing, reused while the person types; a reload starts it over.
48const pickCache = new Map<string, { at: number; paths: readonly string[] }>()
49
50async function planEvent($: EngineInterface, event: PlanEvent): Promise<void> {
51  await update($, planAtom, plan => planTransition(plan ?? EMPTY_PLAN, event))
52}
53
54async function dispatch($: EngineInterface, action: NavAction): Promise<void> {
55  await update($, navAtom, nav => navReduce(nav ?? EMPTY_NAV, action))
56}
57
58/** Puts a view in the pane and opens it; says whether a surface draws it now. */
59async function showView($: EngineInterface, view: View, how: 'reset' | 'push' | 'replaceTop'): Promise<{ isPlaced: boolean }> {
60  await dispatch($, { type: how, view })
61  const opened = await $.ui.open({ id: PANE_ID, title: `Graph · ${view.title}` })
62  // A graph on screen makes an earlier "plan graph updated · /graph" stale.
63  if (opened.isPlaced) $.ui.status(undefined)
64  return { isPlaced: opened.isPlaced }
65}
66
67/** Like showView with replaceTop, but only if `was` is still the top view. */
68async function showReplacing($: EngineInterface, was: View, view: View): Promise<{ isPlaced: boolean }> {
69  await dispatch($, { type: 'replaceIf', was, view })
70  const opened = await $.ui.open({ id: PANE_ID, title: `Graph · ${view.title}` })
71  if (opened.isPlaced) $.ui.status(undefined)
72  return { isPlaced: opened.isPlaced }
73}
74
75/** The render context and a fresh generation number for the next png. */
76async function renderCtx($: EngineInterface): Promise<{ ctx: RenderCtx; gen: number; meta: Meta }> {
77  await update($, metaAtom, meta => ({ ...(meta ?? EMPTY_META), gen: (meta ?? EMPTY_META).gen + 1 }))
78  const meta = await read($, metaAtom)
79  const tools = await read($, toolsAtom)
80  return { ctx: ctxOf(meta, tools), gen: meta.gen, meta }
81}
82
83function ctxOf(meta: Meta, tools: Tools): RenderCtx {
84  return { tools, cacheDir: meta.cacheDir, sessionTag: meta.sessionTag, parserDirs: meta.parserDirs, hints: meta.hints ?? EMPTY_HINTS }
85}
86
87async function targetOf($: EngineInterface, args: string): Promise<string> {
88  const meta = await read($, metaAtom)
89  const home = (await $.env.get('HOME')) ?? ''
90  return resolveTarget(args, meta.cwd, home)
91}
92
93async function treeCommand($: EngineInterface, command: string, args: string): Promise<{ text: string }> {
94  const target = await targetOf($, args)
95  return (await lostTarget($, command, target, 'tree')) ?? treeTarget($, target)
96}
97
98async function treeTarget($: EngineInterface, target: string): Promise<{ text: string }> {
99  const { ctx, gen } = await renderCtx($)
100  const view = await treeView(hostOf($), ctx, target, gen)
101  const nav = await read($, navAtom)
102  await showView($, view, view.error !== null && nav.stack.length > 0 ? 'push' : 'reset')
103  return { text: summary(view) }
104}
105
106/** Extracts a target's skeleton, shows a waiting view and asks Claude for its flow graph. */
107async function flowAsk($: EngineInterface, target: string, how: 'reset' | 'push'): Promise<{ text: string }> {
108  const [file = target] = target.split('#')
109  const checked = await flowCheck(hostOf($), file)
110  if (isFailure(checked)) {
111    const view = { ...emptyView('flow', file, target), error: checked.error }
112    await showView($, view, (await read($, navAtom)).stack.length > 0 ? 'push' : how)
113    return { text: summary(view) }
114  }
115  await showView($, waitingView(target), how)
116  // A command hook holds the turn; the submit runs once it has returned.
117  $.clock.after(0, () => void $.prompt.submit({ text: flowRequest(target) }))
118  return { text: flowSummary(target, checked.files) }
119}
120
121async function flowCommand($: EngineInterface, command: string, args: string): Promise<{ text: string }> {
122  const target = await targetOf($, args)
123  // A .dot file is drawn as written by either command.
124  const isDot = target.endsWith('.dot')
125  return (await lostTarget($, command, target, isDot ? 'tree' : 'flow')) ?? (isDot ? treeTarget($, target) : flowAsk($, target, 'reset'))
126}
127
128/** A root's candidates, listed again once the cached listing is older than PICK_CACHE_MS. */
129async function cachedCandidates(host: Host, root: string): Promise<readonly string[]> {
130  const cached = pickCache.get(root)
131  if (cached !== undefined && Date.now() - cached.at < PICK_CACHE_MS) return cached.paths
132  const paths = await listCandidates(host, root)
133  pickCache.set(root, { at: Date.now(), paths })
134  return paths
135}
136
137/** For a file that does not exist: the not-found error naming the paths it may have meant; null when it exists. */
138async function missingTarget($: EngineInterface, file: string): Promise<{ error: string; near: string[] } | null> {
139  const host = hostOf($)
140  if ((await host.stat(file)) !== null) return null
141  const near = await nearPaths(host, (await read($, toolsAtom)).fzf, file, cachedCandidates)
142  return { error: notFoundText(file, near), near }
143}
144
145/** A command's missing target: shows the error with its near paths and proposes the closest as the next prompt; null when it exists. */
146async function lostTarget($: EngineInterface, command: string, target: string, mode: 'tree' | 'flow'): Promise<{ text: string } | null> {
147  const [file = target, fn] = target.split('#')
148  const missing = await missingTarget($, file)
149  if (missing === null) return null
150  const base = mode === 'flow' ? emptyView('flow', file, target) : emptyView(file.endsWith('.dot') ? 'dot' : 'tree', titleOf(target), target)
151  await showView($, { ...base, error: missing.error }, (await read($, navAtom)).stack.length > 0 ? 'push' : 'reset')
152  const [best] = missing.near
153  if (best !== undefined) {
154    const home = (await $.env.get('HOME')) ?? ''
155    const shown = home !== '' && best.startsWith(`${home}/`) ? `~${best.slice(home.length)}` : best
156    // A command hook holds the box; the proposal shows once it has returned.
157    $.clock.after(0, () => void $.prompt.suggest({ text: `/${command} ${shown}${fn === undefined ? '' : `#${fn}`}` }))
158  }
159  return { text: missing.error }
160}
161
162async function openBox($: EngineInterface, n: number): Promise<void> {
163  const top = topOf(await read($, navAtom))
164  const box = top?.openable.find(o => o.n === n)
165  if (top === undefined || box === undefined) return
166  if (top.mode === 'flow') {
167    await flowAsk($, await targetOf($, box.target), 'push')
168    return
169  }
170  const { ctx, gen } = await renderCtx($)
171  await showView($, await treeView(hostOf($), ctx, box.target, gen), 'push')
172}
173
174/** Whether the top view is a flow pane waiting on (or showing a failure for) this target. */
175function awaitsFlow(top: View | undefined, target: string): boolean {
176  return top !== undefined && top.mode === 'flow' && top.target === target && (top.waiting || top.error !== null)
177}
178
179/** Replaces the waiting flow view for a target with the error that ended the wait. */
180async function failWaiting($: EngineInterface, target: string, error: string): Promise<void> {
181  const top = topOf(await read($, navAtom))
182  if (top !== undefined && awaitsFlow(top, target)) await dispatch($, { type: 'replaceIf', was: top, view: { ...top, waiting: false, error } })
183}
184
185/** Serves graph_skeleton: the tree-sitter skeleton of a file or dir, as JSON. */
186async function handleSkeleton($: EngineInterface, input: Record<string, unknown>) {
187  const meta = await read($, metaAtom)
188  const tools = await read($, toolsAtom)
189  const target = await targetOf($, typeof input.target === 'string' ? input.target : '')
190  const [file = '', fn] = target.split('#')
191  const missing = await missingTarget($, file)
192  if (missing !== null) {
193    await failWaiting($, target, missing.error)
194    return { isError: true as const, result: missing.error, text: missing.error }
195  }
196  const extracted = await skeletonOf(hostOf($), ctxOf(meta, tools), file)
197  if (isFailure(extracted)) {
198    await failWaiting($, target, extracted.error)
199    return { isError: true as const, result: extracted.error, text: extracted.error }
200  }
201  const { sk, isDir } = extracted
202  // A file#fn target answers that function alone; a dir answers one line per file.
203  const answer = fn !== undefined && fn !== '' ? narrow(sk, file, fn) : isDir ? summarize(sk) : sk
204  return { result: JSON.stringify(answer) }
205}
206
207/** Serves show_graph: renders Claude's DOT and routes it to the pane by kind. */
208async function handleShowGraph($: EngineInterface, input: Record<string, unknown>) {
209  const parsed = parseShowGraph(input)
210  if (typeof parsed === 'string') return { isError: true as const, result: parsed, text: parsed }
211  const { ctx, gen } = await renderCtx($)
212  const base = { ...emptyView(parsed.kind, parsed.title, parsed.target), openable: parsed.openable }
213  const view = await renderView(hostOf($), ctx, base, styleDot(parsed.dot), gen)
214  if (view.error !== null) {
215    const text = `dot failed: ${view.error}`
216    if (parsed.kind === 'flow') await failWaiting($, await targetOf($, parsed.target), view.error)
217    return { isError: true as const, result: text, text }
218  }
219  const nav = await read($, navAtom)
220  const top = topOf(nav)
221  if (parsed.kind === 'plan') {
222    await update($, planAtom, plan => ({ ...(plan ?? EMPTY_PLAN), latest: view }))
223    if (nav.stack.length > 0 && nav.stack[0]?.mode !== 'plan') {
224      $.ui.status(RESULT.status)
225      return { result: RESULT.stored }
226    }
227  }
228  const isAwaited = parsed.kind === 'flow' && awaitsFlow(top, await targetOf($, parsed.target))
229  const { isPlaced } = await (isAwaited && top !== undefined ? showReplacing($, top, view) : showView($, view, 'reset'))
230  if (!isPlaced) $.ui.status(RESULT.status)
231  return { result: isPlaced ? RESULT.shown : RESULT.hidden }
232}
233
234async function pollDot($: EngineInterface): Promise<void> {
235  const top = topOf(await read($, navAtom))
236  // Keeps polling under an error too, so fixing the file brings the picture back.
237  if (top === undefined || top.mode !== 'dot') return
238  const ctx = ctxOf(await read($, metaAtom), await read($, toolsAtom))
239  const redrawn = await redrawDot(hostOf($), ctx, top, async () => (await renderCtx($)).gen).catch(() => null)
240  if (redrawn !== null) await dispatch($, { type: 'replaceIf', was: top, view: redrawn })
241}
242
243/**
244 * Finds the tools and writes this session's tools and meta: session.start's job, and the first use's after a /clear.
245 * @param {EngineInterface} $ the engine handed to a hook
246 * @param {PluginOptions} options the plugin options
247 * @param {string} cwd the session's directory
248 * @returns {Promise<void>} once both atoms are written
249 */
250async function setup($: EngineInterface, options: PluginOptions, cwd: string): Promise<void> {
251  const host = hostOf($)
252  const home = (await $.env.get('HOME')) ?? ''
253  const cacheDir = `${(await $.env.get('XDG_CACHE_HOME')) || `${home}/.cache`}/graph-in-pane`
254  const [{ tools, parserDirs }, osRelease, hasBrew] = await Promise.all([
255    resolveTools(host, options).then(async tools => ({
256      tools,
257      parserDirs: tools['ast-grep'] === undefined ? await nvimParserDirs(host, tools, options) : [],
258    })),
259    host.read('/etc/os-release').catch(() => ''),
260    host
261      .run(['sh', '-c', 'command -v brew'], { stdin: '' })
262      .catch(() => ({ exitCode: 1 }))
263      .then(result => result.exitCode === 0),
264  ])
265  const hints: Hints = { dot: installHint(osRelease, hasBrew, 'dot'), astGrep: installHint(osRelease, hasBrew, 'ast-grep') }
266  await update($, toolsAtom, () => tools)
267  await update($, metaAtom, meta => ({
268    cwd,
269    sessionTag: String(Date.now()),
270    cacheDir,
271    gen: meta?.gen ?? 0,
272    parserDirs,
273    hints,
274  }))
275}
276
277/** The typeahead rows for a path-taking command's argument: fuzzy matches under the dir the token names. */
278async function pickRows($: EngineInterface, token: string): Promise<PromptAutocompleteSuggestion[]> {
279  const meta = await read($, metaAtom)
280  const tools = await read($, toolsAtom)
281  const host = hostOf($)
282  const picked = await pickRoot(host, token, meta.cwd, (await $.env.get('HOME')) ?? '')
283  if (picked === null) return []
284  const ranked = await rank(host, tools.fzf, await cachedCandidates(host, picked.root), picked.query)
285  return ranked.map(path => ({
286    text: `${picked.prefix}${path}`,
287    description: path.endsWith('/') ? 'dir' : (langOf(path) ?? (path.endsWith('.dot') ? 'dot' : 'script')),
288  }))
289}
290
291/** Sets the session up unless done: a /clear starts one with fresh state and no session.start. */
292async function ready($: EngineInterface, options: PluginOptions): Promise<void> {
293  if ((await read($, metaAtom)).sessionTag === EMPTY_META.sessionTag) await setup($, options, await $.session.cwd())
294}
295
296/**
297 * The mod's hooks module: session start, the /graph commands, the show_graph and graph_skeleton tools, and plan tracking.
298 * @param {Parameters<Register>[0]} on registers a hook for an event
299 * @param {Parameters<Register>[1]} options the plugin options
300 * @returns {void}
301 */
302export const register: Register = (on, options) => {
303  on('session.start', async ($, e, next) => {
304    await setup($, options, e.cwd)
305    const { cacheDir } = await read($, metaAtom)
306    // Pngs from sessions over a day old.
307    void $.process.run(['find', cacheDir, '-name', '*.png', '-mtime', '+1', '-delete'], { stdin: '' }).catch(() => undefined)
308    await $.command.register({ name: 'graph-tree', description: 'Graph a file or dir literally in the graph pane', argumentHint: '<file|dir>' })
309    await $.command.register({ name: '10kfv', description: '10,000 ft view: alias of /graph-tree', argumentHint: '<file|dir>' })
310    await $.command.register({ name: 'graph-flow', description: '10,000 ft flow of a file or dir, drawn by Claude in the graph pane', argumentHint: '<file|dir>' })
311    await $.command.register({ name: '10kfo', description: '10,000 ft overview: alias of /graph-flow', argumentHint: '<file|dir>' })
312    await $.tool.register({ name: SKELETON, description: SKELETON_DESCRIPTION, inputSchema: SKELETON_SCHEMA, isDeferred: false })
313    await $.tool.register({ name: SHOW_GRAPH, description: SHOW_GRAPH_DESCRIPTION, inputSchema: SHOW_GRAPH_SCHEMA, isDeferred: false })
314    await $.command.register({ name: 'graph', description: 'Reopen the graph pane (latest plan graph first); /graph off stops plan graphs', argumentHint: '[off]' })
315    // One poll at a time: a slow dot run must not be raced by the next tick.
316    let isPolling = false
317    $.clock.every(DOT_POLL_MS, () => {
318      if (isPolling) return
319      isPolling = true
320      void pollDot($).catch(() => undefined).finally(() => {
321        isPolling = false
322      })
323    })
324
325    return next(e)
326  })
327
328  on('command.run', { command: 'graph-tree' }, async ($, e) => (await ready($, options), treeCommand($, 'graph-tree', e.args)))
329  on('command.run', { command: '10kfv' }, async ($, e) => (await ready($, options), treeCommand($, '10kfv', e.args)))
330
331  on('command.run', { command: 'graph-flow' }, async ($, e) => (await ready($, options), flowCommand($, 'graph-flow', e.args)))
332  on('command.run', { command: '10kfo' }, async ($, e) => (await ready($, options), flowCommand($, '10kfo', e.args)))
333
334  on('tool.call', { tool: 'mcp__graph-in-pane__graph_skeleton' }, async ($, e) => (await ready($, options), handleSkeleton($, e as Record<string, unknown>)))
335
336  on('tool.call', { tool: 'mcp__graph-in-pane__show_graph' }, async ($, e) => (await ready($, options), handleShowGraph($, e as Record<string, unknown>)))
337
338  on('prompt.autocomplete', async ($, e, next) => {
339    if (!isPickToken(e.text, e.start)) return next(e)
340    await ready($, options)
341    const rows = await pickRows($, e.token)
342    return { suggestions: [...(await next(e)).suggestions, ...rows] }
343  })
344
345  on('command.run', { command: 'graph' }, async ($, e) => {
346    if (e.args.trim() === 'off') {
347      await planEvent($, { type: 'off' })
348      return { text: 'plan graphs off' }
349    }
350    const latest = (await read($, planAtom)).latest
351    if (latest !== null) await showView($, latest, 'reset')
352    else await $.ui.open({ id: PANE_ID, title: 'Graph' })
353    return { text: 'pane opened' }
354  })
355
356  on('skill.prompt', async ($, e, next) => {
357    if (isPlanningSkill(e.skill)) await planEvent($, { type: 'skill' })
358    return next(e)
359  })
360
361  on('tool.call', { tool: 'EnterPlanMode' }, async ($, e, next) => {
362    const ran = await next(e)
363    await planEvent($, { type: 'enterPlanTool' })
364    return ran
365  })
366
367  on('tool.call', { tool: 'ExitPlanMode' }, async ($, e, next) => {
368    const ran = await next(e)
369    await planEvent($, { type: 'exitPlanTool' })
370    return ran
371  })
372
373  on('classic.UserPromptSubmit', async ($, e, next) => {
374    await planEvent($, { type: 'permissionMode', mode: e.permission_mode })
375    return next(e)
376  })
377
378  on('session.end', async ($, e, next) => {
379    if (e.reason === 'clear') await planEvent($, { type: 'clear' })
380    return next(e)
381  })
382
383  on('prompt.compose', async ($, e, next) => {
384    const composed = await next(e)
385    const plan = await read($, planAtom)
386    return plan.active
387      ? { ...composed, sections: [...composed.sections, { id: PLAN_SECTION_ID, text: PLAN_TEXT, scope: 'session' as const }] }
388      : composed
389  })
390
391  on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
392    const nav = await read($, navAtom)
393    return paneTree(
394      $.ui.resolve(e),
395      nav,
396      { surface: e.surface, bodyColumns: e.props.bodyColumns, rows: e.viewport?.rows ?? DEFAULT_ROWS, isFocused: e.props.isFocused },
397      {
398        back: () => void dispatch($, { type: 'pop' }),
399        open: n => void openBox($, n),
400        more: () => void dispatch($, { type: 'page' }),
401      },
402    )
403  })
404}
405
hooks/lib/commands.ts 150 lines
1import type { Hints, Tools, View } from '../../types'
2import { altOutline, pngPath, treeGraph } from './dot'
3import { extractTarget, type Failure, isFailure, langOfFile, listSourceFiles, noGrammar } from './extract'
4import { type Host, safely } from './host'
5import { emptyView } from './nav'
6import { base, normalize } from './path'
7import { renderPng, type RenderOptions } from './render'
8import type { Skeleton } from './skeleton'
9
10/** What rendering needs from the session: tools, where pngs go, the fallback parsers. */
11export type RenderCtx = { tools: Tools; cacheDir: string; sessionTag: string; parserDirs: readonly string[]; hints: Hints }
12
13/**
14 * A command argument as an absolute path: quotes stripped, `~` expanded, relative joined.
15 * @param {string} arg the raw argument
16 * @param {string} cwd the base for a relative path
17 * @param {string} home what `~` stands for
18 * @returns {string} a normalized absolute path
19 */
20export const resolveTarget = (arg: string, cwd: string, home: string): string => {
21  const bare = arg.trim().replace(/^(['"])(.*)\1$/, '$2')
22  return normalize(bare.startsWith('~/') ? `${home}/${bare.slice(2)}` : bare.startsWith('/') ? bare : `${cwd}/${bare}`)
23}
24
25/**
26 * A view's title: the function name for `file#fn`, else the file's base name.
27 * @param {string} target a path or `${file}#${fn}`
28 * @returns {string} the title
29 */
30export const titleOf = (target: string): string => {
31  const [file, fn] = target.split('#')
32  return fn !== undefined && fn !== '' ? fn : base(file ?? target)
33}
34
35/**
36 * Draws DOT into a fresh png for `view`; errors land on the view.
37 * @param {Host} host runs dot
38 * @param {RenderCtx} ctx tools, cache dir and hints
39 * @param {View} view the view to update
40 * @param {string} dot the DOT source
41 * @param {number} gen the picture's generation
42 * @param {Pick<RenderOptions, 'unflatten'>} [options] whether to unflatten
43 * @returns {Promise<View>} the view with its picture, or with the error
44 */
45export const renderView = async (
46  host: Host,
47  ctx: RenderCtx,
48  view: View,
49  dot: string,
50  gen: number,
51  options: Pick<RenderOptions, 'unflatten'> = {},
52): Promise<View> => {
53  const png = view.png ?? pngPath(ctx.cacheDir, `${ctx.sessionTag}-${gen}`)
54  const rendered = await renderPng(host, ctx.tools, dot, png, { ...options, missingDot: ctx.hints.dot })
55  return rendered.ok
56    ? { ...view, png, gen, px: { w: rendered.w, h: rendered.h }, alt: altOutline(dot), error: null, waiting: false }
57    : { ...view, error: rendered.error, waiting: false }
58}
59
60/**
61 * Stats a file or dir and extracts its skeleton: the one path behind the tree view and graph_skeleton.
62 * @param {Host} host the session host
63 * @param {RenderCtx} ctx tools, parser dirs and hints
64 * @param {string} file absolute path of a file or dir
65 * @returns {Promise<{ sk: Skeleton; isDir: boolean } | Failure>} the skeleton, else why not
66 */
67export const skeletonOf = async (host: Host, ctx: RenderCtx, file: string): Promise<{ sk: Skeleton; isDir: boolean } | Failure> => {
68  const stat = await host.stat(file)
69  if (stat === null) return { error: `not found: ${file}` }
70  const isDir = stat.kind === 'dir'
71  const sk = await extractTarget(host, ctx.tools, file, isDir, ctx.parserDirs, ctx.hints.astGrep)
72  return isFailure(sk) ? sk : { sk, isDir }
73}
74
75/**
76 * The cheap check behind /graph-flow: the target exists and has something to extract. Runs no ast-grep.
77 * @param {Host} host the session host
78 * @param {string} file absolute path of a file or dir
79 * @returns {Promise<{ files: number } | Failure>} how many source files, else why not
80 */
81export const flowCheck = async (host: Host, file: string): Promise<{ files: number } | Failure> => {
82  const stat = await host.stat(file)
83  if (stat === null) return { error: `not found: ${file}` }
84  if (stat.kind !== 'dir') return (await langOfFile(host, file)) === null ? { error: noGrammar(file) } : { files: 1 }
85  const { files } = await listSourceFiles(host, file)
86  return files.length === 0 ? { error: `no bash, TypeScript or JavaScript files under ${file}` } : { files: files.length }
87}
88
89/**
90 * The literal view of a file, dir, `${file}#${fn}` or .dot file; failures land on the view, never thrown.
91 * @param {Host} host the session host
92 * @param {RenderCtx} ctx tools, parser dirs and hints
93 * @param {string} target an absolute path, or `${file}#${fn}`
94 * @param {number} gen the picture's generation
95 * @returns {Promise<View>} the drawn view, or one carrying the error
96 */
97export const treeView = async (host: Host, ctx: RenderCtx, target: string, gen: number): Promise<View> => {
98  const [file = target] = target.split('#')
99  const view = emptyView(file.endsWith('.dot') ? 'dot' : 'tree', titleOf(target), target)
100  return safely<View>(
101    async () => {
102      const stat = await host.stat(file)
103      if (stat === null) return { ...view, error: `not found: ${file}` }
104      if (view.mode === 'dot') {
105        if (stat.kind !== 'file') return { ...view, error: `not a file: ${file}` }
106        return renderView(host, ctx, { ...view, mtimeMs: stat.mtimeMs }, await host.read(file), gen)
107      }
108      const extracted = await skeletonOf(host, ctx, file)
109      if (isFailure(extracted)) return { ...view, error: extracted.error }
110      const graph = treeGraph(extracted.sk, target.includes('#') ? target : file)
111      return renderView(host, ctx, { ...view, openable: graph.openable }, graph.dot, gen, { unflatten: true })
112    },
113    error => ({ ...view, error }),
114  )
115}
116
117/**
118 * Redraws a .dot view when its file changed since it was drawn; null when unchanged.
119 * A bad save keeps the last good picture under the error, and the next good save clears it.
120 * @param {Host} host the session host
121 * @param {RenderCtx} ctx tools, cache dir and hints
122 * @param {View} view the .dot view
123 * @param {() => Promise<number>} nextGen takes the next picture generation
124 * @returns {Promise<View | null>} the redrawn view, else null
125 */
126export const redrawDot = async (
127  host: Host,
128  ctx: RenderCtx,
129  view: View,
130  nextGen: () => Promise<number>,
131): Promise<View | null> => {
132  const stat = await host.stat(view.target)
133  if (stat === null || stat.mtimeMs === view.mtimeMs) return null
134  const gen = await nextGen()
135  const text = await host.read(view.target).catch(() => null)
136  if (text === null) return { ...view, mtimeMs: stat.mtimeMs, error: `cannot read ${view.target}` }
137  const next = await renderView(host, ctx, { ...view, png: null, mtimeMs: stat.mtimeMs }, text, gen)
138  return next.error === null ? next : { ...view, mtimeMs: stat.mtimeMs, error: next.error }
139}
140
141const plural = (n: number, word: string): string => `${n} ${word}${n === 1 ? '' : 'es'}`
142
143/**
144 * The one line a command answers with; the engine already names the plugin.
145 * @param {View} view the drawn view
146 * @returns {string} the error, else a "drew ..." line
147 */
148export const summary = (view: View): string =>
149  view.error ?? `drew ${view.title} (${plural(view.openable.length, 'box')} to open)`
150
hooks/lib/dot.ts 189 lines
1import type { Openable } from '../../types'
2import { base } from './path'
3import { type Call, type FileSkeleton, type Skeleton, TOP } from './skeleton'
4
5const ALT_MAX = 400
6const MAX_EXTERNALS = 12, BIG_FN_LINES = 12, MAX_FN_OPENABLE = 9
7
8export type { Openable }
9/** A DOT graph and the boxes in it the user can open. */
10export type Graph = { dot: string; openable: Openable[] }
11
12/** The palette every graph uses (Catppuccin Mocha). */
13export const COLORS = {
14  bg: '#1e1e2e',
15  fill: '#313244',
16  border: '#89b4fa',
17  text: '#cdd6f4',
18  edge: '#7f849c',
19  edgeText: '#a6adc8',
20  openable: '#f9e2af',
21  ok: '#a6e3a1',
22  fail: '#f38ba8',
23  external: '#585b70',
24  externalText: '#7f849c',
25} as const
26
27/** The preamble every graph the pane draws shares (plan, tree and flow alike). */
28export const STYLE = [
29  `  graph [rankdir=TB bgcolor="${COLORS.bg}" pad=0.3 nodesep=0.3 ranksep=0.32 fontname="sans" fontcolor="${COLORS.text}"]`,
30  `  node [shape=box style="rounded,filled" fillcolor="${COLORS.fill}" color="${COLORS.border}" fontcolor="${COLORS.text}" fontname="sans" fontsize=11]`,
31  `  edge [color="${COLORS.edge}" fontcolor="${COLORS.edgeText}" fontname="sans" fontsize=9]`,
32].join('\n')
33
34const GRAPH_HEADER = /^\s*(?:strict\s+)?(?:di)?graph\s*(?:"(?:[^"\\]|\\.)*"|[^\s{]*)\s*\{/i
35
36/**
37 * Inserts STYLE right after the graph's opening brace, skipping any brace inside a quoted name.
38 * Falls back to the first brace when the header does not match; leaves the text alone when there is no brace.
39 *
40 * @param {string} dot - The DOT source of one graph.
41 * @returns {string} The DOT source with STYLE inserted after the opening brace.
42 */
43export const styleDot = (dot: string): string => {
44  const header = GRAPH_HEADER.exec(dot)
45  const at = header ? header[0].length : dot.indexOf('{') + 1
46  return at === 0 ? dot : `${dot.slice(0, at)}\n${STYLE}\n${dot.slice(at)}`
47}
48
49const q = (text: string): string => `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
50const circled = (n: number): string => (n <= 20 ? String.fromCodePoint(0x2460 + n - 1) : `(${n})`)
51const attrs = (pairs: Readonly<Record<string, string>>): string =>
52  Object.entries(pairs)
53    .map(([key, value]) =>
54      `${key}=${value.startsWith('"') || (key !== 'label' && /^[A-Za-z0-9.]+$/.test(value)) ? value : q(value)}`,
55    )
56    .join(' ')
57const nodeLine = (id: string, pairs: Readonly<Record<string, string>>): string => `  ${q(id)} [${attrs(pairs)}]`
58const edgeLine = (from: string, to: string, pairs: Readonly<Record<string, string>> = {}): string =>
59  `  ${q(from)} -> ${q(to)}${Object.keys(pairs).length > 0 ? ` [${attrs(pairs)}]` : ''}`
60const unique = <T>(items: readonly T[]): T[] => [...new Set(items)]
61const fnId = (name: string): string => (name === TOP ? 'top' : `fn:${name}`)
62const OPENABLE_STYLE = { color: `"${COLORS.openable}"`, penwidth: '"2"' }
63const wrap = (lines: readonly string[]): string => styleDot(`digraph g {\n${unique(lines).join('\n')}\n}`)
64
65const externalLines = (calls: readonly Call[]): string[] => {
66  const names = unique(calls.filter(call => call.kind === 'external').map(call => call.to))
67  const shown = new Set(names.slice(0, MAX_EXTERNALS))
68  const extra = names.length - shown.size
69  const style = { shape: 'cylinder', fillcolor: `"${COLORS.bg}"`, color: `"${COLORS.external}"`, fontcolor: `"${COLORS.externalText}"` }
70  return [
71    ...[...shown].map(name => nodeLine(`ext:${name}`, { label: name, ...style })),
72    ...(extra > 0 ? [nodeLine('ext:more', { label: `+${extra} more`, ...style })] : []),
73    ...calls
74      .filter(call => call.kind === 'external')
75      .map(call => edgeLine(fnId(call.from), shown.has(call.to) ? `ext:${call.to}` : 'ext:more', { style: 'dashed' })),
76  ]
77}
78
79const fileGraph = (file: FileSkeleton, onlyFn: string | null): Graph => {
80  const keep = (from: string): boolean => onlyFn === null || from === onlyFn
81  const calls = file.calls.filter(call => keep(call.from))
82  const exits = file.exits.filter(exit => keep(exit.from))
83  const deps = file.deps.filter(dep => keep(dep.from))
84  const ownDeps = deps.filter(dep => dep.resolved !== null)
85  const reached = new Set(calls.filter(call => call.kind === 'fn').map(call => call.to))
86  const fns = file.fns.filter(fn => onlyFn === null || fn.name === onlyFn || reached.has(fn.name))
87  const bigFns = fns
88    .filter(fn => fn.name !== onlyFn && fn.endLine - fn.startLine + 1 >= BIG_FN_LINES)
89    .sort((a, b) => b.endLine - b.startLine - (a.endLine - a.startLine))
90    .slice(0, MAX_FN_OPENABLE)
91  const openable: Openable[] =
92    ownDeps.length > 0
93      ? unique(ownDeps.map(dep => dep.resolved as string)).map((target, i) => ({ n: i + 1, label: base(target), target }))
94      : bigFns.map((fn, i) => ({ n: i + 1, label: fn.name, target: `${file.file}#${fn.name}` }))
95  const numberOf = (target: string): number | undefined => openable.find(o => o.target === target)?.n
96  const titled = (label: string, target: string) => {
97    const n = numberOf(target)
98    return n === undefined ? { label } : { label: `${circled(n)} ${label}`, ...OPENABLE_STYLE }
99  }
100  const lines = [
101    ...(onlyFn === null ? [nodeLine('top', { label: base(file.file), fillcolor: '"#45475a"' })] : []),
102    ...fns.map(fn => nodeLine(fnId(fn.name), titled(fn.name, `${file.file}#${fn.name}`))),
103    ...calls.filter(call => call.kind === 'fn').map(call => edgeLine(fnId(call.from), fnId(call.to))),
104    ...externalLines(calls),
105    ...exits.map(exit =>
106      nodeLine(`exit:${exit.code ?? '?'}`, {
107        label: `exit ${exit.code ?? '?'}`,
108        shape: 'oval',
109        color: `"${exit.code === 0 ? COLORS.ok : COLORS.fail}"`,
110      }),
111    ),
112    ...exits.map(exit => edgeLine(fnId(exit.from), `exit:${exit.code ?? '?'}`)),
113    ...deps.map(dep =>
114      nodeLine(`dep:${dep.spec}`, dep.resolved === null ? { label: base(dep.spec), shape: 'note' } : titled(base(dep.spec), dep.resolved)),
115    ),
116    ...deps.map(dep => edgeLine(fnId(dep.from), `dep:${dep.spec}`, { style: 'dashed' })),
117  ]
118  return { dot: wrap(lines), openable }
119}
120
121const relative = (root: string, path: string): string => (path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path)
122
123const dirGraph = (sk: Skeleton): Graph => {
124  const openable = sk.files.map((file, i) => ({ n: i + 1, label: relative(sk.root, file.file), target: file.file }))
125  const inSet = new Set(sk.files.map(file => file.file))
126  const lines = [
127    ...openable.map(o => nodeLine(`file:${o.target}`, { label: `${circled(o.n)} ${o.label}`, ...OPENABLE_STYLE })),
128    ...sk.files.flatMap(file =>
129      file.deps
130        .filter(dep => dep.resolved !== null && inSet.has(dep.resolved))
131        .map(dep => edgeLine(`file:${file.file}`, `file:${dep.resolved}`)),
132    ),
133    ...(sk.omitted > 0 || sk.truncated
134      ? [nodeLine('more', { label: `+${sk.omitted}${sk.truncated ? '+' : ''} more files`, shape: 'note' })]
135      : []),
136  ]
137  return { dot: wrap(lines), openable }
138}
139
140/**
141 * The literal graph of a skeleton; `focus` narrows it to one file or `${file}#${fn}`.
142 * @param {Skeleton} sk the skeleton to draw
143 * @param {string} [focus] a file path or `${file}#${fn}`
144 * @returns {Graph} the styled DOT and its openable boxes
145 */
146export const treeGraph = (sk: Skeleton, focus?: string): Graph => {
147  const [focusFile, focusFn] = (focus ?? '').split('#')
148  const file = sk.files.find(f => f.file === focusFile) ?? (sk.files.length === 1 ? sk.files[0] : undefined)
149  return file === undefined ? dirGraph(sk) : fileGraph(file, focusFn === undefined || focusFn === '' ? null : focusFn)
150}
151
152/**
153 * Node labels in order, for terminals that cannot draw the picture.
154 * @param {string} dot the DOT source
155 * @returns {string} labels joined with ` · `, cut to ALT_MAX characters
156 */
157export const altOutline = (dot: string): string => {
158  const labels = [...dot.matchAll(/^\s*"(?:[^"\\]|\\.)*"\s*\[[^\n]*?label="((?:[^"\\]|\\.)*)"/gm)].map(m =>
159    (m[1] ?? '').replace(/\\n/g, ' ').replace(/\\(.)/g, '$1'),
160  )
161  const names = labels.length > 0 ? labels : unique([...dot.matchAll(/"?([A-Za-z0-9_:./-]+)"?\s*->/g)].map(m => m[1] ?? ''))
162  const text = names.join(' · ')
163  return text.length > ALT_MAX ? `${text.slice(0, ALT_MAX - 1)}…` : text || 'graph'
164}
165
166/**
167 * The png a session renders into; one per session so sessions never clash.
168 * @param {string} cacheDir the plugin's cache dir
169 * @param {string} sessionTag the session's tag
170 * @returns {string} the png path
171 */
172export const pngPath = (cacheDir: string, sessionTag: string): string => `${cacheDir}/${sessionTag}.png`
173
174/**
175 * The cell box that shows a picture whole: cells are twice as tall as wide.
176 * @param {{ w: number; h: number }} px the picture's size in pixels
177 * @param {{ columns: number; rows: number }} room the cells available
178 * @returns {{ columns: number; rows: number }} the largest box that fits `room` at the picture's aspect
179 */
180export const fitBox = (
181  px: { w: number; h: number },
182  room: { columns: number; rows: number },
183): { columns: number; rows: number } => {
184  const rows = Math.round((room.columns * (px.h / px.w)) / 2)
185  return rows <= room.rows
186    ? { columns: room.columns, rows: Math.max(1, rows) }
187    : { columns: Math.max(1, Math.round((room.rows * 2 * px.w) / px.h)), rows: room.rows }
188}
189
hooks/lib/extract.ts 209 lines
1import type { Tools } from '../../types'
2import { fallbackRows } from './fallback'
3import { type Host, mapLimit, RUN_TIMEOUT_MS, safely } from './host'
4import { base, dirOf, normalize } from './path'
5import { RULES } from './rules'
6import { type AstRow, type FileSkeleton, fileSkeleton, type Lang, langOf, langOfShebang, type Skeleton, specsOf, toRows } from './skeleton'
7
8/** The most files a dir graph covers; the rest are counted as omitted. */
9export const MAX_FILES = 200
10/** The most source files a dir walk collects before it stops. */
11export const MAX_LISTED = 2000
12/** The most extractions (or probes) in flight at once. */
13export const EXTRACT_CONCURRENCY = 8
14/** The most dirs a dir walk lists before it stops. */
15export const MAX_VISITED_DIRS = 500
16/** Extension-less files over this many bytes are not probed for a shebang. */
17export const MAX_PROBE_BYTES = 64 * 1024
18const PROBE_BATCH = 64
19/** Dirs a walk never enters, besides dot-dirs. */
20export const IGNORED_DIRS: ReadonlySet<string> = new Set(['node_modules', 'dist', 'build', 'target', '.venv'])
21
22/** A step that failed: what to show the user. */
23export type Failure = { error: string }
24
25/**
26 * Tells a Failure from a result.
27 * @param {unknown} value a result or a Failure
28 * @returns {boolean} true when `value` is a Failure
29 */
30export const isFailure = (value: unknown): value is Failure =>
31  typeof value === 'object' && value !== null && 'error' in value
32
33const SCRIPT_TRIES = ['', '.ts', '.tsx', '.mts', '.js', '.mjs', '.jsx', '/index.ts', '/index.tsx', '/index.js']
34// An ESM-style `.js` spec names a `.ts` source: try these stems first.
35const JS_TO_TS: Readonly<Record<string, string>> = { '.js': '.ts', '.jsx': '.tsx', '.mjs': '.mts' }
36
37/**
38 * Resolves a relative dependency spec to an existing own file (never a dir), else null.
39 * @param {Host} host stats the candidate paths
40 * @returns {(fromFile: string, spec: string) => Promise<string | null>} the resolver: importing file and spec in, absolute file path or null out
41 */
42export const ownResolver =
43  (host: Host) =>
44  async (fromFile: string, spec: string): Promise<string | null> => {
45    if (!spec.startsWith('./') && !spec.startsWith('../')) return null
46    const target = normalize(`${dirOf(fromFile)}/${spec}`)
47    if (target.split('/').includes('node_modules')) return null
48    const jsExt = Object.keys(JS_TO_TS).find(ext => target.endsWith(ext))
49    const swapped = jsExt === undefined ? [] : [`${target.slice(0, -jsExt.length)}${JS_TO_TS[jsExt]}`]
50    const tries = langOf(fromFile) === 'bash' ? [target] : [...swapped, ...SCRIPT_TRIES.map(ext => target + ext)]
51    const isFile = async (path: string) => (await host.stat(path))?.kind === 'file'
52    const found = await Promise.all(tries.map(async path => ((await isFile(path)) ? path : null)))
53    return found.find(path => path !== null) ?? null
54  }
55
56/** A source file and its language. */
57export type SourceFile = { path: string; lang: Lang }
58
59/**
60 * The language of a file: by extension, else by shebang when the name has no `.`.
61 * @param {Host} host reads the file for the shebang probe
62 * @param {string} path absolute file path
63 * @returns {Promise<Lang | null>} the language, else null
64 */
65export const langOfFile = async (host: Host, path: string): Promise<Lang | null> =>
66  langOf(path) ?? (base(path).includes('.') ? null : langOfShebang(await host.read(path).catch(() => '')))
67
68/**
69 * The "no grammar" message for a file: its extension when it has one, else its name.
70 * @param {string} path file path
71 * @returns {string} the error text
72 */
73export const noGrammar = (path: string): string => {
74  const name = base(path)
75  return `no grammar for ${name.includes('.') ? name.slice(name.lastIndexOf('.')) : name}`
76}
77
78type Candidate = { path: string; size: number }
79
80/**
81 * The source files under a dir, ignored dirs skipped, sorted, capped at MAX_FILES. The walk stops at MAX_LISTED files or MAX_VISITED_DIRS dirs.
82 * @param {Host} host lists dirs and reads files
83 * @param {string} dir absolute dir path
84 * @returns {Promise<{ files: SourceFile[]; omitted: number; truncated: boolean }>} up to MAX_FILES files, how many more were found (a floor when truncated), and whether the walk stopped early
85 */
86export const listSourceFiles = async (
87  host: Host,
88  dir: string,
89): Promise<{ files: SourceFile[]; omitted: number; truncated: boolean }> => {
90  // Probes entries in batches, bounded, until `room` source files are found.
91  const probe = async (paths: readonly Candidate[], room: number, found: SourceFile[] = []): Promise<SourceFile[]> => {
92    if (paths.length === 0 || found.length >= room) return found.slice(0, room)
93    const batch = await mapLimit(paths.slice(0, PROBE_BATCH), EXTRACT_CONCURRENCY, async ({ path, size }) => ({
94      path,
95      lang: size > MAX_PROBE_BYTES && langOf(path) === null ? null : await langOfFile(host, path),
96    }))
97    const hits = batch.filter((file): file is SourceFile => file.lang !== null)
98    return probe(paths.slice(PROBE_BATCH), room, [...found, ...hits])
99  }
100  // Dirs listed so far, and whether the dir budget cut the walk short.
101  let listedDirs = 0, dirsCut = false
102  // Depth-first and in sequence: a dir's files, then its subdirs, each sorted.
103  const walk = async (at: string, room: number): Promise<SourceFile[]> => {
104    if (room <= 0) return []
105    if (listedDirs >= MAX_VISITED_DIRS) {
106      dirsCut = true
107      return []
108    }
109    listedDirs++
110    // An unreadable folder is skipped, not fatal.
111    const entries = [...(await host.list(at).catch(() => []))].sort((a, b) => a.name.localeCompare(b.name))
112    const here = await probe(
113      entries.filter(entry => entry.kind === 'file').map(entry => ({ path: `${at}/${entry.name}`, size: entry.size })),
114      room,
115    )
116    return entries
117      .filter(entry => entry.kind === 'dir' && !entry.name.startsWith('.') && !IGNORED_DIRS.has(entry.name))
118      .reduce(
119        async (acc, entry) => {
120          const so = await acc
121          return so.length >= room ? so : [...so, ...(await walk(`${at}/${entry.name}`, room - so.length))]
122        },
123        Promise.resolve(here),
124      )
125  }
126  const all = await walk(dir, MAX_LISTED)
127  return {
128    files: all.slice(0, MAX_FILES),
129    omitted: Math.max(0, all.length - MAX_FILES),
130    truncated: dirsCut || all.length >= MAX_LISTED,
131  }
132}
133
134const astGrepRows = async (host: Host, astGrep: string, file: string, lang: Lang): Promise<AstRow[] | Failure> => {
135  const { exitCode, stdout, stderr, isStdoutTruncated } = await host.run(
136    [astGrep, 'scan', '--inline-rules', RULES[lang], '--json=stream', file],
137    { stdin: '', timeoutMs: RUN_TIMEOUT_MS },
138  )
139  if (isStdoutTruncated) return { error: `${file} is too large to graph (ast-grep output over 4 MiB)` }
140  return exitCode !== 0 && stdout.trim() === '' ? { error: `ast-grep failed: ${stderr.split('\n')[0] ?? ''}` } : toRows(stdout)
141}
142
143/**
144 * One file's skeleton: ast-grep, else the tree-sitter CLI with a parser from `parserDirs`.
145 * @param {Host} host runs the tools and stats files
146 * @param {Tools} tools the resolved tool paths
147 * @param {string} file absolute file path
148 * @param {Lang} lang the file's language
149 * @param {readonly string[]} [parserDirs] dirs holding tree-sitter parsers
150 * @param {string} [missing] the error text when no tool can parse
151 * @returns {Promise<FileSkeleton | Failure>} the skeleton, else why not
152 */
153export const extractFile = async (
154  host: Host,
155  tools: Tools,
156  file: string,
157  lang: Lang,
158  parserDirs: readonly string[] = [],
159  missing = 'ast-grep not found',
160): Promise<FileSkeleton | Failure> => {
161  const astGrep = tools['ast-grep']
162  const rows = await safely<AstRow[] | Failure>(
163    () =>
164      astGrep === undefined
165        ? fallbackRows(host, tools, parserDirs, file, lang, missing)
166        : astGrepRows(host, astGrep, file, lang),
167    error => ({ error: `${file}: ${error}` }),
168  )
169  if (isFailure(rows)) return rows
170  const resolve = ownResolver(host)
171  const resolved = new Map(
172    await Promise.all(specsOf(rows, lang).map(async spec => [spec, await resolve(file, spec)] as const)),
173  )
174  return fileSkeleton(file, lang, rows, spec => resolved.get(spec) ?? null)
175}
176
177/**
178 * The skeleton of a file or a dir of source files.
179 * @param {Host} host runs the tools and lists dirs
180 * @param {Tools} tools the resolved tool paths
181 * @param {string} path absolute path of a file or dir
182 * @param {boolean} isDir whether `path` is a dir
183 * @param {readonly string[]} [parserDirs] dirs holding tree-sitter parsers
184 * @param {string} [missing] the error text when no tool can parse
185 * @returns {Promise<Skeleton | Failure>} the skeleton, else why not
186 */
187export const extractTarget = async (
188  host: Host,
189  tools: Tools,
190  path: string,
191  isDir: boolean,
192  parserDirs: readonly string[] = [],
193  missing = 'ast-grep not found',
194): Promise<Skeleton | Failure> => {
195  if (!isDir) {
196    const lang = await langOfFile(host, path)
197    if (lang === null) return { error: noGrammar(path) }
198    const file = await extractFile(host, tools, path, lang, parserDirs, missing)
199    return isFailure(file) ? file : { root: dirOf(path), files: [file], omitted: 0, truncated: false }
200  }
201  const { files, omitted, truncated } = await listSourceFiles(host, path)
202  if (files.length === 0) return { error: `no bash, TypeScript or JavaScript files under ${path}` }
203  const skeletons = await mapLimit(files, EXTRACT_CONCURRENCY, file => extractFile(host, tools, file.path, file.lang, parserDirs, missing))
204  const failed = skeletons.find(isFailure)
205  return failed !== undefined && skeletons.every(isFailure)
206    ? failed
207    : { root: path, files: skeletons.filter((sk): sk is FileSkeleton => !isFailure(sk)), omitted, truncated }
208}
209
hooks/lib/fallback.ts 169 lines
1import type { PluginOptions } from 'claude-code'
2
3import type { Tools } from '../../types'
4import type { Failure } from './extract'
5import { type Host, RUN_TIMEOUT_MS } from './host'
6import { dirOf } from './path'
7import type { AstRow, Lang } from './skeleton'
8
9/** How long the nvim probe for parser dirs may take, in ms. */
10const NVIM_TIMEOUT_MS = 10000
11
12type Pos = { line: number; column: number }
13type Node = { kind: string; field: string | null; start: Pos; end: Pos; children: Node[] }
14
15// A node: optional `field:`, `(`, kind (optionally `MISSING ` and a quoted token), optional range. ERROR/MISSING may lack a range.
16const OPEN =
17  /(?:([a-z_]+):\s*)?\((?:MISSING )?([A-Za-z_]+)(?: "(?:[^"\\]|\\.)*")?(?: \[(\d+), (\d+)\] - \[(\d+), (\d+)\])?|\)/g
18
19/** Parses `tree-sitter parse` output into a node tree. */
20const parseSexp = (sexp: string): Node[] => {
21  const root: Node = { kind: 'root', field: null, start: { line: 0, column: 0 }, end: { line: 0, column: 0 }, children: [] }
22  ;[...sexp.matchAll(OPEN)].reduce<Node[]>(
23    (stack, m) => {
24      if (m[0] === ')') return stack.slice(0, -1)
25      const parent = stack[stack.length - 1] ?? root
26      const ranged = m[3] !== undefined
27      const node: Node = {
28        kind: m[2] ?? '',
29        field: m[1] ?? null,
30        start: ranged ? { line: Number(m[3]), column: Number(m[4]) } : parent.start,
31        end: ranged ? { line: Number(m[5]), column: Number(m[6]) } : parent.end,
32        children: [],
33      }
34      parent.children.push(node)
35      return [...stack, node]
36    },
37    [root],
38  )
39  return root.children
40}
41
42const preorder = (nodes: readonly Node[]): Node[] => nodes.flatMap(node => [node, ...preorder(node.children)])
43
44const encoder = new TextEncoder()
45const decoder = new TextDecoder()
46
47/** The source text between two tree-sitter positions (columns are bytes). */
48const slicer = (source: string) => {
49  const lines = source.split('\n').map(line => encoder.encode(`${line}\n`))
50  const offsets = lines.reduce<number[]>((acc, line) => [...acc, (acc[acc.length - 1] ?? 0) + line.length], [0])
51  const bytes = encoder.encode(source)
52  return (start: Pos, end: Pos): string =>
53    decoder.decode(bytes.slice((offsets[start.line] ?? 0) + start.column, (offsets[end.line] ?? 0) + end.column))
54}
55
56const row = (ruleId: string, node: Node, slice: (a: Pos, b: Pos) => string): AstRow => ({
57  ruleId,
58  text: slice(node.start, node.end),
59  start: node.start,
60  end: node.end,
61})
62
63const ruleOf = (lang: Lang, node: Node, nameText: (node: Node) => string | null): string | null => {
64  if (lang === 'bash') {
65    if (node.kind === 'function_definition') return 'fn'
66    if (node.kind === 'command_name') return 'cmd'
67    if (node.kind !== 'command') return null
68    const name = nameText(node)
69    return name === 'exit' ? 'exit' : name === 'source' || name === '.' ? 'source' : null
70  }
71  if (node.kind === 'function_declaration') return 'fn'
72  if (node.kind === 'variable_declarator')
73    return node.children.some(child => child.field === 'value' && child.kind === 'arrow_function') ? 'fn' : null
74  return node.kind === 'call_expression' ? 'call' : node.kind === 'import_statement' ? 'import' : null
75}
76
77/**
78 * Rows matching rules.ts's rule ids, from `tree-sitter parse` output and the source.
79 * @param {string} sexp the `tree-sitter parse` output
80 * @param {string} source the file's text, for the node text
81 * @param {Lang} lang the file's language
82 * @returns {AstRow[]} rows in source order
83 */
84export const sexpToRows = (sexp: string, source: string, lang: Lang): AstRow[] => {
85  const slice = slicer(source)
86  const nameText = (command: Node): string | null => {
87    const name = command.children.find(child => child.kind === 'command_name')
88    return name === undefined ? null : slice(name.start, name.end)
89  }
90  return preorder(parseSexp(sexp)).flatMap(node => {
91    const ruleId = ruleOf(lang, node, nameText)
92    return ruleId === null ? [] : [row(ruleId, node, slice)]
93  })
94}
95
96/**
97 * Where parser .so files may be: the option, nvim's runtimepath, then XDG defaults.
98 * @param {{ option: string; nvimFiles: string; dataHome: string }} from the option value, nvim's newline-joined parser files, and the XDG data home
99 * @returns {string[]} unique, non-empty dirs in priority order
100 */
101export const parserDirsFrom = (from: { option: string; nvimFiles: string; dataHome: string }): string[] => [
102  ...new Set(
103    [
104      from.option,
105      ...from.nvimFiles
106        .split('\n')
107        .filter(line => line.endsWith('.so'))
108        .map(dirOf),
109      `${from.dataHome}/nvim/site/parser`,
110      `${from.dataHome}/nvim/lazy/nvim-treesitter/parser`,
111    ].filter(dir => dir !== ''),
112  ),
113]
114
115const NVIM_LUA = 'lua io.write(table.concat(vim.api.nvim_get_runtime_file("parser/*.so", true), "\\n"))'
116
117/**
118 * The parser dirs for this session: asks nvim for its runtimepath parsers, then adds the defaults.
119 * @param {Host} host runs nvim and reads env
120 * @param {Tools} tools the resolved tool paths
121 * @param {PluginOptions} options the plugin options (`nvimParserDir`)
122 * @returns {Promise<string[]>} parser dirs in priority order
123 */
124export const nvimParserDirs = async (host: Host, tools: Tools, options: PluginOptions): Promise<string[]> => {
125  const nvim = tools.nvim
126  const nvimFiles =
127    nvim === undefined
128      ? ''
129      : (await host.run([nvim, '--headless', '-c', NVIM_LUA, '-c', 'q'], { stdin: '', timeoutMs: NVIM_TIMEOUT_MS }).catch(
130          () => ({ stdout: '' }),
131        )).stdout
132  const home = (await host.env('HOME')) ?? ''
133  const dataHome = (await host.env('XDG_DATA_HOME')) || `${home}/.local/share`
134  return parserDirsFrom({ option: String(options.nvimParserDir ?? ''), nvimFiles, dataHome })
135}
136
137/**
138 * Rows for one file through the tree-sitter CLI and a prebuilt parser.
139 * @param {Host} host runs tree-sitter and reads the file
140 * @param {Tools} tools the resolved tool paths
141 * @param {readonly string[]} dirs dirs that may hold `<lang>.so`
142 * @param {string} file absolute file path
143 * @param {Lang} lang the file's language
144 * @param {string} [missing] the error text when tree-sitter is not installed
145 * @returns {Promise<AstRow[] | Failure>} the rows, else why not
146 */
147export const fallbackRows = async (
148  host: Host,
149  tools: Tools,
150  dirs: readonly string[],
151  file: string,
152  lang: Lang,
153  missing = 'ast-grep not found',
154): Promise<AstRow[] | Failure> => {
155  const treeSitter = tools['tree-sitter']
156  if (treeSitter === undefined) return { error: missing }
157  const found = await Promise.all(
158    dirs.map(async dir => ((await host.exists(`${dir}/${lang}.so`)) ? `${dir}/${lang}.so` : null)),
159  )
160  const so = found.find(path => path !== null)
161  if (so === undefined || so === null) return { error: `no ${lang} parser for tree-sitter` }
162  const { exitCode, stdout, stderr } = await host.run(
163    [treeSitter, 'parse', '--lib-path', so, '--lang-name', lang, file],
164    { stdin: '', timeoutMs: RUN_TIMEOUT_MS },
165  )
166  if (exitCode !== 0 && stdout.trim() === '') return { error: `tree-sitter failed: ${stderr.split('\n')[0] ?? ''}` }
167  return sexpToRows(stdout, await host.read(file), lang)
168}
169
hooks/lib/flow.ts 107 lines
1import type { Mode, Openable, View } from '../../types'
2import { titleOf } from './commands'
3import { emptyView } from './nav'
4
5/** The show_graph tool's short name. */
6export const SHOW_GRAPH = 'show_graph'
7/** The show_graph tool's full name, as Claude calls it. */
8export const SHOW_GRAPH_TOOL = 'mcp__graph-in-pane__show_graph'
9
10/** What show_graph tells Claude it does. */
11export const SHOW_GRAPH_DESCRIPTION =
12  "Show a Graphviz graph in the user's graph pane, docked beside the chat. kind \"plan\": the plan under discussion as boxes and arrows. kind \"flow\": a 10,000-foot flow of code, answering a /graph-flow request. Give a plain digraph with no styling; the pane applies its own top-down dark style."
13
14/** The JSON schema of show_graph's input. */
15export const SHOW_GRAPH_SCHEMA = {
16  type: 'object',
17  required: ['kind', 'title', 'dot'],
18  properties: {
19    kind: { enum: ['plan', 'flow'] },
20    title: { type: 'string' },
21    dot: { type: 'string', description: 'Graphviz digraph, no styling; the pane styles it' },
22    target: { type: 'string', description: 'flow only: the file or file#fn this graph is about' },
23    openable: {
24      type: 'array',
25      maxItems: 30,
26      items: {
27        type: 'object',
28        required: ['n', 'label', 'target'],
29        properties: { n: { type: 'integer', minimum: 1 }, label: { type: 'string' }, target: { type: 'string' } },
30      },
31    },
32  },
33} as const
34
35/** The graph_skeleton tool's name. */
36export const SKELETON = 'graph_skeleton'
37/** What graph_skeleton tells Claude it does. */
38export const SKELETON_DESCRIPTION =
39  'The tree-sitter skeleton of a bash, TypeScript or JavaScript file or dir as JSON: functions with line ranges, calls (to own functions or external commands), exit codes and dependencies, with own-file dependencies resolved. Use it to ground a flow graph of code in what the code actually does.'
40/** The JSON schema of graph_skeleton's input. */
41export const SKELETON_SCHEMA = {
42  type: 'object',
43  required: ['target'],
44  properties: { target: { type: 'string', description: 'absolute path of a file or dir' } },
45} as const
46
47/** The texts show_graph answers with, by outcome. */
48export const RESULT = {
49  shown: 'shown in the graph pane',
50  hidden: 'drawn; the pane is not visible (narrow terminal), the user was told to run /graph',
51  status: 'plan graph updated · /graph',
52  stored: 'saved as the latest plan graph; the user is browsing another graph and was told to run /graph',
53} as const
54
55/** A checked show_graph input. */
56export type ShowGraph = { kind: Extract<Mode, 'plan' | 'flow'>; title: string; dot: string; target: string; openable: Openable[] }
57
58/**
59 * The tool input checked; a string says what is wrong.
60 * @param {Record<string, unknown>} input the raw show_graph input
61 * @returns {ShowGraph | string} the checked graph, else the complaint
62 */
63export const parseShowGraph = (input: Record<string, unknown>): ShowGraph | string => {
64  const { kind, title, dot, target, openable } = input
65  if (kind !== 'plan' && kind !== 'flow') return 'kind must be "plan" or "flow"'
66  if (typeof title !== 'string' || title === '') return 'title must be a non-empty string'
67  if (typeof dot !== 'string' || !dot.includes('{')) return 'dot must be a Graphviz digraph'
68  const boxes = (Array.isArray(openable) ? openable : [])
69    .filter(
70      (o): o is Openable =>
71        typeof o === 'object' && o !== null && Number.isInteger(o.n) && typeof o.label === 'string' && typeof o.target === 'string',
72    )
73    .map(o => ({ n: o.n, label: o.label, target: o.target }))
74  return { kind, title, dot, target: typeof target === 'string' && target !== '' ? target : title, openable: boxes }
75}
76
77/**
78 * The placeholder view while Claude draws a /graph-flow.
79 * @param {string} target the file or dir being graphed
80 * @returns {View} an empty flow view marked waiting
81 */
82export const waitingView = (target: string): View => ({ ...emptyView('flow', titleOf(target), target), waiting: true })
83
84/**
85 * The line /graph-flow answers with once the skeleton is on its way.
86 * @param {string} target the file or dir being graphed
87 * @param {number} files how many source files it covers
88 * @returns {string} the summary line
89 */
90export const flowSummary = (target: string, files: number): string =>
91  `graph-flow: skeleton of ${target} sent to Claude (${files} file${files === 1 ? '' : 's'})`
92
93/**
94 * The request /graph-flow hands Claude, as the transcript shows it: the rules.
95 * @param {string} target the file or dir to graph
96 * @returns {string} the request text
97 */
98export const flowRequest = (target: string): string =>
99  [
100    `graph-in-pane /graph-flow request for ${target}.`,
101    'Draw its flow from 10,000 feet: 5–12 boxes for phases, top-down, failure exits as red ovals.',
102    `First call mcp__graph-in-pane__graph_skeleton with target "${target}" for its tree-sitter skeleton.`,
103    'Every arrow must be backed by that skeleton (an arrow may merge several calls); do not invent flow.',
104    "Number the boxes that stand for the user's own files or large functions (① ②…) and list them in `openable` with n, label and target (absolute file path, or file#function).",
105    `Call mcp__graph-in-pane__show_graph with kind "flow", target "${target}", a short title and the DOT (no styling). Then reply in at most two sentences.`,
106  ].join('\n')
107
hooks/lib/host.ts 100 lines
1import type { EngineInterface, FsEntry, ProcessRunInit, ProcessRunResult } from 'claude-code'
2
3/** The longest an ast-grep or tree-sitter run may take, in ms. */
4export const RUN_TIMEOUT_MS = 30000
5
6/** The environment variables the mod reads; `$.env.get` takes literal names. */
7export type EnvName = 'HOME' | 'XDG_CACHE_HOME' | 'XDG_DATA_HOME'
8
9/** The slice of the engine the lib modules use, so tests can hand in a fake. */
10export type Host = {
11  run: (argv: readonly string[], init?: ProcessRunInit) => Promise<ProcessRunResult>
12  exists: (path: string) => Promise<boolean>
13  env: (name: EnvName) => Promise<string | undefined>
14  list: (dir: string) => Promise<readonly FsEntry[]>
15  read: (path: string) => Promise<string>
16  /** A file's bytes, base64. */
17  bytes: (path: string) => Promise<string>
18  /** null when the path does not exist. */
19  stat: (path: string) => Promise<{ kind: 'file' | 'dir' | 'other'; mtimeMs: number } | null>
20}
21
22/**
23 * The engine's `$` as a Host. A function declaration: the engine follows `$` past an import only into one.
24 * @param {EngineInterface} $ the engine handed to a hook
25 * @returns {Host} process, fs and env access over `$`
26 */
27export function hostOf($: EngineInterface): Host {
28  return {
29    run: (argv, init) => $.process.run(argv, init),
30    exists: path => $.fs.exists(path),
31    list: dir => $.fs.list(dir),
32    read: path => $.fs.read(path),
33    bytes: async path => (await $.fs.read(path, { as: 'bytes' })).base64,
34    stat: async path => {
35      const stat = await $.fs.stat(path).catch(() => null)
36      return stat === null ? null : { kind: stat.kind, mtimeMs: stat.mtimeMs }
37    },
38    env: name =>
39      name === 'HOME'
40        ? $.env.get('HOME')
41        : name === 'XDG_CACHE_HOME'
42          ? $.env.get('XDG_CACHE_HOME')
43          : $.env.get('XDG_DATA_HOME'),
44  }
45}
46
47const RESULT = { stderr: '', isStdoutTruncated: false, isStderrTruncated: false }
48
49/**
50 * A process result, for fakes.
51 * @param {number} exitCode the exit code
52 * @param {string} [stdout] captured stdout
53 * @param {string} [stderr] captured stderr
54 * @returns {ProcessRunResult} a result with no truncation
55 */
56export const ran = (exitCode: number, stdout = '', stderr = ''): ProcessRunResult => ({
57  ...RESULT,
58  exitCode,
59  stdout,
60  stderr,
61})
62
63/**
64 * The message of anything thrown.
65 * @param {unknown} err the thrown value
66 * @returns {string} its `message` for an Error, else its string form
67 */
68export const messageOf = (err: unknown): string => (err instanceof Error ? err.message : String(err))
69
70/**
71 * Runs `work`; a rejection becomes `onError(message)` instead of escaping a hook.
72 * @param {() => Promise<T>} work the async job
73 * @param {(message: string) => T} onError builds the fallback from the error message
74 * @returns {Promise<T>} the job's result, else the fallback
75 */
76export const safely = async <T>(work: () => Promise<T>, onError: (message: string) => T): Promise<T> => {
77  try {
78    return await work()
79  } catch (err) {
80    return onError(messageOf(err))
81  }
82}
83
84/**
85 * Maps `items` through `fn` with at most `limit` calls in flight; results keep input order.
86 * @param {readonly T[]} items the inputs
87 * @param {number} limit the most calls running at once
88 * @param {(item: T) => Promise<R>} fn the async mapper
89 * @returns {Promise<R[]>} the results, in input order
90 */
91export const mapLimit = async <T, R>(items: readonly T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> => {
92  const out: R[] = new Array(items.length)
93  let next = 0
94  const worker = async (): Promise<void> => {
95    for (let i = next++; i < items.length; i = next++) out[i] = await fn(items[i] as T)
96  }
97  await Promise.all(Array.from({ length: Math.min(Math.max(1, limit), items.length) }, worker))
98  return out
99}
100
hooks/lib/nav.ts 83 lines
1import type { Mode, Nav, View } from '../../types'
2
3/** Openable boxes per page; the digits 1-9 are their hotkeys. */
4export const PAGE = 9
5const MAX_STACK = 20
6
7/** The breadcrumb's actions. */
8export type NavAction =
9  | { type: 'reset'; view: View }
10  | { type: 'push'; view: View }
11  | { type: 'pop' }
12  | { type: 'replaceTop'; view: View }
13  | { type: 'replaceIf'; was: View; view: View }
14  | { type: 'page' }
15
16/**
17 * A view with nothing drawn yet.
18 * @param {Mode} mode the view's mode
19 * @param {string} title the breadcrumb title
20 * @param {string} target what the view shows
21 * @returns {View} the blank view
22 */
23export const emptyView = (mode: Mode, title: string, target: string): View => ({
24  mode,
25  title,
26  target,
27  png: null,
28  gen: 0,
29  px: null,
30  alt: title,
31  openable: [],
32  page: 0,
33  error: null,
34  waiting: false,
35  mtimeMs: 0,
36})
37
38/**
39 * The view on top of the breadcrumb.
40 * @param {Nav} nav the breadcrumb
41 * @returns {View | undefined} the top view, else undefined when empty
42 */
43export const topOf = (nav: Nav): View | undefined => nav.stack[nav.stack.length - 1]
44
45const withTop = (nav: Nav, view: View): Nav =>
46  nav.stack.length === 0 ? { stack: [view] } : { stack: [...nav.stack.slice(0, -1), view] }
47
48const pages = (view: View): number => Math.max(1, Math.ceil(view.openable.length / PAGE))
49
50/**
51 * The breadcrumb's transitions.
52 * @param {Nav} nav the current breadcrumb
53 * @param {NavAction} action what happened
54 * @returns {Nav} the next breadcrumb
55 */
56export const navReduce = (nav: Nav, action: NavAction): Nav => {
57  const top = topOf(nav)
58  switch (action.type) {
59    case 'reset':
60      return { stack: [action.view] }
61    case 'push':
62      return { stack: [...nav.stack, action.view].slice(-MAX_STACK) }
63    case 'pop':
64      return nav.stack.length > 1 ? { stack: nav.stack.slice(0, -1) } : nav
65    case 'replaceTop':
66      return withTop(nav, action.view)
67    case 'replaceIf':
68      return top !== undefined && top.mode === action.was.mode && top.target === action.was.target && top.gen === action.was.gen
69        ? withTop(nav, action.view)
70        : nav
71    case 'page':
72      return top === undefined ? nav : withTop(nav, { ...top, page: (top.page + 1) % pages(top) })
73  }
74}
75
76/**
77 * The openable boxes on the shown page, with the hotkey each answers to.
78 * @param {View} view the view
79 * @returns {{ hotkey: string; n: number; label: string; target: string }[]} up to PAGE boxes
80 */
81export const pageOf = (view: View): { hotkey: string; n: number; label: string; target: string }[] =>
82  view.openable.slice(view.page * PAGE, view.page * PAGE + PAGE).map((o, i) => ({ ...o, hotkey: String(i + 1) }))
83
hooks/lib/pane.tsx 80 lines
1import type { Elements, RenderSurface } from 'claude-code'
2
3import type { Nav, View } from '../../types'
4import { fitBox } from './dot'
5import { PAGE, pageOf, topOf } from './nav'
6
7/** What the pane draws with: surface, width, rows and focus. */
8export type PaneCtx = { surface: RenderSurface; bodyColumns: number; rows: number; isFocused: boolean }
9/** The handlers the pane's buttons call. */
10export type PaneActions = { back: () => void; open: (n: number) => void; more: () => void }
11
12const EMPTY = 'No graph yet: /graph-tree or /graph-flow a file or dir.'
13const CHROME_ROWS = 6
14
15/**
16 * The pane's drawing for the breadcrumb's top view; pure, handlers passed in.
17 * @param {Elements[RenderSurface]} t the surface's element table
18 * @param {Nav} nav the breadcrumb
19 * @param {PaneCtx} ctx surface, size and focus
20 * @param {PaneActions} actions the button handlers
21 * @returns the pane's element tree
22 */
23export const paneTree = (t: Elements[RenderSurface], nav: Nav, ctx: PaneCtx, actions: PaneActions) => {
24  const { Box, Text, Button } = t
25  // Other surfaces' tables carry an Image that draws nothing: only the terminal paints one.
26  const Image = ctx.surface === 'terminal' && 'Image' in t ? t.Image : undefined
27  const view: View | undefined = topOf(nav)
28  if (view === undefined) return <Text dimColor>{EMPTY}</Text>
29
30  const keys = pageOf(view)
31  const hasKeys = keys.length > 0 || nav.stack.length > 1
32  const box = view.px === null ? null : fitBox(view.px, { columns: ctx.bodyColumns, rows: Math.max(4, ctx.rows - CHROME_ROWS) })
33  // An error over a picture that drew before (a bad save of a followed .dot) keeps the picture.
34  const picture =
35    view.error !== null && view.png === null ? (
36      <Text color="red">{view.error}</Text>
37    ) : view.waiting ? (
38      <Text dimColor>asking Claude…</Text>
39    ) : Image !== undefined && view.png !== null && box !== null ? (
40      <Image
41        source={{ file: view.png, format: 'png', generation: view.gen }}
42        columns={Math.min(255, box.columns)}
43        rows={Math.min(255, box.rows)}
44        alt={view.alt}
45      />
46    ) : (
47      <Text>{view.alt}</Text>
48    )
49
50  return (
51    <Box flexDirection="column">
52      <Box flexDirection="row" gap={1}>
53        {nav.stack.length > 1 && (
54          <Button key="back" hotkey="0" plain onPress={actions.back}>
55            back
56          </Button>
57        )}
58        <Text>{nav.stack.map(v => v.title).join(' › ')}</Text>
59      </Box>
60      {picture}
61      {view.error !== null && view.png !== null && <Text color="red">{view.error}</Text>}
62      {keys.length > 0 && (
63        <Box flexDirection="row" flexWrap="wrap" gap={2}>
64          {keys.map(k => (
65            <Button key={`open-${k.n}`} hotkey={k.hotkey} plain onPress={() => actions.open(k.n)}>
66              {k.label}
67            </Button>
68          ))}
69          {view.openable.length > PAGE && (
70            <Button key="more" hotkey="m" plain onPress={actions.more}>
71              more
72            </Button>
73          )}
74        </Box>
75      )}
76      {hasKeys && !ctx.isFocused && <Text dimColor>ctrl+x tab to use keys</Text>}
77    </Box>
78  )
79}
80
hooks/lib/pick.ts 180 lines
1import { IGNORED_DIRS } from './extract'
2import type { Host } from './host'
3import { base, normalize } from './path'
4import { langOf } from './skeleton'
5
6/** The commands whose argument is a file or dir. */
7export const PICK_COMMANDS: readonly string[] = ['graph-flow', '10kfo', 'graph-tree', '10kfv']
8/** The most typeahead rows, the most paths listed under a root, how deep a walk outside git goes. */
9export const MAX_ROWS = 12, MAX_CANDIDATES = 20000, WALK_DEPTH = 4
10/** The most near paths a not-found names. */
11export const MAX_NEAR = 3
12/** The longest a listing or an fzf run may take, in ms; how long a root's listing is reused. */
13export const PICK_TIMEOUT_MS = 2000, PICK_CACHE_MS = 15000
14
15/** Where to look and what to match: `prefix` is the typed part that named `root`, kept in what is written. */
16export type PickQuery = { root: string; prefix: string; query: string }
17
18/**
19 * Whether the token starting at `start` is the argument of a path-taking command.
20 * @param {string} text the prompt box's draft
21 * @param {number} start where the token at the cursor begins
22 * @returns {boolean} true for `/graph-flow <token>` and its siblings
23 */
24export const isPickToken = (text: string, start: number): boolean => {
25  const command = /^\/(\S+)\s+/.exec(text)
26  return command !== null && PICK_COMMANDS.includes(command[1] ?? '') && start >= command[0].length
27}
28
29const resolveTyped = (typed: string, cwd: string, home: string): string =>
30  normalize(typed.startsWith('~/') ? `${home}/${typed.slice(2)}` : typed.startsWith('/') ? typed : `${cwd}/${typed}`)
31
32/**
33 * Splits a token into the longest dir it names and the fuzzy query after it.
34 * @param {Host} host stats the candidate dirs
35 * @param {string} token the token at the cursor
36 * @param {string} cwd the base for a relative token
37 * @param {string} home what `~` stands for
38 * @returns {Promise<PickQuery | null>} the root and query, else null when only `/` would be the root
39 */
40export const pickRoot = async (host: Host, token: string, cwd: string, home: string): Promise<PickQuery | null> => {
41  const isAbsolute = token.startsWith('/') || token.startsWith('~/')
42  const cuts = [...(isAbsolute ? [] : [0]), ...[...token.matchAll(/\//g)].map(match => (match.index ?? 0) + 1)].sort((a, b) => b - a)
43  const found = await cuts.reduce<Promise<PickQuery | undefined>>(async (prior, cut) => {
44    const hit = await prior
45    if (hit !== undefined) return hit
46    const prefix = token.slice(0, cut)
47    const root = prefix === '' ? cwd : resolveTyped(prefix, cwd, home)
48    return (await host.stat(root))?.kind === 'dir' ? { root, prefix, query: token.slice(cut) } : undefined
49  }, Promise.resolve(undefined))
50  return found === undefined || found.root === '/' ? null : found
51}
52
53/**
54 * Files followed by every dir above them (as `dir/`), each once.
55 * @param {readonly string[]} files paths relative to a root
56 * @returns {string[]} the files, then their dirs in first-seen order
57 */
58export const withDirs = (files: readonly string[]): string[] => [
59  ...files,
60  ...new Set(
61    files.flatMap(file =>
62      file
63        .split('/')
64        .slice(0, -1)
65        .map((_, i, parts) => `${parts.slice(0, i + 1).join('/')}/`),
66    ),
67  ),
68]
69
70/**
71 * Whether a path can be graphed: a dir, a `.dot` file, a known language, or an extension-less script.
72 * @param {string} path a relative path, dirs ending in `/`
73 * @returns {boolean} true to offer it
74 */
75export const pickable = (path: string): boolean =>
76  path.endsWith('/') || path.endsWith('.dot') || langOf(path) !== null || !base(path).includes('.')
77
78const lines = (text: string): string[] => text.split('\n').filter(line => line !== '')
79
80const WALK_PRUNE = ['.*', ...IGNORED_DIRS].flatMap((name, i) => [...(i === 0 ? [] : ['-o']), '-name', name])
81
82/**
83 * The graphable paths under a root: `git ls-files` inside a repo, else a shallow `find`.
84 * @param {Host} host runs git and find
85 * @param {string} root an absolute dir
86 * @returns {Promise<string[]>} paths relative to `root`, dirs ending in `/`
87 */
88export const listCandidates = async (host: Host, root: string): Promise<string[]> => {
89  const init = { stdin: '', timeoutMs: PICK_TIMEOUT_MS }
90  const git = await host
91    .run(['git', '-C', root, '-c', 'core.quotePath=false', 'ls-files', '--cached', '--others', '--exclude-standard'], init)
92    .catch(() => null)
93  if (git !== null && git.exitCode === 0) return withDirs(lines(git.stdout)).filter(pickable).slice(0, MAX_CANDIDATES)
94  const walk = await host
95    .run(
96      [
97        'sh', '-c', 'find "$@" | head -n "$0"', String(MAX_CANDIDATES),
98        root, '-mindepth', '1', '-maxdepth', String(WALK_DEPTH), '(', ...WALK_PRUNE, ')', '-prune',
99        '-o', '-type', 'd', '-printf', '%P/\\n', '-o', '-type', 'f', '-printf', '%P\\n',
100      ],
101      init,
102    )
103    .catch(() => null)
104  return walk === null ? [] : lines(walk.stdout).filter(pickable)
105}
106
107const depthOf = (path: string): number => path.replace(/\/$/, '').split('/').length
108
109const byDepth = (a: string, b: string): number => depthOf(a) - depthOf(b) || a.localeCompare(b)
110
111/** Total gap between the query's letters in `path`, in order and ignoring case; null when they are not all there. */
112const gapScore = (path: string, query: string): number | null => {
113  const lower = path.toLowerCase()
114  const end = [...query.toLowerCase()].reduce<{ at: number; gaps: number } | null>((acc, ch) => {
115    if (acc === null) return null
116    const i = lower.indexOf(ch, acc.at + 1)
117    return i < 0 ? null : { at: i, gaps: acc.gaps + (acc.at < 0 ? 0 : i - acc.at - 1) }
118  }, { at: -1, gaps: 0 })
119  return end === null ? null : end.gaps
120}
121
122/**
123 * Subsequence matching for when fzf is not installed: tightest matches first, then shorter paths.
124 * @param {readonly string[]} paths the candidates
125 * @param {string} query the typed letters
126 * @returns {string[]} the matching paths, best first, at most MAX_ROWS
127 */
128export const fuzzyRank = (paths: readonly string[], query: string): string[] =>
129  paths
130    .map(path => ({ path, score: gapScore(path, query) }))
131    .filter((hit): hit is { path: string; score: number } => hit.score !== null)
132    .sort((a, b) => a.score - b.score || a.path.length - b.path.length)
133    .slice(0, MAX_ROWS)
134    .map(hit => hit.path)
135
136/**
137 * The best paths for a query: shallowest first when empty, else `fzf --filter`, else the built-in matcher.
138 * @param {Host} host runs fzf
139 * @param {string | undefined} fzf fzf's path, when installed
140 * @param {readonly string[]} paths the candidates
141 * @param {string} query the typed letters
142 * @returns {Promise<string[]>} at most MAX_ROWS paths, best first
143 */
144export const rank = async (host: Host, fzf: string | undefined, paths: readonly string[], query: string): Promise<string[]> => {
145  if (query === '') return [...paths].sort(byDepth).slice(0, MAX_ROWS)
146  if (fzf === undefined) return fuzzyRank(paths, query)
147  const ran = await host.run([fzf, '--filter', query], { stdin: paths.join('\n'), timeoutMs: PICK_TIMEOUT_MS }).catch(() => null)
148  // fzf exits 1 with no match; anything else falls back to the built-in matcher.
149  return ran === null || ran.exitCode > 1 ? fuzzyRank(paths, query) : lines(ran.stdout).slice(0, MAX_ROWS)
150}
151
152/**
153 * Paths a missing one may have meant: its missing part fuzzy-matched under the longest dir it names.
154 * @param {Host} host stats dirs and runs the listing and fzf
155 * @param {string | undefined} fzf fzf's path, when installed
156 * @param {string} path the absolute path that was not found
157 * @param {(host: Host, root: string) => Promise<readonly string[]>} [list] lists a root's candidates
158 * @returns {Promise<string[]>} at most MAX_NEAR absolute paths, best first
159 */
160export const nearPaths = async (
161  host: Host,
162  fzf: string | undefined,
163  path: string,
164  list: (host: Host, root: string) => Promise<readonly string[]> = listCandidates,
165): Promise<string[]> => {
166  const picked = await pickRoot(host, path, '/', '')
167  if (picked === null || picked.query === '') return []
168  const ranked = await rank(host, fzf, await list(host, picked.root), picked.query)
169  return ranked.slice(0, MAX_NEAR).map(near => normalize(`${picked.root}/${near}`))
170}
171
172/**
173 * The not-found error, naming the near paths when there are any.
174 * @param {string} path the path that was not found
175 * @param {readonly string[]} near the paths it may have meant
176 * @returns {string} the message for the pane, the command and Claude
177 */
178export const notFoundText = (path: string, near: readonly string[]): string =>
179  near.length === 0 ? `not found: ${path}` : `not found: ${path} · did you mean ${near.join(', ')}`
180
hooks/lib/plan.ts 56 lines
1import type { PlanState } from '../../types'
2
3/** The id of the plan section in the system prompt. */
4export const PLAN_SECTION_ID = 'graph-in-pane:plan'
5
6/** The system-prompt text that asks Claude to keep the plan graph up to date. */
7export const PLAN_TEXT =
8  'graph-in-pane: a planning conversation is active. Keep a top-down box-and-arrow picture of the plan under discussion in the user\'s side pane: call mcp__graph-in-pane__show_graph with kind "plan", a short title and a Graphviz digraph (no styling; the pane styles it). The tool only draws in the pane and changes no files, so it is allowed in plan mode. Call it as soon as the plan has a shape (in plan mode, before ExitPlanMode), and again when its boxes or arrows change, not every turn. Keep it under about 15 nodes with labels of a few words.'
9
10/** What can start or end a planning conversation. */
11export type PlanEvent =
12  | { type: 'skill' }
13  | { type: 'enterPlanTool' }
14  | { type: 'exitPlanTool' }
15  | { type: 'permissionMode'; mode: string | undefined }
16  | { type: 'clear' }
17  | { type: 'off' }
18
19/**
20 * Whether a skill starts a planning conversation.
21 * @param {string} skill the skill name, possibly `plugin:skill`
22 * @returns {boolean} true for brainstorming and writing-plans
23 */
24export const isPlanningSkill = (skill: string): boolean => /(^|:)(brainstorming|writing-plans)$/.test(skill)
25
26const on = (state: PlanState, source: 'skill' | 'planmode'): PlanState => ({ ...state, active: true, source })
27const off = (state: PlanState): PlanState => ({ ...state, active: false, source: null })
28
29/**
30 * When the plan section is in the system prompt; `latest` is kept throughout.
31 * @param {PlanState} state the current plan state
32 * @param {PlanEvent} event what happened
33 * @returns {PlanState} the next state
34 */
35export const planTransition = (state: PlanState, event: PlanEvent): PlanState => {
36  switch (event.type) {
37    case 'skill':
38      return on(state, 'skill')
39    case 'enterPlanTool':
40      return on(state, 'planmode')
41    case 'exitPlanTool':
42      return state.source === 'planmode' ? off(state) : state
43    case 'permissionMode':
44      return event.mode === 'plan'
45        ? state.active
46          ? state
47          : on(state, 'planmode')
48        : state.source === 'planmode'
49          ? off(state)
50          : state
51    case 'clear':
52    case 'off':
53      return off(state)
54  }
55}
56
hooks/lib/tools.ts 123 lines
1import type { PluginOptions } from 'claude-code'
2
3import type { ToolName, Tools } from '../../types'
4import type { Host } from './host'
5
6export type { ToolName, Tools }
7
8const TOOL_NAMES: readonly ToolName[] = ['dot', 'ast-grep', 'tree-sitter', 'nvim', 'unflatten', 'fzf']
9
10const OPTION_OF: Readonly<Partial<Record<ToolName, string>>> = {
11  dot: 'dotPath',
12  'ast-grep': 'astGrepPath',
13  'tree-sitter': 'treeSitterPath',
14}
15
16/**
17 * Where binaries live on common distros, after PATH: user installs, then distro packages (a PATH the hook does not see).
18 * @param {string} home the user's home dir
19 * @returns {readonly string[]} dirs to look in, in order
20 */
21export const candidateDirs = (home: string): readonly string[] => [
22  '/home/linuxbrew/.linuxbrew/bin',
23  `${home}/.linuxbrew/bin`,
24  `${home}/.cargo/bin`,
25  `${home}/.local/bin`,
26  '/usr/local/bin',
27  '/usr/bin',
28]
29
30const firstMatch = async <T>(
31  items: readonly T[],
32  test: (item: T) => Promise<string | undefined>,
33): Promise<string | undefined> =>
34  items.reduce<Promise<string | undefined>>(
35    async (found, item) => (await found) ?? test(item),
36    Promise.resolve(undefined),
37  )
38
39const onPath = async (host: Host, name: string): Promise<string | undefined> => {
40  const { exitCode, stdout } = await host.run(['sh', '-c', `command -v ${name}`])
41  const path = stdout.trim().split('\n')[0] ?? ''
42  return exitCode === 0 && path.startsWith('/') ? path : undefined
43}
44
45const existing = async (host: Host, path: string): Promise<string | undefined> =>
46  path !== '' && (await host.exists(path)) ? path : undefined
47
48const resolveOne = async (
49  host: Host,
50  options: PluginOptions,
51  home: string,
52  name: ToolName,
53): Promise<string | undefined> => {
54  const key = OPTION_OF[name]
55  const configured = key === undefined ? '' : String(options[key] ?? '')
56  return (
57    (await existing(host, configured)) ??
58    (await onPath(host, name)) ??
59    (await firstMatch(candidateDirs(home), dir => existing(host, `${dir}/${name}`)))
60  )
61}
62
63/**
64 * Finds each host tool: the option's path, then PATH, then common bin dirs.
65 * @param {Host} host runs `command -v` and checks paths
66 * @param {PluginOptions} options the plugin options (`dotPath`, `astGrepPath`, `treeSitterPath`)
67 * @returns {Promise<Tools>} the paths of the tools that were found
68 */
69export const resolveTools = async (host: Host, options: PluginOptions): Promise<Tools> => {
70  const home = (await host.env('HOME')) ?? ''
71  const found = await Promise.all(
72    TOOL_NAMES.map(async name => [name, await resolveOne(host, options, home, name)] as const),
73  )
74  return Object.fromEntries(found.filter(([, path]) => path !== undefined)) as Tools
75}
76
77const osField = (osRelease: string, field: string): string =>
78  osRelease
79    .split('\n')
80    .filter(line => line.startsWith(`${field}=`))
81    .map(line => line.slice(field.length + 1).replace(/^"|"$/g, ''))[0] ?? ''
82
83type Family = 'arch' | 'fedora' | 'debian' | 'other'
84
85const familyOf = (osRelease: string): Family => {
86  const ids = [osField(osRelease, 'ID'), ...osField(osRelease, 'ID_LIKE').split(' ')]
87  const known: readonly Family[] = ['arch', 'fedora', 'debian']
88  return known.find(family => ids.includes(family)) ?? (ids.includes('ubuntu') ? 'debian' : 'other')
89}
90
91const PORTABLE_AST_GREP = 'cargo install ast-grep  or  npm i -g @ast-grep/cli'
92const PORTABLE_TREE_SITTER = 'cargo install tree-sitter-cli  or  npm i -g tree-sitter-cli'
93
94const HINTS: Readonly<Record<Family, (hasBrew: boolean) => Readonly<Record<'dot' | 'ast-grep' | 'tree-sitter', string>>>> = {
95  arch: () => ({ dot: 'pacman -S graphviz', 'ast-grep': 'pacman -S ast-grep', 'tree-sitter': 'pacman -S tree-sitter-cli' }),
96  fedora: hasBrew =>
97    hasBrew
98      ? { dot: 'brew install graphviz', 'ast-grep': 'brew install ast-grep', 'tree-sitter': PORTABLE_TREE_SITTER }
99      : { dot: 'sudo dnf install graphviz', 'ast-grep': 'cargo install ast-grep', 'tree-sitter': PORTABLE_TREE_SITTER },
100  debian: () => ({ dot: 'sudo apt install graphviz', 'ast-grep': PORTABLE_AST_GREP, 'tree-sitter': PORTABLE_TREE_SITTER }),
101  other: hasBrew =>
102    hasBrew
103      ? { dot: 'brew install graphviz', 'ast-grep': 'brew install ast-grep', 'tree-sitter': PORTABLE_TREE_SITTER }
104      : { dot: 'install graphviz with your package manager', 'ast-grep': PORTABLE_AST_GREP, 'tree-sitter': PORTABLE_TREE_SITTER },
105}
106
107/**
108 * The install line for a missing tool on the distro /etc/os-release describes.
109 * ast-grep's names the tree-sitter CLI too: it is shown only when that is missing as well.
110 * @param {string} osRelease the text of /etc/os-release
111 * @param {boolean} hasBrew whether Homebrew is installed
112 * @param {ToolName} missing the tool that was not found
113 * @returns {string} the message to show
114 */
115export const installHint = (osRelease: string, hasBrew: boolean, missing: ToolName): string => {
116  const hints = HINTS[familyOf(osRelease)](hasBrew)
117  return missing === 'ast-grep'
118    ? `ast-grep not found. Install: ${hints['ast-grep']}  (or ${hints['tree-sitter']} to parse with nvim's tree-sitter parsers)`
119    : missing === 'dot'
120      ? `dot not found. Install: ${hints.dot}`
121      : `${missing} not found (optional).`
122}
123