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

Faro is a VS Code extension for turning agent reasoning about a codebase into guided, navigable code beacons.
[Placeholder: Faro Home screenshot][Placeholder: Faro Path screenshot][Placeholder: Faro Setup screenshot][Placeholder: short onboarding video or GIF]
Faro is currently a local MVP.
It is not published to the VS Code Marketplace yet, so the current way to try it is:
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.
Faro Path Author custom agent.Faro MCP server is enabled for the workspace.Example:
Create a Faro path that explains how the main VS Code view is built and wired.
Faro activity bar item.Home to enter the productPath to read the active pathSetup to manage local/global agent integrationsPrev, Next, and Reveal to walk the path in code.Faro activity bar item is visibleHome, Path, and Setup are availablePathPrev, Next, and Reveal workIf 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:
Faro activity bar item is visiblePath shows an empty state until an agent-authored path existsPrev, Next, and Reveal work from the path routeSetup shows local/global integration targetsWorkspace-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.mdGlobal 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
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 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.
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
If Faro does not appear in VS Code:
make install-localIf the custom agent does not appear in Chat:
make upsert-agent-local SCOPE=local.github/agents/faro-path-author.agent.md existsIf the MCP flow does not work:
Faro MCP server is enabled for the workspacenpm run check and npm testclaude-code/register.tsx 30 lines1import 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}
30claude-code/command.ts 279 lines1import { 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}
279claude-code/pane/path-view.tsx 678 lines1import { 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}
678claude-code/resume.ts 141 lines1import { 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}
141claude-code/stale.ts 56 lines1import { 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}
56claude-code/tools.ts 236 lines1import { 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}
236src/core/model/tour.ts 186 lines1import 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}
186src/core/services/tour-machine.ts 138 lines1import 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}
138claude-code/code-view.ts 128 lines1import { 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}
128claude-code/explain-args.ts 129 lines1import 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}
129claude-code/pane/open.ts 12 lines1import 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}
12claude-code/prompts.ts 77 lines1import 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