SLOPSHOPPER

faro

Explains a diff or existing code as a short, research-grounded tour you walk in a pane beside the chat.

newpaneguardcommandtoolprocess
★ 3v0.1.0MITupdated 2026-10-25pmarchini/faro
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · faro
│ ┃ Faro tour ✕ › fix the failing auth test and add an audit log call │ ┃ o │ ┃ .-' '-. ⏺ Read(src/auth.ts) │ ┃ o' ^ 'o ⎿ Read 6 lines │ ┃ | [#] | ⏺ Update(src/auth.ts) │ ┃ | .-'o'-. | ⎿ Added 2 lines, removed 1 line │ ┃ o-' /#\ '-o ⏺ Bash(bun test) │ ┃ '-/###\-' ⎿ 3 pass, 1 fail │ ┃ =========== │ ┃ F A R O ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Drafting a tour │ ┃ your uncommitted changes ✻ Worked for 42s · done 4:20 PM │ ┃ 2 changed files │ ┃ The path appears here as soon as Claude › /explain │ ┃ writes it. ⎿ faro: Drafting a tour of your uncommitted changes. It opens in t │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Faro tour
o .-' '-. o' ^ 'o | [#] | | .-'o'-. | o-' /#\ '-o '-/###\-' =========== F A R O Drafting a tour your uncommitted changes 2 changed files The path appears here as soon as Claude writes it.
README

Faro

Faro is a VS Code extension for turning agent reasoning about a codebase into guided, navigable code beacons.

Why Faro

  • Author a path in chat with an agent.
  • Inspect the path in the Faro sidebar.
  • Reveal the matching code directly in the editor.

Screenshots

  • [Placeholder: Faro Home screenshot]
  • [Placeholder: Faro Path screenshot]
  • [Placeholder: Faro Setup screenshot]

Video

[Placeholder: short onboarding video or GIF]

Status

Faro is currently a local MVP.

It is not published to the VS Code Marketplace yet, so the current way to try it is:

  1. install the repo locally
  2. install the extension into your VS Code
  3. add the local agent artifacts for this workspace
  4. use Faro from VS Code Chat and the Faro sidebar

What You Need

  • Node.js 23.x or newer
  • npm
  • VS Code 1.109 or newer

Quick Start

Install dependencies:

npm install

Install the extension locally into your VS Code:

make install-local

Install the local Faro agent artifacts for this workspace:

make upsert-agent-local SCOPE=local

Open the repository in VS Code:

code .

If needed, run Developer: Reload Window once so VS Code picks up the local extension and generated agent files.

First Use

  1. Open the Chat view in VS Code.
  2. Select the Faro Path Author custom agent.
  3. Make sure the local Faro MCP server is enabled for the workspace.
  4. Ask for a path around a concrete code-reading goal.

Example:

Create a Faro path that explains how the main VS Code view is built and wired.
  1. Open the Faro activity bar item.
  2. Use the main Faro view:
  3. Home to enter the product
  4. Path to read the active path
  5. Setup to manage local/global agent integrations
  6. Use Prev, Next, and Reveal to walk the path in code.

What You Should See

  • the Faro activity bar item is visible
  • the main Faro view opens correctly
  • Home, Path, and Setup are available
  • the active path is visible in Path
  • Prev, Next, and Reveal work

Extension Development Host

If you want to test Faro in an Extension Development Host instead of installing it into your normal VS Code profile:

make dev-host

Expected local MVP behavior:

  • the Faro activity bar item is visible
  • the main Faro view opens correctly
  • Path shows an empty state until an agent-authored path exists
  • Prev, Next, and Reveal work from the path route
  • Setup shows local/global integration targets

Local Agent Artifacts

Workspace-local artifacts:

make upsert-agent-local SCOPE=local

This writes:

  • CLAUDE.md
  • .github/copilot-instructions.md
  • .github/agents/faro-path-author.agent.md
  • .codex/skills/faro-author-paths/SKILL.md

Global artifacts:

make upsert-agent-global SCOPE=global

If a Faro-owned global Copilot file already exists and differs, Faro refuses to overwrite it unless you opt in:

make upsert-agent-global SCOPE=global FORCE=1

Use Faro

Example prompts:

Create a Faro path that explains how the main VS Code view is built and wired.
Create a Faro path that traces the authentication flow from entrypoint to session state.
Onboard me to the setup flow in this extension as a Faro path.

Faro in Claude Code

Faro is also a Claude Code plugin. /explain asks Claude for a tour of your changes or of existing code, and you walk it in a pane beside the chat: a hypothesis up top, five to seven steps in reading order, and for each step what it does, why, the lines to look at, and the code itself. The explanation follows program-comprehension research; the rules and the bibliography are in skills/faro-author-paths/comprehension.md.

Install it from a Claude Code terminal session:

/plugin install faro --marketplace pmarchini/faro

Answer y to add the marketplace, then pick a scope. To try a local checkout instead, start Claude Code with claude --plugin-dir /path/to/faro.

Use it:

/explain                                   your uncommitted changes
/explain HEAD~3..HEAD                      a commit range
/explain feature/login                     a branch against its merge base
/explain #482 --goal "why do we retry?"    a pull request (needs gh)
/explain src/billing/ --lens domain        existing code
/explain "how does this plugin work?"      a question about a flow
/explain last                              reopen the last tour

Flags: --depth brief|standard|deep, --lens flow|domain, --reader new|familiar, --goal "...".

In the pane, a column beside the chat: a rail on the left holds the Faro mark, the steps (titles wrap) and the tour's files as a folder tree with the current step's file marked; the reader on the right shows the step's story (its headline wraps under its number, beside the plan as a chip) and its code, which fills the rest of the height. The code is centred on the current beacon, beacon lines are tinted and listed under the code (● for the current one), f folds the lines far from every beacon into "N lines folded", and a track on the right marks where the beacons are. Keys: j/k move along the path, e/u show more or less, l switches between the flow and domain lens, b jumps to the next beacon, n/p scroll the code, f folds or unfolds, o shows the whole file, c folds or opens the code panel, h hides or shows the rail, a puts the step's suggested question in the prompt, x asks Claude to re-explain the step more simply, r refreshes a step whose code changed, q closes. In a narrow pane the steps and files stack above the reader; in a pane shorter than 18 rows, such as VS Code's integrated terminal, they give way to a one-line breadcrumb (faro 3/6 ✓✓▸○○○) and t shows the steps. Resuming a session (claude --resume, --continue, or switching to it from the session picker) brings back its tour, where you were in it, and the pane if it was open. /tour pin [name] keeps a tour across sessions; /tour list, /tour open <id> and /tour drop <id> manage them.

The Graph view of a tour is planned for the next version. Design and decisions: Claude Code tour plugin.

Development

Core commands:

npm run check
npm test
npm run test:claude-code   # the Claude Code plugin's hooks, through `claude plugin test`
make dev-host

Troubleshooting

If Faro does not appear in VS Code:

  • reinstall the extension with make install-local
  • reload the VS Code window
  • confirm the extension is enabled

If the custom agent does not appear in Chat:

  • rerun make upsert-agent-local SCOPE=local
  • reload the VS Code window
  • check that .github/agents/faro-path-author.agent.md exists

If the MCP flow does not work:

  • confirm the local Faro MCP server is enabled for the workspace
  • verify the extension is active
  • verify npm run check and npm test

More Docs

Source 24 files
claude-code/register.tsx 30 lines
1import type { Register } from 'claude-code'
2
3import { registerCommands, serveCommands } from './command.ts'
4import { drawPathView } from './pane/path-view.tsx'
5import { restoreSession, serveResume, watchSessionSwitch } from './resume.ts'
6import { watchEdits } from './stale.ts'
7import { registerTools, serveTools } from './tools.ts'
8
9// The faro mod: tour_* tools Claude writes through, /explain and /tour for
10// the person, a pane with the Path view, and the tour kept across a resume.
11// The Graph view is v2.
12
13export const register: Register = on => {
14  on('session.start', async ($, e, next) => {
15    await registerTools($)
16    await registerCommands($)
17    const started = await next(e)
18    await restoreSession($)
19    await watchSessionSwitch($)
20
21    return started
22  })
23
24  serveTools(on)
25  serveCommands(on)
26  watchEdits(on)
27  drawPathView(on)
28  serveResume(on)
29}
30
claude-code/command.ts 279 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On } from 'claude-code'
3
4import type { Tour, TourTarget } from '../src/core/model/tour.ts'
5import { getTour, removeTour, upsertTour } from '../src/core/services/tour-machine.ts'
6import { loadCode } from './code-view.ts'
7import { USAGE, classifyTarget, parseExplainArgs, splitPathSymbol } from './explain-args.ts'
8import { openPane } from './pane/open.ts'
9import { authoringPrompt } from './prompts.ts'
10import { EMPTY_DOCUMENT, PINNED_KEY } from './state.ts'
11import { pinnedTours } from './tools.ts'
12
13const documentAtom = atom({ plugin: 'faro', key: 'document' } as const, EMPTY_DOCUMENT)
14const draftingAtom = atom({ plugin: 'faro', key: 'drafting' } as const, null)
15
16// `/explain [target] [flags]` resolves the target, opens the pane in its
17// drafting state and hands Claude the authoring prompt. `/tour` reopens,
18// pins, lists and drops tours.
19
20export async function registerCommands($: EngineInterface): Promise<void> {
21  await $.command.register({
22    name: 'explain',
23    description: 'Explain your changes, a commit range, a branch, a PR, a path, or a question about the code as a tour in a pane',
24    argumentHint: '[target] [--goal "..."] [--depth brief|standard|deep] [--lens flow|domain] [--reader new|familiar]',
25  })
26  await $.command.register({
27    name: 'tour',
28    description: 'Open the Faro tour pane, or pin, list and drop tours',
29    argumentHint: '[open [id] | pin [name] | list | drop <id>]',
30  })
31}
32
33export function serveCommands(on: On): void {
34  on('command.run', { command: 'explain' }, async ($, e) => {
35    const parsed = parseExplainArgs(e.args)
36
37    if ('error' in parsed) {
38      return { text: parsed.error }
39    }
40
41    const { request } = parsed
42
43    if (classifyTarget(request.target) === 'last') {
44      return { text: await reopen($) }
45    }
46
47    const resolved = await resolveTarget($, request.target)
48
49    if ('empty' in resolved) {
50      return { text: resolved.empty }
51    }
52
53    await update($, draftingAtom, () => ({ label: resolved.label, detail: resolved.detail }))
54    await openPane($)
55    const text = authoringPrompt(request, resolved.target, `${$.plugin.root}/skills/faro-author-paths`, resolved.notes)
56
57    // A command cannot submit while it holds the turn, so the prompt goes
58    // out from a timer once the command has answered.
59    $.clock.after(0, () => $.prompt.submit({ text }))
60
61    return { text: `Drafting a tour of ${resolved.label}. It opens in the Faro pane.` }
62  })
63
64  on('command.run', { command: 'tour' }, async ($, e) => {
65    const [verb = 'open', ...rest] = e.args.trim().split(/\s+/).filter(Boolean)
66    const name = rest.join('-') || undefined
67
68    switch (verb) {
69      case 'open':
70        return { text: await reopen($, name) }
71      case 'pin':
72        return { text: await pin($, name) }
73      case 'list':
74        return { text: await list($) }
75      case 'drop':
76        return { text: name ? await drop($, name) : 'Usage: /tour drop <id>' }
77      default:
78        return { text: USAGE_TOUR }
79    }
80  })
81}
82
83const USAGE_TOUR = 'Usage: /tour [open [id] | pin [name] | list | drop <id>]'
84
85type Resolved =
86  | { target: TourTarget; label: string; detail: string; notes: string[] }
87  | { empty: string }
88
89async function resolveTarget($: EngineInterface, text: string): Promise<Resolved> {
90  const kind = classifyTarget(text)
91
92  if (kind === 'worktree') {
93    const status = await git($, ['status', '--porcelain'])
94    const files = lines(status)
95
96    return files.length === 0
97      ? { empty: 'No changes to explain. Name a target instead. ' + USAGE }
98      : {
99          target: { kind: 'worktree', ref: 'HEAD' },
100          label: 'your uncommitted changes',
101          detail: `${files.length} changed file${files.length === 1 ? '' : 's'}`,
102          notes: ['Read the change with `git diff HEAD` and `git status` (untracked files count too).'],
103        }
104  }
105
106  if (kind === 'range') {
107    const files = lines(await git($, ['diff', '--name-only', text]))
108
109    return files.length === 0
110      ? { empty: `No changes in ${text}.` }
111      : {
112          target: { kind: 'range', ref: text },
113          label: text,
114          detail: `${files.length} changed file${files.length === 1 ? '' : 's'}`,
115          notes: [`Read the change with \`git diff ${text}\` and the stated intent with \`git log ${text}\`.`],
116        }
117  }
118
119  if (kind === 'pr') {
120    return {
121      target: { kind: 'pr', ref: text },
122      label: `pull request ${text}`,
123      detail: 'reading it with gh',
124      notes: [
125        `Read it with \`gh pr view ${text}\` (its description is the stated intent) and \`gh pr diff ${text}\`.`,
126        `Set target.baseSha and target.headSha from \`gh pr view ${text} --json baseRefOid,headRefOid\` when gh can answer.`,
127      ],
128    }
129  }
130
131  if (kind === 'question') {
132    return questionTarget(text)
133  }
134
135  const { path, symbol } = splitPathSymbol(text)
136
137  if (await exists($, path)) {
138    return {
139      target: { kind: 'code', ref: text },
140      label: text,
141      detail: symbol ? `${symbol} in ${path}` : `existing code under ${path}`,
142      notes: ['This is existing code with no diff: anchors carry ranges and no hunks, and most reasons will be inferred.'],
143    }
144  }
145
146  const head = (await git($, ['rev-parse', '--verify', '--quiet', `${text}^{commit}`])).trim()
147
148  if (head) {
149    const base = await defaultBranch($)
150    const mergeBase = (await git($, ['merge-base', base, text])).trim()
151    const files = mergeBase ? lines(await git($, ['diff', '--name-only', mergeBase, head])) : []
152
153    if (mergeBase && files.length === 0) {
154      return { empty: `${text} has no changes against ${base}.` }
155    }
156
157    return {
158      target: { kind: 'branch', ref: text, ...(mergeBase ? { baseSha: mergeBase } : {}), headSha: head },
159      label: `branch ${text}`,
160      detail: mergeBase ? `${files.length} changed files against ${base}` : `no merge base with ${base}`,
161      notes: [
162        mergeBase
163          ? `Read the change with \`git diff ${mergeBase} ${head}\` and the intent with \`git log ${mergeBase}..${head}\`.`
164          : `Find a base for ${text} yourself; git found no merge base with ${base}.`,
165      ],
166    }
167  }
168
169  // A bare word that is neither a path nor a git ref is read as a question.
170  return questionTarget(text)
171}
172
173function questionTarget(text: string): Resolved {
174  return {
175    target: { kind: 'question', ref: text },
176    label: `“${text}”`,
177    detail: 'finding the entry point and tracing the flow',
178    notes: ['This is existing code with no diff: anchors carry ranges and no hunks, and most reasons will be inferred.'],
179  }
180}
181
182async function reopen($: EngineInterface, id?: string): Promise<string> {
183  const document = await read($, documentAtom)
184  const live = getTour(document, id)
185
186  if (live) {
187    await update($, documentAtom, (current) => ({ ...current, activeTourId: live.id }))
188    await loadCode($, live)
189    await openPane($)
190    return `Showing tour ${live.id}.`
191  }
192
193  const pinned = await pinnedTours($)
194  const kept = id ? pinned[id] : Object.values(pinned).at(-1)
195
196  if (!kept) {
197    await openPane($)
198    return id ? `No tour ${id}. /tour list shows the tours you have.` : 'No tour yet. Run /explain to start.'
199  }
200
201  await update($, documentAtom, (current) => upsertTour(current, kept))
202  await loadCode($, kept)
203  await openPane($)
204
205  return `Showing pinned tour ${kept.id}.`
206}
207
208async function pin($: EngineInterface, name?: string): Promise<string> {
209  const tour = getTour(await read($, documentAtom))
210
211  if (!tour) {
212    return 'No tour is open to pin.'
213  }
214
215  const id = name ?? tour.id
216  const pinned = await pinnedTours($)
217  const kept: Tour = { ...tour, id }
218  await $.store.set(PINNED_KEY, { ...pinned, [id]: kept })
219
220  return `Pinned ${tour.title} as ${id}. It stays across sessions; /tour open ${id} brings it back.`
221}
222
223async function list($: EngineInterface): Promise<string> {
224  const document = await read($, documentAtom)
225  const pinned = await pinnedTours($)
226  const rows = [
227    ...document.tours.map((tour) => `${tour.id === document.activeTourId ? '▸' : ' '} ${tour.id}  ${tour.title}`),
228    ...Object.values(pinned).map((tour) => `  ${tour.id}  ${tour.title}  (pinned)`),
229  ]
230
231  return rows.length > 0 ? rows.join('\n') : 'No tours yet. Run /explain to start.'
232}
233
234async function drop($: EngineInterface, id: string): Promise<string> {
235  const pinned = await pinnedTours($)
236  const document = await read($, documentAtom)
237  const wasLive = document.tours.some((tour) => tour.id === id)
238
239  if (!pinned[id] && !wasLive) {
240    return `No tour ${id}.`
241  }
242
243  const { [id]: _dropped, ...rest } = pinned
244  await $.store.set(PINNED_KEY, rest)
245  await update($, documentAtom, (current) => removeTour(current, id))
246
247  return `Dropped ${id}.`
248}
249
250async function git($: EngineInterface, args: string[]): Promise<string> {
251  try {
252    const { exitCode, stdout } = await $.process.run(['git', ...args])
253    return exitCode === 0 ? stdout : ''
254  } catch {
255    return ''
256  }
257}
258
259async function defaultBranch($: EngineInterface): Promise<string> {
260  const remoteHead = (await git($, ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'])).trim()
261
262  if (remoteHead) return remoteHead
263
264  return (await git($, ['rev-parse', '--verify', '--quiet', 'main'])).trim() ? 'main' : 'master'
265}
266
267async function exists($: EngineInterface, path: string): Promise<boolean> {
268  try {
269    await $.fs.stat(path)
270    return true
271  } catch {
272    return false
273  }
274}
275
276function lines(text: string): string[] {
277  return text.split('\n').filter((line) => line.trim().length > 0)
278}
279
claude-code/pane/path-view.tsx 678 lines
1import { atom, read } from 'claude-code'
2import type { Elements, On, RenderChildren } from 'claude-code'
3
4import type { Tour, TourBeacon, TourNode, TourWhy } from '../../src/core/model/tour.ts'
5import { getTour, pathPosition } from '../../src/core/services/tour-machine.ts'
6import { codeWindow, foldRows, type VisualRow, type WindowBeacon } from '../code-window.ts'
7import { compactFiles, railFiles, type TreeFile } from '../file-tree.ts'
8import { EMPTY_DOCUMENT, EMPTY_READER, PANE } from '../state.ts'
9import type { FaroCodeRow as CodeRow, FaroCodeView as CodeView, FaroReader as ReaderState } from '../types'
10import { runAction, selectNode } from './actions.ts'
11import { PANE_KEYS } from './keys.ts'
12import { LOGO_ROWS, LOGO_WIDTH, WORDMARK, logoSegments } from './logo.ts'
13import {
14  ACCENT,
15  ACCENT_SOFT,
16  ADDED,
17  BEACON_ROW,
18  BORDER,
19  CHIP,
20  CURRENT_STEP_ROW,
21  CURRENT_BEACON_ROW,
22  FOLD_ROW,
23  MARK,
24  MARK_LIGHT,
25  RAIL,
26  REMOVED,
27  SUMMARY,
28} from './theme.ts'
29
30const codeAtom = atom({ plugin: 'faro', key: 'code' } as const, null)
31const documentAtom = atom({ plugin: 'faro', key: 'document' } as const, EMPTY_DOCUMENT)
32const draftingAtom = atom({ plugin: 'faro', key: 'drafting' } as const, null)
33const readerAtom = atom({ plugin: 'faro', key: 'reader' } as const, EMPTY_READER)
34
35// Below this many rows (VS Code's integrated terminal, typically) the pane
36// drops the rail for a one-line breadcrumb.
37const SHORT_ROWS = 18
38// The rail of steps and files beside the reader, from this pane width.
39const RAIL_COLUMNS = 30
40const RAIL_FROM_COLUMNS = 90
41// The width of a story field's label, so its text wraps under itself.
42const FIELD_WIDTH = 11
43// The code card's border and padding, in rows and columns.
44const CARD_ROWS = 2
45const CARD_COLUMNS = 4
46const MIN_CODE_ROWS = 6
47
48type Ui = Elements[keyof Elements]
49type Field = { label: string; text: string; color?: string; isSummary?: boolean }
50
51// The Path view, a column beside the chat: a one-line header; a rail with
52// the Faro mark, the steps and the tour's files (`f` hides it); and the
53// reader, with the current step's story and its code filling the rest of
54// the height, folded and windowed around the current beacon, the beacons
55// listed under it.
56
57export function drawPathView(on: On): void {
58  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
59    const ui = $.ui.resolve(e)
60    const { Box, Text, Button } = ui
61    const document = await read($, documentAtom)
62    const drafting = await read($, draftingAtom)
63    const tour = getTour(document)
64    const width = Math.max(20, e.props.bodyColumns)
65    const rule = '─'.repeat(width)
66
67    if (drafting) {
68      return (
69        <Box flexDirection="column">
70          {logo(ui)}
71          <Text bold>Drafting a tour</Text>
72          <Text>{drafting.label}</Text>
73          <Text dimColor>{drafting.detail}</Text>
74          <Text dimColor>The path appears here as soon as Claude writes it.</Text>
75        </Box>
76      )
77    }
78
79    if (!tour) {
80      return (
81        <Box flexDirection="column">
82          {logo(ui)}
83          <Text>No tour yet.</Text>
84          <Text dimColor>Run /explain to explain your changes, or /explain "how does X work?" for existing code.</Text>
85        </Box>
86      )
87    }
88
89    const reader = await read($, readerAtom)
90    const code = await read($, codeAtom)
91    const rows = e.props.scroll.bodyRows
92    const isShort = rows < SHORT_ROWS
93    const isRail = !isShort && width >= RAIL_FROM_COLUMNS && !reader.isFileTreeHidden
94    const readerWidth = isRail ? width - RAIL_COLUMNS - 1 : width
95    const node = tour.current.nodeId ? tour.nodes[tour.current.nodeId] : undefined
96    const { index, total } = pathPosition(tour)
97    const stale = reader.stale[tour.id] ?? []
98    const isCodeFolded = isShort ? !reader.isCodeToggled : reader.isCodeToggled
99    const depth = tour.current.depth
100    const keys = PANE_KEYS.filter(
101      (key) =>
102        (key.action !== 'refresh' || (node && stale.includes(node.id))) &&
103        (key.action !== 'files' || !isShort) &&
104        (key.action !== 'steps' || isShort) &&
105        (!['fold', 'scrollDown', 'scrollUp'].includes(key.action) || !isCodeFolded),
106    )
107    const footer = (
108      <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
109        {keys.map((key) => (
110          <Button key={`key-${key.action}`} plain hotkey={key.hotkey} label={key.label} onPress={() => runAction($, key.action)} />
111        ))}
112      </Box>
113    )
114    const fields = node ? storyFields(tour, node, reader, depth, stale.includes(node.id)) : []
115    const story = node && storyPanel(ui, tour, node, reader, fields, readerWidth)
116    const plainSteps = tour.path.map((id, position) => {
117      const entry = tour.nodes[id]
118
119      return entry ? (
120        <Button key={`node-${id}`} plain onPress={() => selectNode($, id)}>
121          {stepRow(entry, position, tour, reader, width)}
122        </Button>
123      ) : null
124    })
125
126    if (isShort) {
127      return (
128        <Box flexDirection="column">
129          <Text wrap="truncate-end">
130            {mark(ui)} {index + 1}/{total} {progressMarks(tour, reader)}
131            <Text dimColor>
132              {'  '}
133              {isCodeTour(tour) ? 'code tour · ' : ''}
134              {tour.lens}
135            </Text>
136          </Text>
137          {reader.isStepListShown && plainSteps}
138          {story}
139          {node && code && code.nodeId === node.id && foldedCode(ui, node, reader, code)}
140          {footer}
141        </Box>
142      )
143    }
144
145    // The rows the code may take: the pane's height less everything else.
146    const stackedRows = isRail ? 0 : tour.path.length + 1 + (reader.isFileTreeHidden ? 1 : compactFiles(treeFiles(tour, reader)).length + 1)
147    const beaconRows = node ? beaconListRows(node, readerWidth) + 1 : 0
148    const footerRows = Math.ceil(keys.reduce((sum, key) => sum + key.label.length + 5, 0) / width)
149    const codeRows = Math.max(
150      MIN_CODE_ROWS,
151      rows - 2 - headRows(tour, width) - stackedRows - storyRows(node, fields, readerWidth) - 1 - beaconRows - footerRows - CARD_ROWS,
152    )
153    const codeSection =
154      node &&
155      code &&
156      code.nodeId === node.id &&
157      (isCodeFolded ? foldedCode(ui, node, reader, code) : codePanel(ui, node, reader, code, codeRows, readerWidth))
158    const header = (
159      <Text>
160        {mark(ui)}
161        {'  '}
162        <Text bold>{tour.title}</Text>
163        {isCodeTour(tour) ? '  ' : ''}
164        <Text color="suggestion" backgroundColor={isCodeTour(tour) ? CHIP : undefined}>
165          {isCodeTour(tour) ? ` code tour${tour.target.kind === 'code' && tour.target.ref ? ` · ${tour.target.ref}` : ''} ` : ''}
166        </Text>
167        <Text dimColor>
168          {'  '}
169          {tour.lens}
170          {'  '}
171        </Text>
172        {index + 1}/{total}
173      </Text>
174    )
175
176    if (!isRail) {
177      return (
178        <Box flexDirection="column">
179          {header}
180          <Text dimColor>
181            {tour.hypothesis}
182          </Text>
183          <Text dimColor>{rule}</Text>
184          <Text dimColor>STEPS</Text>
185          {plainSteps}
186          <Text dimColor>{reader.isFileTreeHidden ? 'FILES  (h shows)' : 'FILES  (h hides)'}</Text>
187          {!reader.isFileTreeHidden &&
188            compactFiles(treeFiles(tour, reader)).map((row) => (
189              <Text bold={row.isCurrent} dimColor={row.isDim} wrap="truncate-end">
190                {row.text}
191              </Text>
192            ))}
193          <Text dimColor>{rule}</Text>
194          {story}
195          {codeSection}
196          <Text dimColor>{rule}</Text>
197          {footer}
198        </Box>
199      )
200    }
201
202    return (
203      <Box flexDirection="column">
204        {header}
205        <Text dimColor>
206          {tour.hypothesis}
207        </Text>
208        <Text dimColor>{rule}</Text>
209        <Box flexDirection="row" columnGap={1}>
210          <Box flexDirection="column" width={RAIL_COLUMNS} flexShrink={0} backgroundColor={RAIL}>
211            <Box marginLeft={Math.floor((RAIL_COLUMNS - LOGO_WIDTH) / 2)}>{logo(ui)}</Box>
212            <Text dimColor>STEPS</Text>
213            {tour.path.map((id, position) => {
214              const entry = tour.nodes[id]
215
216              return entry ? railStep(ui, entry, position, tour, reader, () => selectNode($, id)) : null
217            })}
218            <Text dimColor> </Text>
219            <Text dimColor>FILES  (h hides)</Text>
220            {railFiles(treeFiles(tour, reader)).map((row) => (
221              <Text bold={row.isCurrent} color={row.isCurrent ? ACCENT : undefined} dimColor={row.isDim} wrap="truncate-end">
222                {row.text}
223              </Text>
224            ))}
225          </Box>
226          <Box flexDirection="column" width={readerWidth} flexShrink={1}>
227            {story}
228            {codeSection}
229          </Box>
230        </Box>
231        <Text dimColor>{rule}</Text>
232        {footer}
233      </Box>
234    )
235  })
236}
237
238// The Faro mark, from media/faro.svg, with the name under it.
239function logo({ Box, Text }: Ui) {
240  return (
241    <Box flexDirection="column" flexShrink={0} width={LOGO_WIDTH}>
242      {LOGO_ROWS.map((row) => (
243        <Text>
244          {logoSegments(row).map((segment) => (
245            <Text bold={segment.isLight} color={segment.isLight ? MARK_LIGHT : MARK}>
246              {segment.text}
247            </Text>
248          ))}
249        </Text>
250      ))}
251      <Text bold color={MARK_LIGHT}>
252        {WORDMARK}
253      </Text>
254    </Box>
255  )
256}
257
258// The mark in one line, for the header: o-^-o faro.
259function mark({ Text }: Ui) {
260  return (
261    <Text bold>
262      <Text color={MARK}>o-</Text>
263      <Text color={MARK_LIGHT}>^</Text>
264      <Text color={MARK}>-o</Text>
265      <Text color={MARK_LIGHT}> faro</Text>
266    </Text>
267  )
268}
269
270// Each file the tour anchors in, with the path steps that read it.
271function treeFiles(tour: Tour, reader: ReaderState): TreeFile[] {
272  const files = new Map<string, TreeFile>()
273  const visited = reader.visited[tour.id] ?? []
274  const current = tour.current.nodeId
275
276  for (const node of Object.values(tour.nodes)) {
277    const step = tour.path.indexOf(node.id) + 1
278
279    for (const { file } of node.anchors) {
280      const entry = files.get(file) ?? { file, steps: [], isCurrent: false, isVisited: false }
281
282      if (step > 0 && !entry.steps.includes(step)) entry.steps.push(step)
283      entry.isCurrent ||= node.id === current
284      entry.isVisited ||= visited.includes(node.id)
285      files.set(file, entry)
286    }
287  }
288
289  return [...files.values()].map((entry) => ({ ...entry, steps: entry.steps.sort((a, b) => a - b) }))
290}
291
292function stepState(node: TourNode, tour: Tour, reader: ReaderState) {
293  const isCurrent = tour.current.nodeId === node.id
294  const isVisited = (reader.visited[tour.id] ?? []).includes(node.id)
295  const isStale = (reader.stale[tour.id] ?? []).includes(node.id)
296
297  return {
298    isCurrent,
299    mark: isCurrent ? '▸' : isVisited ? '✓' : ' ',
300    flags: `${node.surprise ? ' !' : ''}${isStale ? ' ~' : ''}`,
301  }
302}
303
304// A step in the rail: its title wraps under itself, beside mark and number.
305function railStep(
306  { Box, Text, Button }: Ui,
307  node: TourNode,
308  position: number,
309  tour: Tour,
310  reader: ReaderState,
311  onPress: () => Promise<void>,
312) {
313  const { isCurrent, mark, flags } = stepState(node, tour, reader)
314
315  return (
316    <Box flexDirection="row" width={RAIL_COLUMNS}>
317      <Box width={5} flexShrink={0}>
318        <Text color={isCurrent ? ACCENT : undefined} dimColor={!isCurrent}>
319          {`${mark} ${String(position + 1).padStart(2)} `}
320        </Text>
321      </Box>
322      <Box flexShrink={1}>
323        <Button key={`node-${node.id}`} plain onPress={onPress}>
324          <Text bold={isCurrent} backgroundColor={isCurrent ? CURRENT_STEP_ROW : undefined}>
325            {node.title}
326          </Text>
327          <Text color="warning">{flags}</Text>
328        </Button>
329      </Box>
330    </Box>
331  )
332}
333
334// A step on one line, for a narrow pane: the title cut with an ellipsis to
335// leave room for the flags and the file, which stay whole.
336function stepRow(node: TourNode, position: number, tour: Tour, reader: ReaderState, width: number): string {
337  const { mark, flags } = stepState(node, tour, reader)
338  const file = node.anchors[0]?.file.split('/').slice(-2).join('/') ?? ''
339  const head = `${mark} ${String(position + 1).padStart(2)}  `
340  const tail = `${flags}  ${file}`
341  const room = Math.max(8, width - head.length - tail.length)
342  const title = node.title.length > room ? `${node.title.slice(0, room - 1)}…` : node.title.padEnd(room)
343
344  return `${head}${title}${tail}`.slice(0, width)
345}
346
347// The story's fields at the reader's depth, in the lens the pane shows.
348function storyFields(tour: Tour, node: TourNode, reader: ReaderState, depth: number, isStale: boolean): Field[] {
349  const isCode = isCodeTour(tour)
350  const otherLens = tour.lens === 'flow' ? 'domain' : 'flow'
351  const lensText = reader.isOtherLensShown ? node.otherLens : node
352  const fields: Field[] = []
353  const add = (label: string, text: string | undefined, color?: string, isSummary = false) => {
354    if (text) fields.push({ label, text, ...(color ? { color } : {}), ...(isSummary ? { isSummary } : {}) })
355  }
356
357  if (reader.isOtherLensShown) {
358    add('', `${otherLens} lens`)
359  }
360
361  if (reader.isOtherLensShown && !node.otherLens) {
362    add('', `Writing the ${otherLens} lens for this step…`)
363    return fields
364  }
365
366  if (isStale) {
367    add('~', 'The code under this step changed since the tour was written. Press r to refresh it.', 'warning')
368  }
369
370  if (depth === 0) {
371    add('', lensText?.summary ?? node.summary, undefined, true)
372  }
373
374  add('! Surprise', node.surprise, 'warning')
375
376  if (depth >= 1) {
377    add('Confirms', node.confirms)
378    add(isCode ? 'Does' : 'What', lensText?.whatChanged ?? lensText?.whatItDoes)
379    add('Why', whyText(lensText?.why, isCode))
380    add(isCode ? 'Carry on' : 'Carry', node.carryForward, ACCENT_SOFT)
381  }
382
383  if (depth >= 2) {
384    add('Detail', node.detail)
385    add('Ask next', node.suggestedQuestion)
386  }
387
388  if (depth < 2) {
389    add('', `Press e for more (${depth === 0 ? 'what, why, carry' : 'detail, next question'}).`)
390  }
391
392  return fields
393}
394
395// The headline wraps with a hanging indent under its number, beside the
396// plan as a chip; each field's text wraps under itself, not its label.
397function storyPanel({ Box, Text }: Ui, tour: Tour, node: TourNode, reader: ReaderState, fields: Field[], width: number) {
398  const number = `${tour.path.indexOf(node.id) + 1}`.padEnd(3)
399  const chip = chipText(tour, node)
400
401  return (
402    <Box flexDirection="column" paddingLeft={1}>
403      <Box flexDirection="row">
404        <Box width={number.length} flexShrink={0}>
405          <Text bold color={ACCENT}>
406            {number}
407          </Text>
408        </Box>
409        <Box flexShrink={1} width={Math.max(10, width - number.length - chip.length - 2)}>
410          <Text bold>{node.title}</Text>
411        </Box>
412        <Box flexShrink={0} marginLeft={1}>
413          <Text dimColor backgroundColor={CHIP}>{chip}</Text>
414        </Box>
415      </Box>
416      {fields.map((field) =>
417        field.label ? (
418          <Box flexDirection="row">
419            <Box width={FIELD_WIDTH} flexShrink={0}>
420              <Text dimColor color={field.color === 'warning' ? 'warning' : undefined}>
421                {field.label}
422              </Text>
423            </Box>
424            <Box flexShrink={1}>
425              <Text color={field.color}>{field.text}</Text>
426            </Box>
427          </Box>
428        ) : (
429          <Box marginLeft={number.length} {...(field.isSummary ? { backgroundColor: SUMMARY, paddingX: 1 } : {})}>
430            <Text dimColor={field.text.startsWith('Press e')}>{field.text}</Text>
431          </Box>
432        ),
433      )}
434    </Box>
435  )
436}
437
438function chipText(tour: Tour, node: TourNode): string {
439  return node.plan ? ` ${isCodeTour(tour) ? 'role: ' : ''}${node.plan} ` : ''
440}
441
442// The rows the header and the hypothesis take once wrapped: neither is cut,
443// so a long title or hypothesis is read whole.
444function headRows(tour: Tour, width: number): number {
445  const header = `o-^-o faro  ${tour.title}  code tour  ${tour.lens}  00/00`
446  return Math.ceil(header.length / width) + Math.ceil(tour.hypothesis.length / width)
447}
448
449// How many rows the story takes once wrapped, to size the code under it.
450function storyRows(node: TourNode | undefined, fields: Field[], width: number): number {
451  if (!node) return 0
452
453  const wrapped = (text: string, room: number) => Math.max(1, Math.ceil(text.length / Math.max(10, room)))
454
455  return (
456    wrapped(node.title, width - 4 - node.plan.length - 8) +
457    fields.reduce((sum, field) => sum + wrapped(field.text, width - (field.label ? FIELD_WIDTH : field.isSummary ? 5 : 3) - 1), 0)
458  )
459}
460
461// The code panel folded to one line: where the code is, and how to open it.
462function foldedCode({ Text }: Ui, node: TourNode, reader: ReaderState, code: CodeView) {
463  const count = node.beacons.length
464  const which = count > 1 ? ` · beacon ${(reader.beaconIndex % count) + 1} of ${count}` : ''
465
466  return (
467    <Text dimColor wrap="truncate-end">
468      {code.path} {code.heading}
469      {which}
470      {'   '}c shows the code
471    </Text>
472  )
473}
474
475function progressMarks(tour: Tour, reader: ReaderState): string {
476  const visited = reader.visited[tour.id] ?? []
477
478  return tour.path.map((id) => (id === tour.current.nodeId ? '▸' : visited.includes(id) ? '✓' : '○')).join('')
479}
480
481// The code, folded and windowed around the current beacon, a scroll track
482// beside it with the beacons ticked, and the step's beacons listed under.
483function codePanel(ui: Ui, node: TourNode, reader: ReaderState, code: CodeView, height: number, width: number) {
484  const { Box, Text } = ui
485  const count = node.beacons.length
486  const current = count > 0 ? reader.beaconIndex % count : -1
487  const beacons: WindowBeacon[] = node.beacons
488    .map((beacon, index) => ({ beacon, index }))
489    .filter(({ beacon }) => beacon.file === code.path)
490    .map(({ beacon, index }) => ({ line: beacon.line, index, isCurrent: index === current }))
491  const view = codeWindow(foldRows(code.rows, beacons, !reader.isCodeFolded), height, reader.codeOffset)
492  const numbers = view.rows.flatMap((row) => (row.kind === 'row' && row.row.number !== null ? [row.row.number] : []))
493  const gutter = Math.max(2, ...numbers.map((number) => String(number).length))
494
495  return (
496    <Box flexDirection="column" borderStyle="round" borderColor={BORDER} paddingX={1}>
497      <Box flexDirection="row" columnGap={2}>
498        <Box flexGrow={1} flexShrink={1}>
499          <Text wrap="truncate-end">
500            <Text bold>{code.path}</Text>
501            <Text dimColor>
502              {'  '}
503              {code.heading}
504              {view.hidden.above + view.hidden.below > 0 ? `  · ${view.hidden.above} above, ${view.hidden.below} below` : ''}
505            </Text>
506          </Text>
507        </Box>
508        <Text>
509          {badge(ui, 'c')}
510          <Text dimColor> hide</Text>
511        </Text>
512      </Box>
513      <Box flexDirection="row">
514        <Box flexDirection="column" flexShrink={1} width={Math.max(10, width - 3 - CARD_COLUMNS)}>
515          {codeRuns(view.rows, code).map((run) => drawRun(ui, run, code, gutter))}
516        </Box>
517        {view.track.length > 0 && (
518          <Box flexDirection="column" width={1} flexShrink={0}>
519            {view.track.map((cell) =>
520              cell === 'current' ? (
521                <Text color={ACCENT}>◆</Text>
522              ) : cell === 'beacon' ? (
523                <Text>•</Text>
524              ) : (
525                <Text dimColor>{cell === 'window' ? '┃' : '│'}</Text>
526              ),
527            )}
528          </Box>
529        )}
530      </Box>
531      {node.beacons.map((beacon, index) => {
532        const isCurrent = index === current
533        const where = beaconWhere(beacon, index, isCurrent, code.path)
534
535        return (
536          <Box flexDirection="row">
537            <Box width={where.length} flexShrink={0}>
538              <Text color={isCurrent ? ACCENT : undefined} dimColor={!isCurrent}>
539                {where}
540              </Text>
541            </Box>
542            <Box flexShrink={1}>
543              <Text>
544                <Text bold={isCurrent} color={isCurrent ? ACCENT : undefined} dimColor={!isCurrent}>
545                  {beacon.label}
546                </Text>
547                <Text dimColor={!isCurrent}>{beacon.note ? `  ${beacon.note}` : ''}</Text>
548              </Text>
549            </Box>
550          </Box>
551        )
552      })}
553      <Text dimColor wrap="truncate-end">
554        {badge(ui, 'b')} next beacon  {badge(ui, 'n p')} scroll  {badge(ui, 'f')} {reader.isCodeFolded ? 'unfold' : 'fold'}  {badge(ui, 'o')}{' '}
555        {code.isWholeFile ? 'the step' : 'whole file'}
556      </Text>
557    </Box>
558  )
559}
560
561// A beacon's mark, number and line, before its label and note.
562function beaconWhere(beacon: TourBeacon, index: number, isCurrent: boolean, path: string): string {
563  return `${isCurrent ? '●' : '○'} ${index + 1} ${beacon.file === path ? '' : `${beacon.file} `}L${beacon.line}  `
564}
565
566// The rows the beacon list takes once each label and note wraps.
567function beaconListRows(node: TourNode, width: number): number {
568  return node.beacons.reduce((sum, beacon, index) => {
569    const room = Math.max(10, width - CARD_COLUMNS - beaconWhere(beacon, index, false, beacon.file).length)
570    const text = `${beacon.label}${beacon.note ? `  ${beacon.note}` : ''}`
571    return sum + Math.max(1, Math.ceil(text.length / room))
572  }, 0)
573}
574
575type Run = { kind: 'plain'; rows: CodeRow[] } | { kind: 'single'; row: VisualRow }
576
577// Plain source lines drawn together through the Code element, for syntax
578// colour; beacon lines, changed lines and folds one by one.
579function codeRuns(rows: VisualRow[], code: CodeView): Run[] {
580  const runs: Run[] = []
581
582  for (const row of rows) {
583    const last = runs.at(-1)
584    const isPlain = code.format === 'source' && row.kind === 'row' && !row.beacon
585
586    if (isPlain && last?.kind === 'plain') {
587      last.rows.push(row.row)
588    } else if (isPlain) {
589      runs.push({ kind: 'plain', rows: [row.row] })
590    } else {
591      runs.push({ kind: 'single', row })
592    }
593  }
594
595  return runs
596}
597
598function drawRun({ Box, Text, Code }: Ui, run: Run, code: CodeView, gutter: number) {
599  const number = (row: CodeRow) => (row.number === null ? '' : String(row.number)).padStart(gutter)
600
601  if (run.kind === 'plain') {
602    return (
603      <Box flexDirection="row">
604        <Box flexDirection="column" width={gutter + 4} flexShrink={0}>
605          {run.rows.map((row) => (
606            <Text dimColor>{` ${number(row)}   `}</Text>
607          ))}
608        </Box>
609        <Code source={run.rows.map((row) => row.text).join('\n')} path={code.path} wrap="truncate-end" />
610      </Box>
611    )
612  }
613
614  const { row } = run
615
616  if (row.kind === 'fold') {
617    return (
618      <Text dimColor backgroundColor={FOLD_ROW} wrap="truncate-end">
619        {` ${'⋯'.padStart(gutter)}   ${row.count} lines folded · f unfolds`}
620      </Text>
621    )
622  }
623
624  const color = row.row.marker === '+' ? ADDED : row.row.marker === '-' ? REMOVED : undefined
625  const line = `${number(row.row)} ${row.row.marker} ${row.row.text}`
626  // One column before the number: the current beacon's bar, else blank.
627  const bar = row.beacon?.isCurrent ? <Text color={ACCENT}>▌</Text> : <Text> </Text>
628
629  if (!row.beacon) {
630    return (
631      <Text color={color} wrap="truncate-end">
632        {` ${line}`}
633      </Text>
634    )
635  }
636
637  return (
638    <Box flexDirection="row">
639      {bar}
640      <Box flexGrow={1} flexShrink={1}>
641        <Text
642          bold={row.beacon.isCurrent}
643          color={color}
644          backgroundColor={row.beacon.isCurrent ? CURRENT_BEACON_ROW : BEACON_ROW}
645          wrap="truncate-end"
646        >
647          {line}
648        </Text>
649      </Box>
650      <Text color={row.beacon.isCurrent ? ACCENT : undefined} dimColor={!row.beacon.isCurrent}>
651        {` ${row.beacon.isCurrent ? '◀' : '◁'} ${row.beacon.index + 1}`}
652      </Text>
653    </Box>
654  )
655}
656
657// A key as a badge, as the design draws keys.
658function badge({ Text }: Ui, keys: string) {
659  return (
660    <Text bold color="text" backgroundColor={BORDER}>
661      {` ${keys} `}
662    </Text>
663  )
664}
665
666// A tour of existing code, with no change behind it: there is no "what
667// changed", and no commit to give a reason, so the labels say so.
668function isCodeTour(tour: Tour): boolean {
669  return tour.target.kind === 'code' || tour.target.kind === 'question'
670}
671
672function whyText(why: TourWhy | undefined, isCode: boolean): string | undefined {
673  if (!why) return undefined
674  if (why.source !== 'inferred') return `${why.text} (from the ${why.source})`
675
676  return isCode ? `${why.text} (inferred: no commit says so)` : `${why.text} (inferred)`
677}
678
claude-code/resume.ts 141 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On } from 'claude-code'
3
4import { getTour } from '../src/core/services/tour-machine.ts'
5import { loadCode } from './code-view.ts'
6import { closePane, openPane } from './pane/open.ts'
7import { EMPTY_DOCUMENT, EMPTY_READER, PANE } from './state.ts'
8import type { FaroDocument, FaroReader } from './types'
9
10const codeAtom = atom({ plugin: 'faro', key: 'code' } as const, null)
11const documentAtom = atom({ plugin: 'faro', key: 'document' } as const, EMPTY_DOCUMENT)
12const draftingAtom = atom({ plugin: 'faro', key: 'drafting' } as const, null)
13const readerAtom = atom({ plugin: 'faro', key: 'reader' } as const, EMPTY_READER)
14
15// The pane's state lives in the session's memory, so a resumed session
16// (`claude --resume`, `--continue`) would start empty. Each session's tours,
17// reading state and whether the pane was open are kept in the plugin's store
18// under the session's id: written when the session ends and after every
19// tour write, read back when a session with that id starts.
20//
21// Switching sessions from the session picker keeps the process, so no
22// session.start fires: the id is checked every SWITCH_CHECK_MS instead, and
23// a change of id swaps in the new session's tour, or none.
24
25const SESSIONS_KEY = 'sessions'
26// The store holds 4 MiB in all; only the most recent sessions are kept.
27const KEPT_SESSIONS = 12
28const SWITCH_CHECK_MS = 1000
29
30type Snapshot = { document: FaroDocument; reader: FaroReader; isPaneOpen: boolean }
31
32const sessionKey = (id: string) => `session:${id}`
33
34export function serveResume(on: On): void {
35  on('ui.open', async ($, e, next) => {
36    const opened = await next(e)
37    if (e.id === PANE) await setPaneOpen($, true)
38    return opened
39  })
40
41  on('ui.close', async ($, e, next) => {
42    const closed = await next(e)
43    if (e.id === PANE && e.origin.kind !== 'unload') await setPaneOpen($, false)
44    return closed
45  })
46
47  on('session.end', async ($, e, next) => {
48    await saveSession($, e.resume.id)
49    return next(e)
50  })
51}
52
53// Follows a switch to another session in the same process.
54// One watch at a time: session.start runs again on a reload or an enable.
55let stopWatching: (() => void) | undefined
56
57export async function watchSessionSwitch($: EngineInterface): Promise<void> {
58  let current = await $.session.id()
59
60  stopWatching?.()
61  const timer = $.clock.every(SWITCH_CHECK_MS, async () => {
62    const id = await $.session.id()
63
64    if (id === current) {
65      return
66    }
67
68    // What the reader did since the last save, kept for the session left.
69    await saveSession($, current)
70    current = id
71    await update($, documentAtom, () => EMPTY_DOCUMENT)
72    await update($, readerAtom, () => EMPTY_READER)
73    await update($, codeAtom, () => null)
74    await update($, draftingAtom, () => null)
75
76    if (!(await restoreSession($))) {
77      await closePane($)
78    }
79  })
80  stopWatching = () => timer.cancel()
81}
82
83// Brings back what the session had, when this session id kept a tour;
84// answers whether it did.
85export async function restoreSession($: EngineInterface): Promise<boolean> {
86  const snapshot = (await $.store.get(sessionKey(await $.session.id()))) as Snapshot | undefined
87  const tour = snapshot ? getTour(snapshot.document) : undefined
88
89  if (!snapshot || !tour || getTour(await read($, documentAtom))) {
90    return false
91  }
92
93  await update($, documentAtom, () => snapshot.document)
94  await update($, readerAtom, () => ({ ...EMPTY_READER, ...snapshot.reader }))
95  await loadCode($, tour)
96
97  if (snapshot.isPaneOpen) {
98    await openPane($)
99  } else {
100    await closePane($)
101  }
102
103  return true
104}
105
106// Keeping the tour is a convenience: a save that fails (the store is full,
107// the session ends too fast) is dropped, never raised.
108export async function saveSession($: EngineInterface, id?: string): Promise<void> {
109  try {
110    await save($, id ?? (await $.session.id()))
111  } catch {
112    // Nothing to do: the session simply will not come back with its tour.
113  }
114}
115
116async function save($: EngineInterface, sessionId: string): Promise<void> {
117  const document = await read($, documentAtom)
118
119  if (document.tours.length === 0) {
120    return
121  }
122
123  const reader = await read($, readerAtom)
124  const snapshot: Snapshot = { document, reader, isPaneOpen: reader.isPaneOpen }
125  const kept = ((await $.store.get(SESSIONS_KEY)) as string[] | undefined) ?? []
126  const order = [...kept.filter((entry) => entry !== sessionId), sessionId]
127
128  for (const dropped of order.slice(0, -KEPT_SESSIONS)) {
129    await $.store.delete(sessionKey(dropped))
130  }
131
132  await $.store.set(sessionKey(sessionId), snapshot)
133  await $.store.set(SESSIONS_KEY, order.slice(-KEPT_SESSIONS))
134}
135
136async function setPaneOpen($: EngineInterface, isPaneOpen: boolean): Promise<void> {
137  if ((await read($, readerAtom)).isPaneOpen !== isPaneOpen) {
138    await update($, readerAtom, (reader) => ({ ...reader, isPaneOpen }))
139  }
140}
141
claude-code/stale.ts 56 lines
1import { atom, read, update } from 'claude-code'
2import type { On } from 'claude-code'
3
4import { EMPTY_DOCUMENT, EMPTY_READER } from './state.ts'
5
6const documentAtom = atom({ plugin: 'faro', key: 'document' } as const, EMPTY_DOCUMENT)
7const readerAtom = atom({ plugin: 'faro', key: 'reader' } as const, EMPTY_READER)
8
9// After an edit lands, marks the nodes of open tours whose anchors sit in
10// the edited file, so the pane can say the explanation may be out of date.
11
12const EDITING_TOOLS = /^(Edit|Write|MultiEdit|NotebookEdit)$/
13
14export function watchEdits(on: On): void {
15  on('tool.call', { tool: EDITING_TOOLS }, async ($, e, next) => {
16    const ran = await next(e)
17    const filePath = (e as { file_path?: unknown; notebook_path?: unknown }).file_path ??
18      (e as { notebook_path?: unknown }).notebook_path
19
20    if (ran.deny !== undefined || ran.isError === true || typeof filePath !== 'string') {
21      return ran
22    }
23
24    const document = await read($, documentAtom)
25    const touched: Record<string, string[]> = {}
26
27    for (const tour of document.tours) {
28      const ids = Object.values(tour.nodes)
29        .filter((node) => node.anchors.some((anchor) => isSameFile(filePath, anchor.file)))
30        .map((node) => node.id)
31
32      if (ids.length > 0) touched[tour.id] = ids
33    }
34
35    if (Object.keys(touched).length > 0) {
36      await update($, readerAtom, (reader) => {
37        const stale = { ...reader.stale }
38
39        for (const [tourId, ids] of Object.entries(touched)) {
40          stale[tourId] = [...new Set([...(stale[tourId] ?? []), ...ids])]
41        }
42
43        return { ...reader, stale }
44      })
45    }
46
47    return ran
48  }).catch(($, e, next) => next(e))
49}
50
51function isSameFile(editedPath: string, anchorFile: string): boolean {
52  const relative = anchorFile.replace(/^\.\//, '')
53
54  return editedPath === relative || editedPath.endsWith(`/${relative}`)
55}
56
claude-code/tools.ts 236 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On } from 'claude-code'
3
4import type { FaroDocumentV2, Tour, TourNode } from '../src/core/model/tour.ts'
5import { lintTour } from '../src/core/model/tour-validation.ts'
6import { focusNode, getTour, updateNode, upsertTour } from '../src/core/services/tour-machine.ts'
7import { loadCode } from './code-view.ts'
8import { openPane } from './pane/open.ts'
9import { saveSession } from './resume.ts'
10import { EMPTY_DOCUMENT, EMPTY_READER, PINNED_KEY, addOnce } from './state.ts'
11
12const documentAtom = atom({ plugin: 'faro', key: 'document' } as const, EMPTY_DOCUMENT)
13const draftingAtom = atom({ plugin: 'faro', key: 'drafting' } as const, null)
14const readerAtom = atom({ plugin: 'faro', key: 'reader' } as const, EMPTY_READER)
15
16// The tour_* tools: Claude's only way to write to the pane. Every write goes
17// through the core's validation, and a refusal lists every problem at once.
18
19const TOUR_SCHEMA_NOTE = 'The shape is in skills/faro-author-paths/tour-schema.md (source of truth: src/core/model/tour.ts).'
20
21export async function registerTools($: EngineInterface): Promise<void> {
22  await Promise.all([
23    $.tool.register({
24      name: 'tour_upsert',
25      description: `Create or replace a whole Faro tour (schemaVersion 2) and open it in the pane on its first path node. Prefer one whole-tour write. Refuses an invalid tour, listing every problem; returns advice on soft rules. ${TOUR_SCHEMA_NOTE}`,
26      inputSchema: {
27        type: 'object',
28        properties: { tour: { type: 'object', description: 'The whole Tour.' } },
29        required: ['tour'],
30      },
31      isDeferred: false,
32    }),
33    $.tool.register({
34      name: 'tour_update_node',
35      description:
36        'Replace one node of a tour, or add one to the path after insertAfter, keeping the reader where they are. Use it to refine a node, re-explain one more simply, or fill otherLens.',
37      inputSchema: {
38        type: 'object',
39        properties: {
40          tourId: { type: 'string' },
41          node: { type: 'object', description: 'The whole TourNode.' },
42          insertAfter: { type: 'string', description: 'A path node id; needed only to add a new node.' },
43        },
44        required: ['tourId', 'node'],
45      },
46      isDeferred: false,
47    }),
48    $.tool.register({
49      name: 'tour_get',
50      description:
51        "Return a tour (the active one by default) with the reader's state: current node and depth, visited nodes, nodes marked \"don't get this\", and nodes whose files changed since.",
52      inputSchema: { type: 'object', properties: { tourId: { type: 'string' } } },
53      isDeferred: false,
54    }),
55    $.tool.register({
56      name: 'tour_focus',
57      description: 'Move the reader to a node of a tour, for "look at step 4".',
58      inputSchema: {
59        type: 'object',
60        properties: { tourId: { type: 'string' }, nodeId: { type: 'string' } },
61        required: ['tourId', 'nodeId'],
62      },
63      isDeferred: false,
64    }),
65    $.tool.register({
66      name: 'tour_list',
67      description: 'List the live and pinned tours, id and title.',
68      inputSchema: { type: 'object', properties: {} },
69      isDeferred: false,
70    }),
71  ])
72}
73
74export function serveTools(on: On): void {
75  on('tool.call', { tool: 'mcp__faro__tour_upsert' }, async ($, e) => {
76    const tour = (e as { tour?: Tour }).tour
77
78    if (!tour || typeof tour !== 'object') {
79      return { deny: `tour_upsert needs { tour }. ${TOUR_SCHEMA_NOTE}` }
80    }
81
82    const written = await writeDocument($, (document) => upsertTour(document, tour))
83
84    if ('deny' in written) {
85      return { deny: `The tour was refused:\n${written.deny}` }
86    }
87
88    const stored = getTour(written.document, tour.id)
89    await update($, draftingAtom, () => null)
90    await update($, readerAtom, (reader) => ({
91      ...reader,
92      visited: { ...reader.visited, [tour.id]: stored?.current.nodeId ? [stored.current.nodeId] : [] },
93      confused: { ...reader.confused, [tour.id]: [] },
94      stale: { ...reader.stale, [tour.id]: [] },
95      isOtherLensShown: false,
96      beaconIndex: 0,
97    }))
98
99    if (stored) {
100      await loadCode($, stored)
101    }
102
103    await openPane($)
104    await saveSession($)
105    const advice = stored ? lintTour(stored) : []
106
107    return {
108      result: [
109        `Tour ${tour.id} is in the pane with ${stored?.path.length ?? 0} nodes on the path.`,
110        ...(advice.length > 0 ? ['Advice (accepted, but worth fixing with tour_update_node):', ...advice.map((line) => `- ${line}`)] : []),
111      ].join('\n'),
112    }
113  })
114
115  on('tool.call', { tool: 'mcp__faro__tour_update_node' }, async ($, e) => {
116    const { tourId, node, insertAfter } = e as { tourId?: string; node?: TourNode; insertAfter?: string }
117
118    if (typeof tourId !== 'string' || !node || typeof node !== 'object') {
119      return { deny: 'tour_update_node needs { tourId, node }.' }
120    }
121
122    const written = await writeDocument($, (document) => updateNode(document, tourId, { node, insertAfter }))
123
124    if ('deny' in written) {
125      return { deny: `The node was refused:\n${written.deny}` }
126    }
127
128    await update($, readerAtom, (reader) => ({
129      ...reader,
130      stale: { ...reader.stale, [tourId]: (reader.stale[tourId] ?? []).filter((id) => id !== node.id) },
131      confused: { ...reader.confused, [tourId]: (reader.confused[tourId] ?? []).filter((id) => id !== node.id) },
132    }))
133
134    const tour = getTour(written.document, tourId)
135
136    if (tour && tour.current.nodeId === node.id) {
137      await loadCode($, tour)
138    }
139
140    await saveSession($)
141    return { result: `Node ${node.id} of tour ${tourId} is updated.` }
142  })
143
144  on('tool.call', { tool: 'mcp__faro__tour_get' }, async ($, e) => {
145    const { tourId } = e as { tourId?: string }
146    const document = await read($, documentAtom)
147    const tour = getTour(document, tourId) ?? (await pinnedTours($))[tourId ?? '']
148
149    if (!tour) {
150      return { deny: tourId ? `No tour ${tourId}.` : 'No tour is open. Author one with tour_upsert.' }
151    }
152
153    const reader = await read($, readerAtom)
154
155    return {
156      result: JSON.stringify({
157        tour,
158        reader: {
159          current: tour.current,
160          visited: reader.visited[tour.id] ?? [],
161          dontGet: reader.confused[tour.id] ?? [],
162          stale: reader.stale[tour.id] ?? [],
163          isOtherLensShown: reader.isOtherLensShown,
164        },
165      }),
166    }
167  })
168
169  on('tool.call', { tool: 'mcp__faro__tour_focus' }, async ($, e) => {
170    const { tourId, nodeId } = e as { tourId?: string; nodeId?: string }
171
172    if (typeof tourId !== 'string' || typeof nodeId !== 'string') {
173      return { deny: 'tour_focus needs { tourId, nodeId }.' }
174    }
175
176    const written = await writeDocument($, (document) => ({ ...focusNode(document, tourId, nodeId), activeTourId: tourId }))
177
178    if ('deny' in written) {
179      return { deny: written.deny }
180    }
181
182    await update($, readerAtom, (reader) => ({
183      ...reader,
184      visited: { ...reader.visited, [tourId]: addOnce(reader.visited[tourId], nodeId) },
185      beaconIndex: 0,
186    }))
187
188    const tour = getTour(written.document, tourId)
189
190    if (tour) {
191      await loadCode($, tour)
192    }
193
194    await openPane($)
195
196    return { result: `The reader is on ${nodeId}.` }
197  })
198
199  on('tool.call', { tool: 'mcp__faro__tour_list' }, async ($) => {
200    const document = await read($, documentAtom)
201    const pinned = await pinnedTours($)
202    const live = document.tours.map((tour) => `- ${tour.id}: ${tour.title}${tour.id === document.activeTourId ? ' (open)' : ''}`)
203    const kept = Object.values(pinned).map((tour) => `- ${tour.id}: ${tour.title} (pinned)`)
204    const lines = [...live, ...kept]
205
206    return { result: lines.length > 0 ? lines.join('\n') : 'No tours yet.' }
207  })
208}
209
210export async function pinnedTours($: EngineInterface): Promise<Record<string, Tour>> {
211  const value = await $.store.get(PINNED_KEY)
212
213  return value && typeof value === 'object' ? (value as Record<string, Tour>) : {}
214}
215
216async function writeDocument(
217  $: EngineInterface,
218  change: (document: FaroDocumentV2) => FaroDocumentV2,
219): Promise<{ document: FaroDocumentV2 } | { deny: string }> {
220  let next: FaroDocumentV2 | undefined
221  let problem: string | undefined
222
223  await update($, documentAtom, (document) => {
224    try {
225      next = change(document)
226      problem = undefined
227      return next
228    } catch (error) {
229      problem = error instanceof Error ? error.message : String(error)
230      return document
231    }
232  })
233
234  return problem !== undefined || !next ? { deny: problem ?? 'Nothing was written.' } : { document: next }
235}
236
src/core/model/tour.ts 186 lines
1import type { FaroDocument } from "./document.ts";
2
3// A tour is a FaroPath grown into a graph: nodes are structured mental steps,
4// edges say how they relate, and `path` is the recommended reading order.
5
6export type TourTargetKind = "worktree" | "range" | "branch" | "pr" | "code" | "question";
7
8export type TourTarget = {
9  kind: TourTargetKind;
10  ref: string;
11  baseSha?: string;
12  headSha?: string;
13};
14
15export type TourLens = "flow" | "domain";
16
17export type TourReader = "new" | "familiar";
18
19export type TourDepth = 0 | 1 | 2;
20
21export type TourRange = {
22  startLine: number;
23  endLine: number;
24  startColumn?: number;
25  endColumn?: number;
26};
27
28export type TourAnchor = {
29  file: string;
30  range: TourRange;
31  hunk?: { oldStart: number; newStart: number };
32};
33
34export type TourBeacon = {
35  label: string;
36  file: string;
37  line: number;
38  // One sentence on why the eye should land here.
39  note?: string;
40};
41
42export type TourWhySource = "commit" | "pr" | "comment" | "inferred";
43
44export type TourWhy = {
45  text: string;
46  source: TourWhySource;
47};
48
49export type TourNodeRole = "path" | "supporting";
50
51export type TourLensText = {
52  summary: string;
53  whatChanged?: string;
54  whatItDoes?: string;
55  why?: TourWhy;
56};
57
58export type TourNode = {
59  id: string;
60  title: string;
61  plan: string;
62  summary: string;
63  confirms?: string;
64  whatChanged?: string;
65  whatItDoes?: string;
66  why?: TourWhy;
67  beacons: TourBeacon[];
68  carryForward?: string;
69  detail?: string;
70  suggestedQuestion?: string;
71  surprise?: string;
72  anchors: TourAnchor[];
73  role: TourNodeRole;
74  otherLens?: TourLensText;
75};
76
77export type TourEdgeKind = "calls" | "data" | "requires" | "tests" | "configures";
78
79export type TourEdge = {
80  from: string;
81  to: string;
82  kind: TourEdgeKind;
83  label?: string;
84};
85
86export type TourCursor = {
87  nodeId: string | null;
88  depth: TourDepth;
89};
90
91export type Tour = {
92  id: string;
93  title: string;
94  target: TourTarget;
95  goal: string;
96  hypothesis: string;
97  lens: TourLens;
98  reader: TourReader;
99  nodes: Record<string, TourNode>;
100  edges: TourEdge[];
101  path: string[];
102  current: TourCursor;
103};
104
105export type FaroDocumentV2 = {
106  schemaVersion: 2;
107  activeTourId: string | null;
108  tours: Tour[];
109};
110
111export const TOUR_TARGET_KINDS: readonly TourTargetKind[] = [
112  "worktree",
113  "range",
114  "branch",
115  "pr",
116  "code",
117  "question",
118];
119export const TOUR_LENSES: readonly TourLens[] = ["flow", "domain"];
120export const TOUR_READERS: readonly TourReader[] = ["new", "familiar"];
121export const TOUR_WHY_SOURCES: readonly TourWhySource[] = ["commit", "pr", "comment", "inferred"];
122export const TOUR_NODE_ROLES: readonly TourNodeRole[] = ["path", "supporting"];
123export const TOUR_EDGE_KINDS: readonly TourEdgeKind[] = [
124  "calls",
125  "data",
126  "requires",
127  "tests",
128  "configures",
129];
130
131export const MAX_BEACONS_PER_NODE = 3;
132export const MAX_PATH_LENGTH = 12;
133
134export function createEmptyTourDocument(): FaroDocumentV2 {
135  return {
136    schemaVersion: 2,
137    activeTourId: null,
138    tours: [],
139  };
140}
141
142// v1 paths become tours with no edges: each beacon is one node on the path,
143// its file URI turned into a path and its explanation kept as the L1 text.
144export function migrateDocumentToV2(document: FaroDocument): FaroDocumentV2 {
145  return {
146    schemaVersion: 2,
147    activeTourId: document.activePathId,
148    tours: document.paths.map((path) => {
149      const nodes: Record<string, TourNode> = {};
150
151      for (const [id, beacon] of Object.entries(path.beacons)) {
152        const file = fileUriToPath(beacon.fileUri);
153
154        nodes[id] = {
155          id,
156          title: beacon.title ?? id,
157          plan: "",
158          summary: beacon.summary ?? beacon.title ?? id,
159          whatItDoes: beacon.explanation,
160          beacons: [],
161          anchors: [{ file, range: { ...beacon.range } }],
162          role: path.mainPath.includes(id) ? "path" : "supporting",
163        };
164      }
165
166      return {
167        id: path.id,
168        title: path.title ?? path.id,
169        target: { kind: "code", ref: path.goal ?? path.id },
170        goal: path.goal ?? path.title ?? path.id,
171        hypothesis: path.goal ?? path.title ?? path.id,
172        lens: "flow",
173        reader: "familiar",
174        nodes,
175        edges: [],
176        path: [...path.mainPath],
177        current: { nodeId: path.current?.beaconId ?? path.mainPath[0] ?? null, depth: 0 },
178      };
179    }),
180  };
181}
182
183function fileUriToPath(fileUri: string): string {
184  return decodeURIComponent(new URL(fileUri).pathname);
185}
186
src/core/services/tour-machine.ts 138 lines
1import type { FaroDocumentV2, Tour, TourDepth, TourNode } from "../model/tour.ts";
2import { assertValidTour, findNodeProblems } from "../model/tour-validation.ts";
3
4// Pure operations on a v2 document. Each returns a new document and leaves
5// its input untouched, so a host can keep the old value until a write lands.
6
7export function upsertTour(document: FaroDocumentV2, tour: Tour): FaroDocumentV2 {
8  const nextTour = normalizeTourCursor(clone(tour));
9  assertValidTour(nextTour);
10
11  const tours = document.tours.filter((entry) => entry.id !== nextTour.id);
12  const index = document.tours.findIndex((entry) => entry.id === nextTour.id);
13  tours.splice(index === -1 ? tours.length : index, 0, nextTour);
14
15  return { ...document, tours, activeTourId: nextTour.id };
16}
17
18export function removeTour(document: FaroDocumentV2, tourId: string): FaroDocumentV2 {
19  const tours = document.tours.filter((entry) => entry.id !== tourId);
20  const activeTourId =
21    document.activeTourId === tourId ? (tours[tours.length - 1]?.id ?? null) : document.activeTourId;
22
23  return { ...document, tours, activeTourId };
24}
25
26export function getTour(document: FaroDocumentV2, tourId?: string): Tour | undefined {
27  const id = tourId ?? document.activeTourId;
28
29  return document.tours.find((entry) => entry.id === id);
30}
31
32export type NodeUpdate = {
33  node: TourNode;
34  // Puts a new node on the path after this id; a node that exists keeps its place.
35  insertAfter?: string;
36};
37
38export function updateNode(
39  document: FaroDocumentV2,
40  tourId: string,
41  { node, insertAfter }: NodeUpdate,
42): FaroDocumentV2 {
43  const tour = requireTour(document, tourId);
44  const isKnown = Boolean(tour.nodes[node.id]);
45
46  if (!isKnown && insertAfter === undefined) {
47    throw new Error(`Tour ${tourId} has no node ${node.id}; pass insertAfter to add it.`);
48  }
49
50  if (insertAfter !== undefined && !isKnown && !tour.path.includes(insertAfter)) {
51    throw new Error(`Tour ${tourId} path has no node ${insertAfter} to insert after.`);
52  }
53
54  const problems = findNodeProblems(`Tour ${tourId}`, node.id, node);
55
56  if (problems.length > 0) {
57    throw new Error(problems.join("\n"));
58  }
59
60  const nextTour = clone(tour);
61  nextTour.nodes[node.id] = clone(node);
62
63  if (!isKnown && insertAfter !== undefined && node.role === "path") {
64    nextTour.path.splice(nextTour.path.indexOf(insertAfter) + 1, 0, node.id);
65  }
66
67  return replaceTour(document, nextTour);
68}
69
70export function focusNode(document: FaroDocumentV2, tourId: string, nodeId: string): FaroDocumentV2 {
71  const tour = requireTour(document, tourId);
72
73  if (!tour.nodes[nodeId]) {
74    throw new Error(`Tour ${tourId} has no node ${nodeId}.`);
75  }
76
77  return replaceTour(document, { ...tour, current: { nodeId, depth: tour.current.depth } });
78}
79
80export function moveOnPath(document: FaroDocumentV2, tourId: string, delta: number): FaroDocumentV2 {
81  const tour = requireTour(document, tourId);
82  const index = tour.current.nodeId === null ? -1 : tour.path.indexOf(tour.current.nodeId);
83  const nextIndex = clamp(index + delta, 0, tour.path.length - 1);
84  const nodeId = tour.path[nextIndex] ?? null;
85
86  return replaceTour(document, { ...tour, current: { ...tour.current, nodeId } });
87}
88
89export function changeDepth(document: FaroDocumentV2, tourId: string, delta: number): FaroDocumentV2 {
90  const tour = requireTour(document, tourId);
91  const depth = clamp(tour.current.depth + delta, 0, 2) as TourDepth;
92
93  return replaceTour(document, { ...tour, current: { ...tour.current, depth } });
94}
95
96export function pathPosition(tour: Tour): { index: number; total: number } {
97  const index = tour.current.nodeId === null ? -1 : tour.path.indexOf(tour.current.nodeId);
98
99  return { index, total: tour.path.length };
100}
101
102function normalizeTourCursor(tour: Tour): Tour {
103  const current = tour.current ?? { nodeId: null, depth: 0 };
104  const nodeId =
105    current.nodeId !== null && tour.path.includes(current.nodeId)
106      ? current.nodeId
107      : (tour.path[0] ?? null);
108
109  return { ...tour, current: { nodeId, depth: current.depth ?? 0 } };
110}
111
112function replaceTour(document: FaroDocumentV2, tour: Tour): FaroDocumentV2 {
113  return {
114    ...document,
115    tours: document.tours.map((entry) => (entry.id === tour.id ? tour : entry)),
116  };
117}
118
119function requireTour(document: FaroDocumentV2, tourId: string): Tour {
120  const tour = getTour(document, tourId);
121
122  if (!tour) {
123    throw new Error(`No tour ${tourId}.`);
124  }
125
126  return tour;
127}
128
129function clamp(value: number, min: number, max: number): number {
130  return Math.min(Math.max(value, min), max);
131}
132
133// JSON round-trip rather than structuredClone: the Claude Code mod host runs
134// this module in an environment without Node globals.
135function clone<T>(value: T): T {
136  return JSON.parse(JSON.stringify(value)) as T;
137}
138
claude-code/code-view.ts 128 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface } from 'claude-code'
3
4import type { Tour, TourAnchor, TourBeacon, TourNode, TourTarget } from '../src/core/model/tour.ts'
5import { hunksOverlapping } from './code-excerpt.ts'
6import { diffRows, sourceRows } from './code-window.ts'
7import { diffArgv } from './explain-args.ts'
8import { locate } from './paths.ts'
9import { EMPTY_READER } from './state.ts'
10import type { FaroCodeView as CodeView } from './types'
11
12const codeAtom = atom({ plugin: 'faro', key: 'code' } as const, null)
13const readerAtom = atom({ plugin: 'faro', key: 'reader' } as const, EMPTY_READER)
14
15// Fills the code panel for the current beacon's anchor: its diff hunks for
16// a change, or its lines for existing code; with `o`, the whole file. The
17// pane folds and windows these rows when it draws them. Reads happen here,
18// in handlers and tool calls, never while drawing: a render hook only reads
19// state.
20export async function loadCode($: EngineInterface, tour: Tour, isWholeFile = false): Promise<void> {
21  const node = tour.current.nodeId ? tour.nodes[tour.current.nodeId] : undefined
22
23  if (!node) {
24    await update($, codeAtom, () => null)
25    return
26  }
27
28  const { beaconIndex } = await read($, readerAtom)
29  const beacon = node.beacons.length > 0 ? node.beacons[beaconIndex % node.beacons.length] : undefined
30  const anchor = anchorFor(node, beacon)
31
32  if (!anchor) {
33    await update($, codeAtom, () => null)
34    return
35  }
36
37  const view = await codeView($, tour, node, anchor, isWholeFile)
38  await update($, codeAtom, () => view)
39}
40
41function anchorFor(node: TourNode, beacon: TourBeacon | undefined): TourAnchor | undefined {
42  const holding = beacon
43    ? node.anchors.find(
44        (anchor) =>
45          anchor.file === beacon.file && beacon.line >= anchor.range.startLine && beacon.line <= anchor.range.endLine,
46      )
47    : undefined
48
49  return holding ?? node.anchors[0]
50}
51
52async function codeView(
53  $: EngineInterface,
54  tour: Tour,
55  node: TourNode,
56  anchor: TourAnchor,
57  isWholeFile: boolean,
58): Promise<CodeView> {
59  const base = { tourId: tour.id, nodeId: node.id, isWholeFile, path: anchor.file }
60  const { startLine, endLine } = anchor.range
61  const argv = isWholeFile ? null : diffArgv(tour.target, anchor.file)
62
63  if (argv) {
64    const hunks = await runText($, argv)
65    const overlapping = hunks ? hunksOverlapping(hunks, startLine, endLine) : ''
66
67    if (overlapping) {
68      const against = diffBaseOf(tour.target) ?? 'base'
69      return { ...base, heading: `L${startLine}–${endLine} · diff vs ${against}`, format: 'diff', rows: diffRows(overlapping) }
70    }
71  }
72
73  const loaded = await readText($, anchor.file)
74
75  if ('error' in loaded) {
76    return { ...base, heading: `could not be read: ${loaded.error}`, format: 'source', rows: [] }
77  }
78
79  const { text } = loaded
80
81  const rows = isWholeFile ? sourceRows(text, 1, Number.MAX_SAFE_INTEGER) : sourceRows(text, startLine, endLine)
82  const span = isWholeFile ? 'whole file' : `L${startLine}–${endLine}`
83  const at = tour.target.headSha ? ` @ ${tour.target.headSha.slice(0, 7)}` : ''
84  const readOnly = diffArgv(tour.target, anchor.file) ? '' : ` ·${at} read only`
85
86  return { ...base, heading: `${span} · ${rows.length} lines${readOnly}`, format: 'source', rows }
87}
88
89// The ref a tour of a change is compared against, for the heading.
90function diffBaseOf(target: TourTarget): string | undefined {
91  switch (target.kind) {
92    case 'worktree':
93      return target.baseSha ?? 'HEAD'
94    case 'range':
95      return target.baseSha ?? (target.ref.split(/\.{2,3}/)[0] || undefined)
96    case 'branch':
97    case 'pr':
98      return target.baseSha
99    default:
100      return undefined
101  }
102}
103
104// The file's text, or why it could not be had.
105async function readText($: EngineInterface, file: string): Promise<{ text: string } | { error: string }> {
106  const path = await locate($, file)
107
108  if (!path) {
109    return { error: `not found from ${await $.session.cwd()}` }
110  }
111
112  try {
113    const text = await $.fs.read(path)
114    return typeof text === 'string' ? { text } : { error: 'not text' }
115  } catch (error) {
116    return { error: error instanceof Error ? error.message : String(error) }
117  }
118}
119
120async function runText($: EngineInterface, argv: string[]): Promise<string | undefined> {
121  try {
122    const { exitCode, stdout } = await $.process.run(argv)
123    return exitCode === 0 ? stdout : undefined
124  } catch {
125    return undefined
126  }
127}
128
claude-code/explain-args.ts 129 lines
1import type { TourLens, TourReader, TourTarget, TourTargetKind } from '../src/core/model/tour.ts'
2
3// Parsing and classifying `/explain [target] [flags]`. Pure, so it is tested
4// with the core under node:test; the probes that touch git or the disk are
5// passed in by the command.
6
7export type TourDepthName = 'brief' | 'standard' | 'deep'
8
9export type ExplainRequest = {
10  target: string
11  goal?: string
12  depth: TourDepthName
13  lens: TourLens
14  reader: TourReader
15}
16
17export type ParsedExplain = { request: ExplainRequest } | { error: string }
18
19const DEPTHS: readonly TourDepthName[] = ['brief', 'standard', 'deep']
20const LENSES: readonly TourLens[] = ['flow', 'domain']
21const READERS: readonly TourReader[] = ['new', 'familiar']
22
23export const NODE_BUDGET: Record<TourDepthName, string> = {
24  brief: 'about 4',
25  standard: '5 to 7',
26  deep: 'up to 9',
27}
28
29export function parseExplainArgs(args: string): ParsedExplain {
30  const tokens = tokenize(args)
31  const words: string[] = []
32  const request: ExplainRequest = { target: '', depth: 'standard', lens: 'flow', reader: 'familiar' }
33
34  for (let index = 0; index < tokens.length; index += 1) {
35    const token = tokens[index] ?? ''
36
37    if (!token.startsWith('--')) {
38      words.push(token)
39      continue
40    }
41
42    const [flag = '', inline] = token.slice(2).split(/=(.*)/s, 2)
43    const value = inline ?? tokens[++index]
44
45    if (value === undefined) {
46      return { error: `--${flag} needs a value.` }
47    }
48
49    if (flag === 'goal') {
50      request.goal = value
51    } else if (flag === 'depth' && isOneOf(DEPTHS, value)) {
52      request.depth = value
53    } else if (flag === 'lens' && isOneOf(LENSES, value)) {
54      request.lens = value
55    } else if (flag === 'reader' && isOneOf(READERS, value)) {
56      request.reader = value
57    } else {
58      return { error: `Unknown flag or value: --${flag} ${value}. ${USAGE}` }
59    }
60  }
61
62  request.target = words.join(' ')
63
64  return { request }
65}
66
67export const USAGE =
68  'Usage: /explain [target] [--goal "..."] [--depth brief|standard|deep] [--lens flow|domain] [--reader new|familiar]'
69
70// Splits on whitespace, keeping "double" or 'single' quoted runs whole.
71export function tokenize(args: string): string[] {
72  const tokens: string[] = []
73  const pattern = /"([^"]*)"|'([^']*)'|(\S+)/g
74
75  for (const match of args.matchAll(pattern)) {
76    tokens.push(match[1] ?? match[2] ?? match[3] ?? '')
77  }
78
79  return tokens
80}
81
82export type TargetClass = TourTargetKind | 'last' | 'probe'
83
84// What can be told from the words alone; 'probe' means the command must ask
85// the disk (a path) and then git (a branch), falling back to a question.
86export function classifyTarget(target: string): TargetClass {
87  const trimmed = target.trim()
88
89  if (trimmed === '') return 'worktree'
90  if (trimmed === 'last') return 'last'
91  if (/^#\d+$/.test(trimmed) || /^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/\d+/.test(trimmed)) return 'pr'
92  if (/\s/.test(trimmed) || trimmed.endsWith('?')) return 'question'
93  if (/^[^\s.][^\s]*\.\.\.?[^\s]+$/.test(trimmed) && !/\.\.\//.test(trimmed)) return 'range'
94
95  return 'probe'
96}
97
98// `src/auth/session.ts:createSession` names a path and a symbol in it.
99export function splitPathSymbol(target: string): { path: string; symbol?: string } {
100  const match = /^(.+?):([A-Za-z_$][\w$.]*)$/.exec(target)
101
102  return match ? { path: match[1] ?? target, symbol: match[2] } : { path: target }
103}
104
105// The git command whose output shows a target's change for one file, or
106// null when the target is existing code with no diff.
107export function diffArgv(target: TourTarget, file: string): string[] | null {
108  const tail = ['--', file]
109
110  if (target.baseSha && target.headSha) return ['git', 'diff', target.baseSha, target.headSha, ...tail]
111
112  switch (target.kind) {
113    case 'worktree':
114      return ['git', 'diff', 'HEAD', ...tail]
115    case 'range':
116      return ['git', 'diff', target.ref, ...tail]
117    case 'branch':
118      return ['git', 'diff', `${target.baseSha ?? 'HEAD'}...${target.ref}`, ...tail]
119    case 'pr':
120      return target.baseSha ? ['git', 'diff', target.baseSha, ...tail] : null
121    default:
122      return null
123  }
124}
125
126function isOneOf<T extends string>(values: readonly T[], value: string): value is T {
127  return (values as readonly string[]).includes(value)
128}
129
claude-code/pane/open.ts 12 lines
1import type { EngineInterface } from 'claude-code'
2
3import { PANE } from '../state.ts'
4
5export function openPane($: EngineInterface) {
6  return $.ui.open({ id: PANE, title: 'Faro tour' })
7}
8
9export function closePane($: EngineInterface) {
10  return $.ui.close({ id: PANE })
11}
12
claude-code/prompts.ts 77 lines
1import type { Tour, TourNode, TourTarget } from '../src/core/model/tour.ts'
2import { NODE_BUDGET, type ExplainRequest } from './explain-args.ts'
3
4// The prompts the plugin hands Claude. Each names the skill and the tool to
5// call, so the model's reply lands in the pane rather than only in chat.
6
7const SKILL = 'faro-author-paths'
8
9export function authoringPrompt(
10  request: ExplainRequest,
11  target: TourTarget,
12  skillDir: string,
13  notes: readonly string[] = [],
14): string {
15  const goal = request.goal ?? inferredGoal(target)
16
17  return [
18    `Use the ${SKILL} skill to author a Faro tour, then call mcp__faro__tour_upsert once with the whole tour.`,
19    `Read ${skillDir}/comprehension.md and ${skillDir}/tour-schema.md first.`,
20    '',
21    `Target: kind "${target.kind}", ref ${JSON.stringify(target.ref)}${shaText(target)}.`,
22    `Goal: ${goal}`,
23    `Lens: ${request.lens} (write only this lens; leave otherLens empty).`,
24    `Reader: ${request.reader}.`,
25    `Depth: ${request.depth}, so ${NODE_BUDGET[request.depth]} nodes on the path${request.depth === 'brief' ? ', L0 and L1 only' : ''}.`,
26    ...notes,
27    '',
28    'Copy the target object above into the tour as given. When the tool is done, reply in two or three lines: the goal, the shape of what you found, and that the pane is open to walk it.',
29  ].join('\n')
30}
31
32export function askPrompt(tour: Tour, node: TourNode, question: string): string {
33  return `[faro ${tour.id} › ${node.id} "${node.title}"] ${question}`
34}
35
36export function simplerPrompt(tour: Tour, node: TourNode): string {
37  return [
38    `[faro ${tour.id} › ${node.id} "${node.title}"] I don't get this step.`,
39    `Call mcp__faro__tour_get to see where I am, then re-explain only node ${node.id} more simply`,
40    'and write it back with mcp__faro__tour_update_node. Keep your chat reply to one or two lines.',
41  ].join(' ')
42}
43
44export function otherLensPrompt(tour: Tour, node: TourNode): string {
45  const other = tour.lens === 'flow' ? 'domain' : 'flow'
46
47  return [
48    `[faro ${tour.id} › ${node.id} "${node.title}"] Show this step in the ${other} lens.`,
49    `Write node ${node.id}'s otherLens (summary, ${tour.target.kind === 'code' || tour.target.kind === 'question' ? 'whatItDoes' : 'whatChanged'}, why) in the ${other} lens`,
50    'and save it with mcp__faro__tour_update_node, leaving every other field unchanged. No chat reply is needed beyond one line.',
51  ].join(' ')
52}
53
54export function refreshPrompt(tour: Tour, node: TourNode): string {
55  return [
56    `[faro ${tour.id} › ${node.id} "${node.title}"] The files under this step changed since the tour was written.`,
57    `Re-read its anchors and update node ${node.id} (ranges, beacons, text) with mcp__faro__tour_update_node.`,
58  ].join(' ')
59}
60
61function inferredGoal(target: TourTarget): string {
62  switch (target.kind) {
63    case 'question':
64      return target.ref
65    case 'code':
66      return `How does ${target.ref} work? Infer the question a reader of this code most needs answered.`
67    default:
68      return 'Infer it from the commit messages or PR description; if none states one, from the change itself.'
69  }
70}
71
72function shaText(target: TourTarget): string {
73  const parts = [target.baseSha && `baseSha ${target.baseSha}`, target.headSha && `headSha ${target.headSha}`]
74
75  return parts.some(Boolean) ? `, ${parts.filter(Boolean).join(', ')}` : ''
76}
77