SLOPSHOPPER

raven

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

newpanebandrowsguardcommand
★ 1v0.1.0Apache-2.0updated 2026-10-09arunkumar9t2/raven/plugins/raven
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · raven
│ ┃ Diff ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ 󰊢 1 file □□□□□ · source: HEAD ▾ │ raven │ │ ┃ ▁ ↑ ↓ ▣ client module ../surface/strip. ⏺ Read(src/auth.ts) │ Close the built-in diff panel (✕) once so │ │ ┃ ▣ client module ../surface/strip.tsx ⎿ Read 6 lines │ Raven can dock beside the transcript │ │ ┃ ──────────────────────────────────────────── ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ │ ┃ ─────────── ⎿ Added 2 lines, removed 1 line │ ┃ ╭─ 󰈔 src/auth.ts ?? src/auth.test.ts M +0 −0 ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /raven │ ┃ ◆ Raven diff shown │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Diff
󰊢 1 file □□□□□ · source: HEAD ▾ ▁ ↑ ↓ ▣ client module ../surface/strip.tsx ▣ client module ../surface/strip.tsx ─────────────────────────────────────────────────────── ╭─ 󰈔 src/auth.ts ?? src/auth.test.ts M +0 −0 ─ ▣ client modu ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
Command output
◆ Raven diff shown
README

Raven

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 Code on the left, Raven on the right: a note on a hunk, Claude fixes the line, the note is marked addressed

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.

What you get

<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">

  • A live diff. Every changed file in one scrolling view, with syntax highlighting, updated as Claude edits files and runs commands. The file Claude is working on right now is highlighted.
  • Notes Claude reads. Comment on a file, a hunk or a single line. Your notes go to Claude with your next prompt, or right away with send. After Claude replies, Raven marks the notes it addressed with ✓.
  • Stage and revert a single hunk with a click.
  • Compare against your last commit, the start of the session, the branch point, or one turn's edits.
  • Plans and docs, rendered. Plans and specs show up formatted as Claude writes them, and Claude can show you any Markdown file on request. You can leave notes on their sections too.
  • Files and Tasks. Your repository tree with changed files marked, and Claude's task list with its progress.
  • Reminders above the prompt. Pending notes and updated plans show there while the pane is closed.

Requirements

  • Claude Code 2.1.287 or later
  • A terminal at least 110 columns wide
  • Bun installed (Claude uses it to run Raven's command-line helper; self-contained binaries that don't need Bun are coming soon)
  • A Nerd Font for the file icons (optional; without one, icons show as blank boxes)
  • Under tmux, CLAUDE_CODE_NO_FLICKER=1 set in your environment

Install

In Claude Code:

/plugin marketplace add arunkumar9t2/raven
/plugin install raven@raven

Then run /reload-plugins, or restart Claude Code.

Use

<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).

CommandDoes
/raven or /raven diffopens the diff
/raven docopens the rendered plan or doc
/raven filesopens your repository tree
/raven tasksopens Claude's task list
/raven sendsends 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.

Settings

Open /config and look for Raven:

SettingDefaultWhat it does
autoOpenonLets 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.
autoOpenColumns144Raven only opens by itself when the terminal is at least this many columns wide.
watchedPathsemptyExtra folders, comma-separated, whose Markdown files open rendered as Claude writes them. Plan folders are watched already.
keyboardControlsoffDraws plain buttons you can reach with Tab, so every control works from the keyboard.

Troubleshooting

  • The pane doesn't appear. Widen the terminal to at least 110 columns; the pane draws as soon as it fits. Under tmux, set CLAUDE_CODE_NO_FLICKER=1.
  • Claude Code's own diff panel covers Raven. Close it once with its ✕; Claude Code remembers.
  • Raven keeps opening by itself. Turn off autoOpen in /config.
  • Icons look like boxes. Switch your terminal to a Nerd Font.

Contributing

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.

License

Apache License 2.0. See LICENSE.

Source 64 files
hooks/register.ts 308 lines
1import 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}
308
hooks/core/checkpointing.ts 22 lines
1import 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
22
hooks/core/command-glyph.ts 24 lines
1/** 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}
24
hooks/core/directive.ts 57 lines
1import { 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()
57
hooks/core/host.ts 57 lines
1import 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  }
57
hooks/core/is-record.ts 4 lines
1/** 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
4
hooks/core/raven.ts 517 lines
1import 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}
517
hooks/core/settings.ts 51 lines
1import 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}
51
hooks/core/triggers.ts 144 lines
1import { 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    : []
144
hooks/core/view.ts 103 lines
1import 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}
103
hooks/names.ts 43 lines
1/**
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'
43
hooks/views/band.tsx 85 lines
1/* @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