A live preview pane beside the transcript: the session's diff with review comments, and rendered plans and docs as Claude writes them.

A diff pane that sits next to Claude Code. Watch Claude's edits land, leave a note on the line you mean, and Claude reads it on its next turn.

Claude finishes a turn with "I've updated the retry logic across the codebase", and you scroll up to find out where. Then you describe the line you want changed in English ("the second change, the one with the hours, no, the other one"). Raven is a side panel for that moment.
<img src="docs/media/raven-grid.webp" alt="Raven's panes: the live diff, notes Claude has addressed, a rendered plan with a note, and the file tree with changes marked" width="640">
CLAUDE_CODE_NO_FLICKER=1 set in your environmentIn Claude Code:
/plugin marketplace add arunkumar9t2/raven
/plugin install raven@raven
Then run /reload-plugins, or restart Claude Code.
<img src="docs/media/raven-hero.webp" alt="Claude Code on the left and the Raven diff pane on the right, with a note marked addressed" width="640">
Type /raven to open the diff. Raven also opens on its own when Claude makes its first edit, writes a plan, or starts a task list (see Settings to turn that off).
| Command | Does |
|---|---|
/raven or /raven diff | opens the diff |
/raven doc | opens the rendered plan or doc |
/raven files | opens your repository tree |
/raven tasks | opens Claude's task list |
/raven send | sends your pending notes to Claude now |
Run a pane's command again to close it.
To leave a note, click ✎ note on a file or a hunk, pick a line if you want one, type, and press Enter. To send notes without waiting for your next prompt, click send.
You can also ask Claude to show you things, like "show me the plan in raven". To let Claude do that without asking for permission each time, add Bash(raven:*) to the allowed tools in your settings.
Open /config and look for Raven:
| Setting | Default | What it does |
|---|---|---|
autoOpen | on | Lets Raven open panes by itself: the diff on Claude's first edit, a plan as Claude writes it, the task list when Claude starts one. Turn it off and panes open only when you (or Claude, when you ask) open them. |
autoOpenColumns | 144 | Raven only opens by itself when the terminal is at least this many columns wide. |
watchedPaths | empty | Extra folders, comma-separated, whose Markdown files open rendered as Claude writes them. Plan folders are watched already. |
keyboardControls | off | Draws plain buttons you can reach with Tab, so every control works from the keyboard. |
CLAUDE_CODE_NO_FLICKER=1.autoOpen in /config.Raven is a Claude Code plugin written in TypeScript. To work on it:
bun install
bun run setup:local # load this checkout in every local Claude Code session (--remove to undo)
bun run check # typecheck, lint, tests and plugin validation
claude --plugin-dir ./plugins/raven loads it for a single session instead. The design lives in spec/, and CLAUDE.md covers the layout and workflow.
Apache License 2.0. See LICENSE.
hooks/register.ts 308 lines1import type { On, PluginOptions, RenderSurface } from 'claude-code'
2import { isCheckpointing } from './core/checkpointing'
3import { commandGlyphOf } from './core/command-glyph'
4import { DIRECTIVE_OPS, withoutDirectives } from './core/directive'
5import type { Host } from './core/host'
6import { isRecord } from './core/is-record'
7import { createRaven, type Raven } from './core/raven'
8import { settingsOf } from './core/settings'
9import type { ToolEvent } from './core/triggers'
10import { capabilitiesOf, type Kit, type Ui } from './core/view'
11import {
12 COMMAND,
13 COMMAND_DESCRIPTION,
14 PANE_IDS,
15 PANE_SUBCOMMANDS,
16 TOOL_NAME,
17 toolNameOf,
18} from './names'
19import { commandOutputRow } from './views/band'
20
21// The plugin's own name is only known once `$` binds, so the tool's full name cannot be a static
22// string here; this matches any plugin's `show` tool as `tool.call`'s matcher must be static.
23const TOOL_MATCH = new RegExp(`^mcp__.+__${TOOL_NAME}$`)
24
25const TOOL_DESCRIPTION =
26 'Show something to the user in the Raven preview pane beside the transcript.'
27
28const TOOL_INPUT_SCHEMA = {
29 type: 'object',
30 properties: {
31 op: {
32 enum: [...DIRECTIVE_OPS],
33 description:
34 "'show' renders a file at `path`; 'note' renders the markdown you compose; 'diff' opens " +
35 "the diff, optionally at `path`; 'comments' reads the user's pending review comments; " +
36 "'open' switches the pane to `pane` (leaving it showing if it already is).",
37 },
38 path: {
39 type: 'string',
40 description: 'A file path, relative to the session cwd unless absolute.',
41 },
42 pane: {
43 enum: [...PANE_SUBCOMMANDS],
44 description: 'The pane to show, for `op: "open"`.',
45 },
46 markdown: { type: 'string', description: 'Markdown to render, for `op: "note"`.' },
47 title: {
48 type: 'string',
49 description: 'A title for the pane, for `op: "show"` or `op: "note"`.',
50 },
51 },
52 required: ['op'],
53}
54
55/**
56 * Raven's hooks: binds the engine once at `session.start`, then forwards commands, tool calls,
57 * prompts and pane drawing to the controller in `core/raven`.
58 */
59export function register(on: On, options: PluginOptions) {
60 const settings = settingsOf(options)
61 let raven: Raven | null = null
62
63 /**
64 * Notes the viewport off any `ui.render` event and builds its `Kit` around `resolve` (always
65 * `() => $.ui.resolve(e)` at the call site: the sandbox forbids passing `$` itself), whose `ui`
66 * resolves lazily so a handler whose guard declines to draw (a hidden pane, a band a survey
67 * suppresses) never pays for `$.ui.resolve`. `capabilities` is computed once here, off
68 * `surface`, not off which elements `resolve()` happens to hand back. See `core/view.ts`.
69 */
70 function kitOf(
71 viewport: { columns?: number } | undefined,
72 resolve: () => unknown,
73 columns: number,
74 rows: number,
75 surface: RenderSurface,
76 requestId: string,
77 ): Kit {
78 raven?.noteViewport(viewport?.columns)
79 let cached: Ui | undefined
80 return {
81 get ui() {
82 if (cached === undefined) cached = resolve() as unknown as Ui
83 return cached
84 },
85 columns,
86 rows,
87 capabilities: capabilitiesOf(surface, settings.keyboardControls),
88 press: (id, onPress) => raven?.registerPress(requestId, id, onPress),
89 }
90 }
91
92 on('session.start', async ($, e, next) => {
93 const bound: Host = {
94 run: (argv, stdin) => $.process.run(argv, stdin === undefined ? undefined : { stdin }),
95 readFile: async path => {
96 const text = await $.fs.read(path)
97 return typeof text === 'string' ? text : ''
98 },
99 after: (ms, fn) => $.clock.after(ms, fn),
100 redraw: () => $.ui.invalidate('ui.render'),
101 openPane: async pane => (await $.ui.open(pane)).isPlaced,
102 closePane: id => $.ui.close({ id }),
103 shownPaneIds: async () =>
104 new Set((await $.ui.panes()).filter(pane => pane.isShown).map(pane => pane.id)),
105 focus: async (paneId, key) => {
106 await $.ui.focus({ requestId: paneId, key })
107 },
108 storeGet: key => $.store.get(key),
109 storeSet: (key, value) => $.store.set(key, value),
110 submitPrompt: async text => {
111 await $.prompt.submit({ text })
112 },
113 cwd: () => $.session.cwd(),
114 fork: async prompt => {
115 const result = await $.model.fork({ prompt })
116 return result.isAnswered ? result.text : null
117 },
118 status: text => $.ui.status(text),
119 fillPrompt: async text => {
120 const result = await $.prompt.fill({ text, mode: 'replace' })
121 return { isFilled: result.isFilled, refusal: result.refusal }
122 },
123 toast: text => $.ui.toast(text),
124 messages: () => $.session.messages(),
125 debug: text => $.ui.log(text, { to: 'debug' }),
126 readGlobalConfig: async () => {
127 const home = await $.env.get('HOME')
128 if (home === undefined) return null
129 try {
130 const text = await $.fs.read(`${home}/.claude.json`)
131 return JSON.parse(typeof text === 'string' ? text : '')
132 } catch {
133 return null
134 }
135 },
136 isCheckpointing: async () =>
137 isCheckpointing(
138 await $.settings.read(),
139 await $.env.get('CLAUDE_CODE_DISABLE_FILE_CHECKPOINTING'),
140 ),
141 }
142
143 const created = createRaven(bound, settings, () => Date.now())
144 await $.command.register({
145 name: COMMAND,
146 description: COMMAND_DESCRIPTION,
147 argumentHint: created.argumentHint,
148 })
149 await $.tool.register({
150 name: TOOL_NAME,
151 description: TOOL_DESCRIPTION,
152 inputSchema: TOOL_INPUT_SCHEMA,
153 })
154 raven = created
155
156 return next(e)
157 })
158
159 on('command.run', { command: COMMAND }, async ($, e, next) => {
160 if (!raven) return next(e)
161 const result = await raven.command(e.args)
162 return { text: result.text }
163 })
164
165 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
166 const kit = kitOf(
167 e.viewport,
168 () => $.ui.resolve(e),
169 e.props.bodyColumns,
170 e.props.scroll.bodyRows,
171 e.surface,
172 e.requestId,
173 )
174 if (!raven || !PANE_IDS.includes(e.requestId)) return next(e)
175 const drawn = raven.render(e.requestId, kit)
176 return drawn ?? next(e)
177 })
178
179 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
180 const kit = kitOf(
181 e.viewport,
182 () => $.ui.resolve(e),
183 e.props.bodyColumns,
184 e.props.maxRows,
185 e.surface,
186 e.requestId,
187 )
188 if (!raven) return next(e)
189 const drawn = await raven.band(kit, e.props.hasSurvey, e.requestId)
190 return drawn ?? next(e)
191 })
192
193 on(
194 'ui.render',
195 { component: 'CommandOutput', props: { command: COMMAND } },
196 async ($, e, next) => {
197 const kit = kitOf(e.viewport, () => $.ui.resolve(e), 0, 0, e.surface, e.requestId)
198 if (!raven) return next(e)
199 return commandOutputRow(kit, {
200 text: e.props.text,
201 isErrored: e.props.isErrored,
202 glyph: commandGlyphOf(raven.resultKindOf(e.props.text)),
203 })
204 },
205 )
206
207 // A `Client` pill's press (`surface/strip.tsx` posts `{ press: id }`) from a pane or the band. `data` came from code, so
208 // it is validated; a stale or unknown id is ignored. Answers `{}` either way: nothing to hand back.
209 on('ui.message', ($, e, next) => {
210 const data = e.data
211 if (raven && isRecord(data) && typeof data.press === 'string') {
212 raven.press(e.requestId, data.press)
213 return {}
214 }
215 return next(e)
216 })
217
218 on('ui.scroll', { requestId: PANE_IDS }, ($, e, next) => {
219 if (!raven || e.origin.kind !== 'person' || !raven.scroll(e.requestId, e.by)) return next(e)
220 $.ui.invalidate('ui.render')
221 return {}
222 })
223
224 // A fork of its own answer raises no turn.complete the types promise, but the flag inside
225 // `turnCompleted` guards it either way; `next(e)` runs first so this never slows the turn. Every
226 // main-loop turn end reaches it, not just `reason: 'answer'`, so the live feed resets even on an
227 // interrupted or errored turn; `turnCompleted` itself only forks to resolve sent comments when
228 // the turn actually answered.
229 on('turn.complete', ($, e, next) => {
230 const result = next(e)
231 if (raven && e.agentId === undefined) void raven.turnCompleted(e)
232 return result
233 })
234
235 on('ui.close', { id: PANE_IDS }, async ($, e, next) => {
236 const result = await next(e)
237 if (result.deny === undefined) raven?.paneClosed(e.id)
238 return result
239 })
240
241 // Registered before the catch-all below: answering here without calling `next` keeps that hook
242 // from also reacting to this call (a call no hook answers fails, so this one must answer).
243 on('tool.call', { tool: TOOL_MATCH }, async ($, e, next) => {
244 if (e.tool !== toolNameOf($.plugin.name) || !raven) return next(e)
245 try {
246 const text = await raven.runTool(e)
247 return { result: text, text }
248 } catch (error) {
249 return { deny: error instanceof Error ? error.message : String(error) }
250 }
251 })
252
253 on('tool.call', async ($, e, next) => {
254 const result = await next(e)
255 if (!raven) return result
256
257 const isLanded = result.deny === undefined && result.isError !== true
258 const output = isLanded && isRecord(result.result) ? result.result : {}
259 const event: ToolEvent = {
260 tool: e.tool,
261 input: isRecord(e) ? e : {},
262 isLanded,
263 agentId: e.agentId,
264 stdout: typeof output.stdout === 'string' ? output.stdout : undefined,
265 result: isLanded ? result.result : undefined,
266 }
267
268 const ack = await raven.afterTool(event)
269 if (ack === undefined || !isLanded || !isRecord(result.result)) return result
270 // The model reads Bash's result from its `stdout`, so the ack replaces the CLI's lines there;
271 // anything else the command printed stays.
272 const rest = withoutDirectives(event.stdout ?? '')
273 const text = rest === '' ? ack : `${rest}\n${ack}`
274 return { ...result, result: { ...result.result, stdout: text }, text }
275 })
276
277 // Plan mode's notes name the plan file wherever plans are kept; observed, never rewritten.
278 on(
279 'prompt.attachment',
280 { type: ['plan_mode', 'plan_mode_exit', 'plan_mode_reentry'] },
281 async ($, e, next) => {
282 const result = await next(e)
283 if (raven && e.agentId === undefined && e.detail) {
284 await raven.planNoted({ type: e.type, ...e.detail })
285 }
286 return result
287 },
288 )
289
290 // Only a prompt the person sent (typed, or through Remote Control) carries the review. The
291 // toast only fires once the prompt actually went through: a dropped prompt restores the
292 // comments it carried instead (R5).
293 on('prompt.submit', async ($, e, next) => {
294 const isPersons = e.origin.kind === 'composer' || e.origin.kind === 'bridge'
295 const carried = isPersons ? await raven?.takePromptContext() : undefined
296 if (!carried) return next(e)
297 try {
298 const result = await next({ ...e, context: [...(e.context ?? []), carried.text] })
299 if (result.drop === undefined) raven?.noteCarried(carried.count)
300 else raven?.restoreCarried(carried.ids)
301 return result
302 } catch (error) {
303 raven?.restoreCarried(carried.ids)
304 throw error
305 }
306 })
307}
308hooks/core/checkpointing.ts 22 lines1import type { Settings } from 'claude-code'
2import { isRecord } from './is-record'
3
4/**
5 * Whether the session checkpoints Claude's edits, read as the built-in diff panel reads it: the
6 * setting on unless set false, the variable unset or falsy.
7 */
8export const isCheckpointing = (settings: Settings, disabling: string | undefined): boolean =>
9 settings.fileCheckpointingEnabled !== false &&
10 !['1', 'true', 'yes', 'on'].includes((disabling ?? '').trim().toLowerCase())
11
12/** Whether `~/.claude.json` leaves the built-in diff sidebar open; undefined when unreadable. */
13export const diffSidebarOpenOf = (globalConfig: unknown): boolean | undefined => {
14 if (!isRecord(globalConfig)) return undefined
15 const value = globalConfig.diffSidebarOpen
16 return typeof value === 'boolean' ? value : undefined
17}
18
19/** Whether the built-in diff panel is set to cover Raven's dock: open (or unknown) and checkpointing. */
20export const coversRavenDock = (globalConfig: unknown, isCheckpointingOn: boolean): boolean =>
21 diffSidebarOpenOf(globalConfig) !== false && isCheckpointingOn
22hooks/core/command-glyph.ts 24 lines1/** The text `/raven` answers when the terminal is too narrow to dock the pane. */
2export const NARROW_TEXT = 'Widen the terminal to dock the Raven pane'
3
4/**
5 * What a `/raven` command run resolved to. `info` is the fallback for a `CommandOutput` row whose
6 * text no `command()` call in this session produced (a replayed transcript, a reloaded module).
7 */
8export type CommandKind = 'shown' | 'hidden' | 'narrow' | 'error' | 'info'
9
10export type CommandResult = { kind: CommandKind; text: string }
11
12const GLYPHS: Record<CommandKind, string> = {
13 shown: '◆',
14 hidden: '◇',
15 narrow: '!',
16 error: '!',
17 info: '◆',
18}
19
20/** Which glyph leads a `/raven` command's output row, by its result's kind. */
21export function commandGlyphOf(kind: CommandKind): string {
22 return GLYPHS[kind]
23}
24hooks/core/directive.ts 57 lines1import { DIRECTIVE_PREFIX, FALLBACK_PREFIX, PANE_SUBCOMMANDS, type PaneSubcommand } from '../names'
2import { isRecord } from './is-record'
3
4export const DIRECTIVE_OPS = ['show', 'note', 'diff', 'comments', 'open'] as const
5type DirectiveOp = (typeof DIRECTIVE_OPS)[number]
6
7/** What the CLI asks of the pane; see spec/agentic.md. */
8export type Directive =
9 | { op: 'show'; path: string; title?: string }
10 | { op: 'note'; markdown: string; title?: string }
11 | { op: 'diff'; path?: string }
12 | { op: 'comments' }
13 | { op: 'open'; pane: PaneSubcommand }
14
15const optionalString = (value: unknown) => value === undefined || typeof value === 'string'
16
17/** Parses one directive's payload (a CLI's printed JSON, or the `show` tool's input) or null. */
18export function directiveOf(value: unknown): Directive | null {
19 if (!isRecord(value)) return null
20 const { op, path, title, markdown, pane } = value
21 if (!optionalString(title) || !optionalString(path)) return null
22 if (!DIRECTIVE_OPS.includes(op as DirectiveOp)) return null
23
24 switch (op as DirectiveOp) {
25 case 'show':
26 return typeof path === 'string' ? (value as Directive) : null
27 case 'note':
28 return typeof markdown === 'string' ? (value as Directive) : null
29 case 'open':
30 return PANE_SUBCOMMANDS.includes(pane as PaneSubcommand) ? (value as Directive) : null
31 case 'diff':
32 case 'comments':
33 return value as Directive
34 }
35}
36
37/** The directives in a command's stdout, in order; malformed and unknown lines are skipped. */
38export function directivesIn(stdout: string): Directive[] {
39 return stdout.split('\n').flatMap(line => {
40 if (!line.startsWith(DIRECTIVE_PREFIX)) return []
41 try {
42 const directive = directiveOf(JSON.parse(line.slice(DIRECTIVE_PREFIX.length)))
43 return directive ? [directive] : []
44 } catch {
45 return []
46 }
47 })
48}
49
50/** A command's output with the CLI's directive and fallback lines removed, keeping everything else. */
51export const withoutDirectives = (output: string) =>
52 output
53 .split('\n')
54 .filter(line => !line.startsWith(DIRECTIVE_PREFIX) && !line.startsWith(FALLBACK_PREFIX))
55 .join('\n')
56 .trim()
57hooks/core/host.ts 57 lines1import type { PaneOpenArgs, SessionMessage, Timer } from 'claude-code'
2
3export type RunResult = {
4 exitCode: number
5 stdout: string
6 stderr: string
7 /** Set when the engine cut `stdout` at its 4 MiB cap; absent from fakes that never cut it. */
8 isStdoutTruncated?: boolean
9}
10
11/**
12 * The slice of the engine Raven uses, bound once from `$` at `session.start`. Controllers and
13 * views take a Host rather than `$`, so they can be driven by a fake in unit tests.
14 */
15export type Host = {
16 run: (argv: readonly string[], stdin?: string) => Promise<RunResult>
17 readFile: (path: string) => Promise<string>
18 after: (ms: number, fn: () => void) => Timer
19 redraw: () => void
20 /** Resolves false when the engine left the pane waiting undrawn (too narrow to dock). */
21 openPane: (pane: PaneOpenArgs) => Promise<boolean>
22 closePane: (id: string) => Promise<void>
23 /** Every pane id the surface currently shows, off one call, for a check over several ids. */
24 shownPaneIds: () => Promise<ReadonlySet<string>>
25 /** Moves keyboard focus to the element drawn under `key` in pane `paneId`. */
26 focus: (paneId: string, key: string) => Promise<void>
27 storeGet: (key: string) => Promise<unknown>
28 storeSet: (key: string, value: unknown) => Promise<void>
29 submitPrompt: (text: string) => Promise<void>
30 /** The session's working directory, absolute; relative tool paths resolve against it. */
31 cwd: () => Promise<string>
32 /** Forks the main thread's last turn with `prompt`; the reply's text, or null when unanswered. */
33 fork: (prompt: string) => Promise<string | null>
34 /** Pins `text` as Raven's status line under the prompt; `undefined` clears it. */
35 status: (text: string | undefined) => void
36 /** Replaces the prompt box's draft with `text`; false when no box could take it. */
37 fillPrompt: (text: string) => Promise<{ isFilled: boolean; refusal?: 'no_composer' | 'dialog' }>
38 /** A transient toast over the transcript, for an error the pane has no room to show inline. */
39 toast: (text: string) => void
40 /** The main conversation's transcript so far. */
41 messages: () => Promise<readonly SessionMessage[]>
42 /** Writes `text` to the debug log only, for a failure a person never needs to see. */
43 debug: (text: string) => void
44 /** The parsed contents of `~/.claude.json` (settings never carries it); null when unreadable. */
45 readGlobalConfig: () => Promise<unknown>
46 /** Whether the session checkpoints Claude's edits, the built-in diff panel's own gate. */
47 isCheckpointing: () => Promise<boolean>
48}
49
50/** A `.catch` handler that logs the failure to the debug log and answers `fallback`. */
51export const loggedAs =
52 <T>(host: Pick<Host, 'debug'>, what: string, fallback: T) =>
53 (error: unknown): T => {
54 host.debug(`raven: ${what} failed: ${String(error)}`)
55 return fallback
56 }
57hooks/core/is-record.ts 4 lines1/** Narrows untrusted data (store values, JSON, tool results) to an object whose fields can be read. */
2export const isRecord = (value: unknown): value is Record<string, unknown> =>
3 typeof value === 'object' && value !== null
4hooks/core/raven.ts 517 lines1import type { RenderElement, Timer, TurnCompleteInput } from 'claude-code'
2import { existingPathsOf } from '../git/load'
3import { DIFF_PANEL_WARNING } from '../names'
4import { addressedIdsOf, type Comment, reviewTextOf } from '../review/comments'
5import { resolvePromptOf } from '../review/resolve'
6import { createReview } from '../review/review'
7import { createDiffView } from '../views/diff-view'
8import { createDocView, type Doc } from '../views/doc-view'
9import { createTasksView } from '../views/tasks-view'
10import { createTreeView } from '../views/tree-view'
11import { shouldAutoOpen } from './auto-open'
12import { createBandState } from './band-state'
13import { coversRavenDock } from './checkpointing'
14import { type CommandKind, type CommandResult, NARROW_TEXT } from './command-glyph'
15import { type Directive, directiveOf } from './directive'
16import { countOf } from './format'
17import { type Host, loggedAs } from './host'
18import { createPresses } from './presses'
19import type { RavenSettings } from './settings'
20import {
21 type Action,
22 actionsOf,
23 type PlanNote,
24 planActionsOf,
25 type ToolEvent,
26 triggersOf,
27} from './triggers'
28import type { Kit, View } from './view'
29
30/** The engine-facing surface of Raven; `register` forwards engine events here and nothing else. */
31export type Raven = {
32 /** The `/raven` argument hint: each view's subcommand, then `send`. */
33 argumentHint: string
34 command: (args: string) => Promise<CommandResult>
35 /**
36 * Reacts to a finished tool call; returns replacement result text for the model, if any. Never
37 * rejects: a failure goes to the debug log.
38 */
39 afterTool: (event: ToolEvent) => Promise<string | undefined>
40 /**
41 * Learns the plan file plan mode named, so its edits render live; opens it on exit/re-entry.
42 * Never rejects: a failure goes to the debug log.
43 */
44 planNoted: (note: PlanNote) => Promise<void>
45 /** Runs the `show` tool's input as a directive; throws on input `directiveOf` rejects. */
46 runTool: (input: unknown) => Promise<string>
47 /**
48 * Hidden context the next prompt carries: the live pending comments, which it marks sent (a
49 * stale pending comment among them moves to 'open' instead, never riding). `undefined` when
50 * there is nothing live to carry. `ids` is the taken batch, for `restoreCarried` if the prompt
51 * this primes is later dropped.
52 */
53 takePromptContext: () => Promise<
54 { text: string; count: number; ids: readonly string[] } | undefined
55 >
56 /** The prompt `takePromptContext` primed went through: toasts how many comments it carried. */
57 noteCarried: (count: number) => void
58 /** The prompt `takePromptContext` primed was dropped: undoes it, restoring `ids` to pending. */
59 restoreCarried: (ids: readonly string[]) => void
60 /**
61 * Reacts to a finished main-loop turn: always resets the live feed (clears this turn's edited
62 * marks and resumes following); only an answered turn also forks once to learn which sent
63 * comments it addressed.
64 */
65 turnCompleted: (turn: TurnCompleteInput) => Promise<void>
66 render: (paneId: string, kit: Kit) => RenderElement | null
67 /**
68 * Runs the handler a `Client` pill's `{ press: id }` post names, in the pane `paneId` drew it in;
69 * false when the id is not (or no longer) drawn there.
70 */
71 press: (paneId: string, id: string) => boolean
72 /** Registers the handler for a pill `id` drawn in `paneId`'s current render (`Kit.press`). */
73 registerPress: (paneId: string, id: string, onPress: () => void) => void
74 /** Moves a pane's own scroll by `by` rows; true when its view handled the move. */
75 scroll: (paneId: string, by: number) => boolean
76 paneClosed: (paneId: string) => void
77 /** Records the terminal's width off any `ui.render` Raven sees, for the auto-open gate. */
78 noteViewport: (columns: number | undefined) => void
79 /**
80 * The `AbovePrompt` band: null while a survey holds it, `maxRows` is too small, nothing is
81 * pending, or a Raven pane is already visible (not just open behind another tab).
82 */
83 band: (kit: Kit, hasSurvey: boolean, requestId: string) => Promise<RenderElement | null>
84 /** The kind a past `command()` call resolved `text` to; `'info'` when no call produced it. */
85 resultKindOf: (text: string) => CommandKind
86}
87
88const REFRESH_DEBOUNCE_MS = 300
89const SEND = 'send'
90
91const REFUSAL_TEXTS: Record<'no_composer' | 'dialog', string> = {
92 no_composer: 'no prompt box in this session',
93 dialog: 'a dialog has the keyboard',
94}
95
96export function createRaven(host: Host, settings: RavenSettings, now: () => number): Raven {
97 const review = createReview(host, now)
98 const diff = createDiffView(
99 host,
100 review,
101 {
102 send: () => void sendReview(),
103 editAndSend: () => void editAndSend(),
104 focus: key => void focusIn(diff, key),
105 },
106 now,
107 )
108 const doc = createDocView(host, review, { focus: key => void focusIn(doc, key) }, now)
109 const tree = createTreeView(host, { open: path => void showDoc({ kind: 'file', path }) })
110 const tasksView = createTasksView(host)
111 const views: readonly View[] = [diff, doc, tree, tasksView]
112
113 const open = new Set<string>()
114 let refreshTimer: Timer | null = null
115 let hasAutoOpened = false
116 let hasOpenedTasks = false
117 let hasWarnedDiffPanel = false
118 // Guards the fork below from re-entering itself and caps it at one per sent batch.
119 let isResolving = false
120 const planPaths = new Set<string>()
121 const triggers = triggersOf(settings)
122 // The kind the most recent `command()` call resolved each reply text to, for a `CommandOutput`
123 // row the engine asks Raven to redraw without re-running the command.
124 const resultKindByText = new Map<string, CommandKind>()
125 const presses = createPresses()
126
127 const bandState = createBandState(host, review, {
128 openDiff: async () => {
129 await showDiff()
130 },
131 openDoc: async () => {
132 await show(doc)
133 },
134 sendReview,
135 })
136
137 // Kicked off at session start rather than waiting for the first `AbovePrompt` render or prompt
138 // submit to need it: by the time either happens, the common case already has it loaded.
139 // `ensureReviewLoaded` is idempotent, so the `await` each still does is then a no-op; this
140 // only spares the *first* one from paying for the git read itself.
141 void bandState.ensureReviewLoaded()
142
143 /**
144 * Once per module instance: warns when the built-in diff panel will cover Raven's dock. Never
145 * throws, so a failed check never blocks the command or edit that triggered it.
146 */
147 async function warnDiffPanelOnce(): Promise<void> {
148 if (hasWarnedDiffPanel) return
149 hasWarnedDiffPanel = true
150 try {
151 const [globalConfig, checkpointing] = await Promise.all([
152 host.readGlobalConfig(),
153 host.isCheckpointing(),
154 ])
155 if (coversRavenDock(globalConfig, checkpointing)) host.toast(DIFF_PANEL_WARNING)
156 } catch (error) {
157 host.debug(`raven: diff panel check failed: ${String(error)}`)
158 }
159 }
160
161 const takeReviewText = () => reviewTextOf(review.take())
162
163 /**
164 * A pending comment is live when it has a `section` (a doc comment, always live); `known` (the
165 * diff's `knownPaths` at call time) is null (no live git list to check against: a turn source,
166 * or nothing loaded yet); its path is among those known paths (which include a renamed file's
167 * `oldPath`); or `existing` says the file is still there: checked only for comments `known`
168 * doesn't already cover (R32: it's the diff moving past a file, not the file being gone, that
169 * stops meaning "stale"). `existing` null (the check never ran, or failed) never counts against
170 * a comment: fail open, same as `known` null. Only a comment whose file is truly gone moves to
171 * 'open' by `review.take`, rather than riding the prompt silently.
172 */
173 const isLiveComment =
174 (known: ReadonlySet<string> | null, existing: ReadonlySet<string> | null) =>
175 (comment: Comment) =>
176 comment.section !== undefined ||
177 known === null ||
178 known.has(comment.path) ||
179 existing === null ||
180 existing.has(comment.path)
181
182 /** Forks once to ask which sent comments the finished turn addressed, then marks them. */
183 async function resolveSent(): Promise<void> {
184 const sent = review.sent()
185 if (sent.length === 0 || isResolving) return
186 isResolving = true
187 // Captured before the fork: a newer batch sent while this fork is in flight must not be
188 // touched by the reply this one gets back.
189 const batchIds = sent.map(comment => comment.id)
190 try {
191 const reply = await host.fork(resolvePromptOf(sent))
192 // No bracketed array in the reply means it was unparseable, not "nothing addressed"; leave
193 // the comments sent rather than bouncing every one of them to 'open'.
194 if (reply !== null && /\[[\s\S]*\]/.test(reply)) {
195 review.resolveBatch(batchIds, addressedIdsOf(reply, batchIds))
196 host.redraw()
197 }
198 } catch (error) {
199 host.debug(`raven: resolving sent comments failed: ${String(error)}`)
200 } finally {
201 isResolving = false
202 }
203 }
204
205 function cancelRefresh() {
206 refreshTimer?.cancel()
207 refreshTimer = null
208 }
209
210 /** Submits the pending review as a visible prompt, so the person sees what Claude was asked. */
211 async function sendReview(): Promise<boolean> {
212 const text = takeReviewText()
213 if (text !== undefined) await host.submitPrompt(text)
214 return text !== undefined
215 }
216
217 const refusalTextOf = (refusal: 'no_composer' | 'dialog' | undefined) =>
218 (refusal && REFUSAL_TEXTS[refusal]) ?? 'the fill was refused'
219
220 /** Fills the prompt box with the pending review so the person can edit it before sending. */
221 async function editAndSend(): Promise<void> {
222 const taken = review.take()
223 const text = reviewTextOf(taken)
224 if (text === undefined) return
225 const filled = await host.fillPrompt(text)
226 if (!filled.isFilled) {
227 review.restore(taken.map(comment => comment.id))
228 host.toast(`Raven: could not fill the prompt (${refusalTextOf(filled.refusal)})`)
229 }
230 }
231
232 /**
233 * Shows a view's pane, bringing it forward when it is a tab behind another; false when the
234 * terminal is too narrow to dock it.
235 */
236 const isPaneShown = async (id: string) => (await host.shownPaneIds()).has(id)
237
238 async function show(view: View, focus?: true): Promise<boolean> {
239 if (open.has(view.pane.id)) {
240 if (!focus && (await isPaneShown(view.pane.id))) return true
241 // Reopening an open id only retitles it; a fresh open brings a background tab forward.
242 if (!focus) await host.closePane(view.pane.id)
243 }
244 // Marked before the open: the engine draws the pane while `openPane` is in flight.
245 open.add(view.pane.id)
246 const isPlaced = await host.openPane({ ...view.pane, holdToasts: true, focus })
247 // A pane left waiting would seat itself on a later resize; withdraw it instead.
248 if (!isPlaced) await host.closePane(view.pane.id)
249 return isPlaced
250 }
251
252 async function hide(view: View) {
253 await host.closePane(view.pane.id)
254 open.delete(view.pane.id)
255 host.redraw()
256 }
257
258 /** The keyboard is the person's: an element can take it only once its pane asked for focus. */
259 async function focusIn(view: View, key: string) {
260 if (await show(view, true)) {
261 await host
262 .focus(view.pane.id, key)
263 .catch(error => host.debug(`raven: focus failed: ${String(error)}`))
264 }
265 }
266
267 function scheduleRefresh() {
268 cancelRefresh()
269 refreshTimer = host.after(REFRESH_DEBOUNCE_MS, () => {
270 refreshTimer = null
271 if (open.has(diff.pane.id)) void diff.refresh()
272 if (open.has(tree.pane.id)) void tree.refresh()
273 })
274 }
275
276 async function showDiff(path?: string) {
277 cancelRefresh()
278 await diff.refresh()
279 if (open.has(tree.pane.id)) void tree.refresh()
280 if (path) diff.reveal(path)
281 return show(diff)
282 }
283
284 async function showDoc(shown: Doc) {
285 await doc.show(shown)
286 bandState.noteDocShown(
287 shown.kind === 'file' ? shown.path : undefined,
288 await isPaneShown(doc.pane.id),
289 )
290 return show(doc)
291 }
292
293 async function showTree() {
294 await tree.refresh({ force: true })
295 return show(tree)
296 }
297
298 const shownText = (isShown: boolean, what: string) =>
299 isShown
300 ? `Shown in the Raven pane: ${what}.`
301 : `Raven could not dock its pane (the terminal is too narrow): ${what} was not shown.`
302
303 /** Shows `view` the way its slash command does, minus the toggle: always leaves it visible. */
304 const showView = (view: View) =>
305 view === diff ? showDiff() : view === tree ? showTree() : show(view)
306
307 function viewOf(subcommand: string): View {
308 const view = views.find(each => each.subcommand === subcommand)
309 if (!view) throw new Error(`Raven has no pane "${subcommand}"`)
310 return view
311 }
312
313 async function runDirective(directive: Directive): Promise<string> {
314 switch (directive.op) {
315 case 'show':
316 return shownText(
317 await showDoc({ kind: 'file', path: directive.path, title: directive.title }),
318 directive.path,
319 )
320 case 'note':
321 return shownText(
322 await showDoc({ kind: 'note', markdown: directive.markdown, title: directive.title }),
323 directive.title ?? 'the note',
324 )
325 case 'diff':
326 return shownText(await showDiff(directive.path), 'the diff')
327 case 'open':
328 return shownText(await showView(viewOf(directive.pane)), `the ${directive.pane} pane`)
329 case 'comments':
330 return takeReviewText() ?? 'The user has no pending review comments.'
331 }
332 }
333
334 /** A relative `path` is the tool's own, resolved against the session's cwd, not the CLI's shell. */
335 async function resolveDirective(directive: Directive): Promise<Directive> {
336 if (directive.op !== 'show' && directive.op !== 'diff') return directive
337 const { path } = directive
338 if (path === undefined || path.startsWith('/')) return directive
339 return { ...directive, path: `${await host.cwd()}/${path}` }
340 }
341
342 /** Every pane Raven opens unasked goes through this one gate. */
343 const mayAutoOpen = () => shouldAutoOpen(settings, bandState.viewportColumns())
344
345 async function runAction(action: Action): Promise<string | undefined> {
346 switch (action.kind) {
347 case 'refresh-diff':
348 if (action.path !== undefined) diff.noteEdited(action.path)
349 if (open.has(diff.pane.id) || open.has(tree.pane.id)) scheduleRefresh()
350 return undefined
351 case 'main-loop-edit':
352 void warnDiffPanelOnce()
353 if (!hasAutoOpened) {
354 hasAutoOpened = true
355 if (mayAutoOpen()) await showDiff()
356 }
357 return undefined
358 case 'show-doc':
359 if (mayAutoOpen()) await showDoc({ kind: 'file', path: action.path })
360 return undefined
361 case 'reload-doc':
362 await doc.reload(action.path)
363 bandState.noteDocReloaded(action.path, await isPaneShown(doc.pane.id))
364 return undefined
365 case 'directive':
366 return runDirective(action.directive)
367 case 'tasks': {
368 const changed = tasksView.apply(action.tool, action.input, action.result)
369 if (changed && !hasOpenedTasks && tasksView.hasTasks()) {
370 hasOpenedTasks = true
371 if (open.size === 0 && mayAutoOpen()) await show(tasksView)
372 }
373 return undefined
374 }
375 }
376 }
377
378 /** Records `kind` against `text` for a later `resultKindOf`, and returns the pair as-is. */
379 const resultOf = (kind: CommandKind, text: string): CommandResult => {
380 resultKindByText.set(text, kind)
381 return { kind, text }
382 }
383
384 async function toggle(view: View): Promise<CommandResult> {
385 const name = `Raven ${view.pane.title.toLowerCase()}`
386 if (open.has(view.pane.id) && (await isPaneShown(view.pane.id))) {
387 await hide(view)
388 return resultOf('hidden', `${name} hidden`)
389 }
390 const isShown = await showView(view)
391 return isShown ? resultOf('shown', `${name} shown`) : resultOf('narrow', NARROW_TEXT)
392 }
393
394 const argumentHint = `[${[...views.map(view => view.subcommand), SEND].join('|')}]`
395
396 /** Never rejects: a thrown failure becomes an `'error'` result like usage help does. */
397 async function command(args: string): Promise<CommandResult> {
398 try {
399 await warnDiffPanelOnce()
400 const word = args.trim() || diff.subcommand
401 const view = views.find(each => each.subcommand === word)
402 if (view) return toggle(view)
403 if (word === SEND) {
404 return (await sendReview())
405 ? resultOf('shown', 'Review sent')
406 : resultOf('hidden', 'No review comments to send')
407 }
408 return resultOf('error', `Usage: /raven ${argumentHint}`)
409 } catch (error) {
410 return resultOf('error', `Raven failed: ${String(error)}`)
411 }
412 }
413
414 return {
415 argumentHint,
416 command,
417 afterTool: async event => {
418 try {
419 const texts: string[] = []
420 for (const action of actionsOf(event, triggers, planPaths)) {
421 const text = await runAction(action)
422 if (text !== undefined) texts.push(text)
423 }
424 return texts.length > 0 ? texts.join('\n') : undefined
425 } catch (error) {
426 host.debug(`raven: afterTool failed: ${String(error)}`)
427 return undefined
428 }
429 },
430 planNoted: async note => {
431 planPaths.add(note.planFilePath)
432 try {
433 for (const action of planActionsOf(note)) await runAction(action)
434 } catch (error) {
435 host.debug(`raven: planNoted failed: ${String(error)}`)
436 }
437 },
438 runTool: async input => {
439 const directive = directiveOf(input)
440 if (!directive)
441 throw new Error(`Invalid input for the Raven show tool: ${JSON.stringify(input)}`)
442 return runDirective(await resolveDirective(directive))
443 },
444 takePromptContext: async () => {
445 // A prompt can carry comments before any pane has ever loaded the review (the band's own
446 // load path, reused here so both see the same `hasLoadedReview` guard).
447 await bandState.ensureReviewLoaded()
448 const known = diff.knownPaths()
449 const toplevel = diff.toplevel()
450 let existing: ReadonlySet<string> | null = null
451 if (known !== null && toplevel !== null) {
452 // Only the comments `known` doesn't already resolve need the existence check:
453 // `existingPathsOf` batches all of them together (chunked, never one call per comment),
454 // and this is skipped entirely when `known` is null (everything is already live) or
455 // there is nothing left to check.
456 const candidates = review
457 .pending()
458 .filter(comment => comment.section === undefined && !known.has(comment.path))
459 if (candidates.length > 0) {
460 const paths = [...new Set(candidates.map(comment => comment.path))]
461 existing = await existingPathsOf(host.run, toplevel, paths).catch(
462 loggedAs(host, 'checking comment file existence', null),
463 )
464 }
465 }
466 const taken = review.take(isLiveComment(known, existing))
467 const text = reviewTextOf(taken)
468 if (text === undefined) return undefined
469 return { text, count: taken.length, ids: taken.map(comment => comment.id) }
470 },
471 noteCarried: count => {
472 host.toast(`Raven: ${countOf(count, 'review comment')} sent with this prompt`)
473 },
474 restoreCarried: ids => {
475 if (ids.length > 0) review.restore(ids)
476 },
477 turnCompleted: async turn => {
478 diff.turnEnded()
479 if (turn.reason === 'answer') await resolveSent()
480 },
481 // A throwing handler is logged, never rejected into the `ui.message` hook (which would fail the post).
482 press: (paneId, id) => {
483 try {
484 return presses.dispatch(paneId, id)
485 } catch (error) {
486 host.debug(`raven: press handler "${id}" threw: ${String(error)}`)
487 return true
488 }
489 },
490 registerPress: (paneId, id, onPress) => presses.add(paneId, id, onPress),
491 render: (paneId, kit) => {
492 const view = views.find(each => each.pane.id === paneId)
493 if (!view) return null
494 // A reloaded module inherits open panes it never opened: drawing one proves it is open, and
495 // its model starts empty until a refresh.
496 if (!open.has(paneId)) {
497 open.add(paneId)
498 void view.refresh?.().catch(error => host.debug(`raven: refresh failed: ${String(error)}`))
499 }
500 if (view === doc) bandState.noteDocRendered()
501 presses.begin(paneId)
502 return view.render(kit)
503 },
504 scroll: (paneId, by) => views.find(view => view.pane.id === paneId)?.scroll?.(by) ?? false,
505 paneClosed: paneId => {
506 open.delete(paneId)
507 host.redraw()
508 },
509 noteViewport: columns => bandState.noteViewport(columns),
510 // `begin` runs right before the band's synchronous registrations, after the band's awaits: a
511 // render that draws nothing, or a newer one landing meanwhile, never wipes handlers it still needs.
512 band: (kit, hasSurvey, requestId) =>
513 bandState.band(kit, hasSurvey, () => presses.begin(requestId)),
514 resultKindOf: text => resultKindByText.get(text) ?? 'info',
515 }
516}
517hooks/core/settings.ts 51 lines1import type { PluginOptions } from 'claude-code'
2
3/** Raven's parsed `userConfig`, defaults filled in for any field a wrong type left unusable. */
4export type RavenSettings = {
5 /** Extra path fragments (trimmed, non-empty) whose `.md` files open in the Doc view. */
6 watchedPaths: readonly string[]
7 /** Lets Raven open panes nobody asked for; `shouldAutoOpen` is the one gate. */
8 autoOpen: boolean
9 /** Skips those opens below this terminal width, in columns. */
10 autoOpenColumns: number
11 /** Draws plain Tab-reachable Buttons instead of interactive pills on every surface (R42). */
12 keyboardControls: boolean
13}
14
15export const DEFAULT_SETTINGS: RavenSettings = {
16 watchedPaths: [],
17 autoOpen: true,
18 autoOpenColumns: 144,
19 keyboardControls: false,
20}
21
22const watchedPathsOf = (value: unknown): readonly string[] => {
23 if (typeof value !== 'string') return DEFAULT_SETTINGS.watchedPaths
24 const fragments = value
25 .split(',')
26 .map(fragment => fragment.trim())
27 .filter(fragment => fragment !== '')
28 return fragments.length > 0 ? fragments : DEFAULT_SETTINGS.watchedPaths
29}
30
31const autoOpenOf = (value: unknown): boolean =>
32 typeof value === 'boolean' ? value : DEFAULT_SETTINGS.autoOpen
33
34const autoOpenColumnsOf = (value: unknown): number =>
35 typeof value === 'number' && Number.isFinite(value) && value > 0
36 ? value
37 : DEFAULT_SETTINGS.autoOpenColumns
38
39/** Parses `register`'s `options` defensively: a field of the wrong type falls back to its default. */
40export function settingsOf(options: PluginOptions): RavenSettings {
41 return {
42 watchedPaths: watchedPathsOf(options.watchedPaths),
43 autoOpen: autoOpenOf(options.autoOpen),
44 autoOpenColumns: autoOpenColumnsOf(options.autoOpenColumns),
45 keyboardControls:
46 typeof options.keyboardControls === 'boolean'
47 ? options.keyboardControls
48 : DEFAULT_SETTINGS.keyboardControls,
49 }
50}
51hooks/core/triggers.ts 144 lines1import { TASK_TOOLS } from '../review/tasks'
2import { type Directive, directivesIn } from './directive'
3import type { RavenSettings } from './settings'
4
5/** A finished tool call, as the triggers read it. */
6export type ToolEvent = {
7 tool: string
8 input: Readonly<Record<string, unknown>>
9 /** The call ran and was neither refused nor an error. */
10 isLanded: boolean
11 /** Set when a subagent made the call. */
12 agentId?: string
13 stdout?: string
14 /** The tool result's structured `result`, when the call landed. */
15 result?: unknown
16}
17
18export type Action =
19 /** `path` is the edit that caused it; a shell refresh carries no path. */
20 | { kind: 'refresh-diff'; path?: string }
21 /** The main loop's edit landed: the controller opens the diff on the first of these. */
22 | { kind: 'main-loop-edit' }
23 | { kind: 'show-doc'; path: string }
24 | { kind: 'reload-doc'; path: string }
25 | { kind: 'directive'; directive: Directive }
26 | { kind: 'tasks'; tool: string; input: Readonly<Record<string, unknown>>; result?: unknown }
27
28/** A pure reading of one finished call, given the plan files plan mode has named so far. */
29export type Trigger = (event: ToolEvent, planPaths: ReadonlySet<string>) => readonly Action[]
30
31const EDIT_TOOLS = ['Edit', 'Write', 'NotebookEdit', 'MultiEdit']
32const SHELL_TOOLS = ['Bash', 'PowerShell']
33
34/**
35 * Markdown written under these paths opens in the doc view as it is written. Plan mode's own plan
36 * file is not guessed here: its path arrives on plan mode's notes (`PlanNote`).
37 */
38const WATCHED_DOC_PATTERNS = [
39 /\/docs\/superpowers\/(plans|specs)\/[^/]+\.md$/,
40 /\/\.superpowers\/.+\.md$/,
41]
42
43/** `fragment` split into its non-empty `/`-separated segments. */
44const segmentsOf = (value: string) => value.split('/').filter(segment => segment !== '')
45
46/**
47 * True when `fragment`'s segments occur contiguously among `path`'s, so a single-segment fragment
48 * like `docs` never matches a longer segment like `docsystem`, while a multi-segment fragment like
49 * `notes/drafts` still matches across the two segments it names.
50 */
51const containsFragment = (path: string, fragment: string): boolean => {
52 const needle = segmentsOf(fragment)
53 if (needle.length === 0) return false
54 const haystack = path.split('/')
55 for (let start = 0; start + needle.length <= haystack.length; start++) {
56 if (needle.every((segment, i) => haystack[start + i] === segment)) return true
57 }
58 return false
59}
60
61const isWatchedDocPath = (
62 path: string,
63 watchedPaths: readonly string[],
64 planPaths: ReadonlySet<string>,
65) =>
66 planPaths.has(path) ||
67 (path.endsWith('.md') &&
68 (WATCHED_DOC_PATTERNS.some(pattern => pattern.test(path)) ||
69 watchedPaths.some(fragment => containsFragment(path, fragment))))
70
71/** The file a landed edit wrote, or null for any other call. */
72const editedPathOf = (event: ToolEvent) => {
73 if (!event.isLanded || !EDIT_TOOLS.includes(event.tool)) return null
74 const path = event.input.file_path ?? event.input.notebook_path
75 return typeof path === 'string' ? path : null
76}
77
78const onEdit: Trigger = event => {
79 const path = editedPathOf(event)
80 if (path === null) return []
81 const actions: Action[] = [
82 { kind: 'refresh-diff', path },
83 { kind: 'reload-doc', path },
84 ]
85 if (event.agentId === undefined) actions.push({ kind: 'main-loop-edit' })
86 return actions
87}
88
89// A failed or interrupted command may still have written files, so any shell call refreshes.
90const onShell: Trigger = event =>
91 SHELL_TOOLS.includes(event.tool) ? [{ kind: 'refresh-diff' }] : []
92
93const onWatchedDocOf =
94 (watchedPaths: readonly string[]): Trigger =>
95 (event, planPaths) => {
96 const path = editedPathOf(event)
97 return path !== null && isWatchedDocPath(path, watchedPaths, planPaths)
98 ? [{ kind: 'show-doc', path }]
99 : []
100 }
101
102const onDirective: Trigger = event =>
103 SHELL_TOOLS.includes(event.tool) && event.stdout
104 ? directivesIn(event.stdout).map(directive => ({ kind: 'directive', directive }))
105 : []
106
107const onTasks: Trigger = event =>
108 event.isLanded && TASK_TOOLS.includes(event.tool)
109 ? [{ kind: 'tasks', tool: event.tool, input: event.input, result: event.result }]
110 : []
111
112/** Every trigger, built once per session from its settings; only `onWatchedDoc` reads them. */
113export const triggersOf = (settings: RavenSettings): readonly Trigger[] => [
114 onEdit,
115 onShell,
116 onWatchedDocOf(settings.watchedPaths),
117 onDirective,
118 onTasks,
119]
120
121export const actionsOf = (
122 event: ToolEvent,
123 triggers: readonly Trigger[],
124 planPaths: ReadonlySet<string> = new Set(),
125) => triggers.flatMap(trigger => trigger(event, planPaths))
126
127/**
128 * One of plan mode's notes to the main loop, off `prompt.attachment`: `plan_mode` rides every
129 * request while planning, `plan_mode_exit` and `plan_mode_reentry` mark leaving and re-entering it.
130 * Each names the plan file wherever the session keeps plans.
131 */
132export type PlanNote = {
133 type: 'plan_mode' | 'plan_mode_exit' | 'plan_mode_reentry'
134 planFilePath: string
135 /** Whether the engine found the plan file; a re-entry note is only made when it did. */
136 hasPlan?: boolean
137}
138
139/** Leaving or re-entering plan mode opens the plan, when there is one to read. */
140export const planActionsOf = (note: PlanNote): readonly Action[] =>
141 note.type !== 'plan_mode' && note.hasPlan !== false
142 ? [{ kind: 'show-doc', path: note.planFilePath }]
143 : []
144hooks/core/view.ts 103 lines1import type { Elements, RenderElement, RenderSurface } from 'claude-code'
2import type { PaneSubcommand } from '../names'
3
4/**
5 * The elements a view draws with; Raven draws on the terminal surface, mobile among them. The
6 * engine always completes every surface's table to a constructor for every element name, even one
7 * that surface doesn't carry (it just draws a fragment there); so `Image`, `Input` and `Select`
8 * are typed as present here too, never `| undefined`: presence can never tell a real control from
9 * a completed fragment. A view never checks for one; it reads `kit.capabilities` instead, which
10 * `capabilitiesOf` derives from the surface name, the one source of truth for what each surface
11 * actually carries.
12 */
13export type Ui = Pick<
14 Elements['terminal'],
15 'Box' | 'Text' | 'Button' | 'Code' | 'Markdown' | 'Image' | 'Input' | 'Select' | 'Client'
16>
17
18/** What a surface's element table lets a view draw: typed text, a picker, an inline image. */
19export type Capabilities = {
20 canType: boolean
21 canPick: boolean
22 canShowImage: boolean
23 /** Whether the surface draws a `Client` (a surface module with pointer hover); see `ui/strip.tsx`. */
24 canClient: boolean
25}
26
27/**
28 * The fixed, per-surface element table (`Elements` in `claude-code`): every surface carries
29 * `Input` and `Select` but `mobile`; only `terminal` carries `Image`.
30 */
31const CAPABILITIES_BY_SURFACE: Record<RenderSurface, Capabilities> = {
32 terminal: { canType: true, canPick: true, canShowImage: true, canClient: true },
33 desktop: { canType: true, canPick: true, canShowImage: false, canClient: true },
34 vscode: { canType: true, canPick: true, canShowImage: false, canClient: false },
35 mobile: { canType: false, canPick: false, canShowImage: false, canClient: false },
36}
37
38/** A surface not among the table's known rows draws no typed, picking or imaging controls. */
39const NO_CAPABILITIES: Capabilities = {
40 canType: false,
41 canPick: false,
42 canShowImage: false,
43 canClient: false,
44}
45
46/**
47 * A surface's capabilities, read off the fixed table above, never off element presence. An
48 * unknown future surface (one the table hasn't been taught yet) degrades to `NO_CAPABILITIES`
49 * rather than throwing, so a view still renders, just without controls the surface may not
50 * actually support.
51 */
52export function capabilitiesOf(surface: RenderSurface, keyboardControls = false): Capabilities {
53 const capabilities = CAPABILITIES_BY_SURFACE[surface] ?? NO_CAPABILITIES
54 // R42: with `keyboardControls` every surface draws the plain-Button fallback, which is in the Tab ring.
55 return keyboardControls ? { ...capabilities, canClient: false } : capabilities
56}
57
58/** `terminal`'s row, every capability on, so a caller outside a real render can skip the surface. */
59export const FULL_CAPABILITIES: Capabilities = CAPABILITIES_BY_SURFACE.terminal
60
61/** The engine refuses a `Markdown`, `Code` or `Text` element whose text is longer than this. */
62export const ELEMENT_TEXT_LIMIT = 10_000
63
64/**
65 * What a view's render is handed: the elements, the pane body's width and rows in cells, and the
66 * capabilities its surface carries: computed once in `register.ts`'s `kitOf`, so a view never
67 * calls `capabilitiesOf` or checks an element's presence itself.
68 */
69export type Kit = {
70 ui: Ui
71 columns: number
72 rows: number
73 capabilities: Capabilities
74 /**
75 * Registers the handler a pill's `{ press: id }` post runs, for the pane this render draws; the
76 * controller clears the pane's registrations before each of its renders (`core/presses.ts`).
77 */
78 press: (id: string, onPress: () => void) => void
79}
80
81/**
82 * What a kit function that draws off `kit.ui` alone needs: the `hooks/ui/` kit's own
83 * components take this rather than the full `Kit`, so a caller holding only `{ ui, columns }`
84 * (the `AbovePrompt` band) can still reach them without a cast.
85 */
86export type UiKit = Pick<Kit, 'ui'>
87
88/**
89 * One engine pane Raven draws. The engine shows one pane at a time and tabs the rest, so each
90 * view is a tab.
91 */
92export type View = {
93 readonly pane: { readonly id: string; readonly title: string }
94 /** The `/raven <subcommand>` that toggles it; one of `PANE_SUBCOMMANDS`, so the CLI, the tool
95 * and the views share one list. */
96 readonly subcommand: PaneSubcommand
97 /** Rereads the world; the controller runs it before the first drawing of a fresh module. */
98 refresh?: () => Promise<void>
99 render: (kit: Kit) => RenderElement
100 /** Moves the view's own scroll by `by` rows (negative up); true when it handled the move. */
101 scroll?: (by: number) => boolean
102}
103hooks/names.ts 43 lines1/**
2 * Every name derived from the product's working title, so a rename is one edit here; the CLI
3 * imports these at build time.
4 */
5export const NAME = 'raven'
6
7export const COMMAND = NAME
8export const COMMAND_DESCRIPTION = 'Toggle the Raven preview pane (diff, docs, review)'
9
10export const DIFF_PANE = { id: NAME, title: 'Diff' } as const
11export const DOC_PANE = { id: `${NAME}-doc`, title: 'Doc' } as const
12export const TREE_PANE = { id: `${NAME}-files`, title: 'Files' } as const
13export const TASKS_PANE = { id: `${NAME}-tasks`, title: 'Tasks' } as const
14/** The `/raven <subcommand>` word of each pane, which `raven open <pane>` and the tool share. */
15export const PANE_SUBCOMMANDS = ['diff', 'doc', 'files', 'tasks'] as const
16export type PaneSubcommand = (typeof PANE_SUBCOMMANDS)[number]
17export const PANE_IDS: readonly string[] = [DIFF_PANE.id, DOC_PANE.id, TREE_PANE.id, TASKS_PANE.id]
18
19/** The short name `$.tool.register` takes; the model calls it as `mcp__<plugin>__show`. */
20export const TOOL_NAME = 'show'
21
22/**
23 * The tool's full name, as the model sees it. A plugin loaded from a directory may carry a name
24 * other than `raven`, so this reads `$.plugin.name` at runtime rather than assuming one.
25 */
26export const toolNameOf = (pluginName: string) => `mcp__${pluginName}__${TOOL_NAME}`
27
28/** The start of the line the CLI prints after a directive, for a reader with no mod to consume it. */
29export const FALLBACK_PREFIX = 'Raven pane is not active;'
30
31/** The prefix of a directive line the CLI prints; the JSON payload follows it. */
32export const DIRECTIVE_PREFIX = `::${NAME}::`
33
34/** The store key of one repository's pending review comments. */
35export const commentsStoreKeyOf = (repository: string) => `comments:${repository}`
36
37/** The store key of one repository's diff base choice (HEAD, session start, or branch point). */
38export const sourceStoreKeyOf = (repository: string) => `source:${repository}`
39
40/** Warns that the built-in diff panel, left open, will cover Raven's dock. */
41export const DIFF_PANEL_WARNING =
42 'Close the built-in diff panel (✕) once so Raven can dock beside the transcript'
43hooks/views/band.tsx 85 lines1/* @jsxRuntime classic */
2/* @jsx h */
3/* @jsxFrag Fragment */
4import type { RenderElement } from 'claude-code'
5import { COLORS } from '../core/colors'
6import type { Kit } from '../core/view'
7import { type Chip, chipsLayout } from '../ui/chips'
8import { BAND_GUTTER, BAND_LABEL_CELLS } from '../ui/chrome'
9import { pillRow } from '../ui/strip'
10
11/** What the `AbovePrompt` band has to say: pending review comments, an unseen doc, or both. */
12export type BandProps = {
13 pendingCount: number
14 isDocUpdated: boolean
15}
16
17export type BandActions = {
18 /** Brings the relevant pane forward: the diff when comments are pending, else the doc. */
19 open: () => void
20 send: () => void
21}
22
23/** The band's mark: a bird (nf-md-bird) in the accent. */
24export const BAND_MARK = '\u{f15c6}'
25
26/**
27 * One row above the prompt: `<mark> ✎ 2 pending · plan updated open ➤ send`: the accent Raven
28 * mark, the notes summary in the suggestion colour (the plan flag dim), then the controls as
29 * pills on the right, `send` the one primary action. No brackets, one row. `chipsLayout` shrinks
30 * `open` before `send` keeps its words, same priority rule as the diff header's own chips.
31 */
32export function band(kit: Kit, state: BandProps, actions: BandActions) {
33 const { Box, Text } = kit.ui
34
35 const chips: Chip[] = [
36 { key: 'band:open', icon: '', label: 'open', priority: 0, onPress: actions.open },
37 ]
38 if (state.pendingCount > 0) {
39 chips.push({
40 key: 'band:send',
41 icon: '➤',
42 label: 'send',
43 kind: 'primary',
44 priority: 1,
45 onPress: actions.send,
46 })
47 }
48 const modes = chipsLayout(chips, Math.max(0, kit.columns - BAND_LABEL_CELLS))
49
50 return (
51 <Box
52 flexDirection="row"
53 width={Math.max(1, kit.columns - BAND_GUTTER)}
54 gap={2}
55 justifyContent="space-between"
56 overflow="hidden"
57 flexWrap="nowrap"
58 >
59 <Text wrap="truncate-end">
60 <Text color={COLORS.accent}>{BAND_MARK}</Text>
61 {state.pendingCount > 0 ? (
62 <Text color={COLORS.suggestion}>{` ✎ ${state.pendingCount} pending`}</Text>
63 ) : (
64 ''
65 )}
66 {state.isDocUpdated ? <Text dimColor>{` plan updated`}</Text> : ''}
67 </Text>
68 <Box flexShrink={0}>{pillRow(kit, chips, modes, 'band')}</Box>
69 </Box>
70 )
71}
72
73/** The `/raven` command's output row: its reply text, unchanged, behind a leading glyph. */
74export function commandOutputRow(
75 kit: Pick<Kit, 'ui'>,
76 props: { glyph: string; text: string; isErrored: boolean },
77): RenderElement {
78 const { Text } = kit.ui
79 return (
80 <Text color={props.isErrored ? COLORS.error : undefined} wrap="truncate-end">
81 {props.glyph} {props.text}
82 </Text>
83 )
84}
85