SLOPSHOPPER

inline-review

Show a file in a pane and leave inline comments the model can read

newpaneguardcommandtoasttool
v0.1.0MITupdated 2026-10-06elayeek/elayeek-ai/inline-review
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · inline-review
│ ┃ Changes ✕ › fix the failing auth test and add an audit log call │ ┃ M src/auth.ts │ ┃ ?? src/auth.test.ts ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /inline-review │ ⎿ inline-review: Review pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Changes
M src/auth.ts ?? src/auth.test.ts
README

elayeek-ai

This is where I back up random projects and tools I make for myself. It doubles as a Claude Code plugin marketplace to make it easier to sync back to my Claude.

PluginDescription
inline-reviewShow a file or a git diff in a side pane and leave inline comments the model can read
deslopFind AI-authored slop in a change with read-only finder agents, grade each finding with a verifier, then fix the ones you pick

inline-review in action

inline-review demo

Install

/plugin marketplace add elayeek/elayeek-ai
/plugin install inline-review@elayeek-ai
/plugin install deslop@elayeek-ai

Outside a session, use claude plugin marketplace add … and claude plugin install ….

inline-review is a mod and needs Claude Code 2.1.287 or later.

Update

Run claude plugin update. Auto-update is off by default for this marketplace.

To release, raise version in the plugin's .claude-plugin/plugin.json.

Use in a team project

Add this to the project's .claude/settings.json:

{
  "extraKnownMarketplaces": {
    "elayeek-ai": { "source": { "source": "github", "repo": "elayeek/elayeek-ai" } }
  },
  "enabledPlugins": { "inline-review@elayeek-ai": true }
}
Source 3 files
hooks/register.tsx 322 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Change, DiffItem, ReviewFile, Side } from '../types'
5
6const PANE = 'inline-review'
7const SHOW = 'mcp__inline-review__show_file'
8const LIST = 'mcp__inline-review__list_comments'
9const MAX_FILE = 80_000
10const MAX_CODE = 10_000
11
12const file = atom({ plugin: 'inline-review', key: 'file' } as const, null)
13const comments = atom({ plugin: 'inline-review', key: 'comments' } as const, [])
14const changes = atom({ plugin: 'inline-review', key: 'changes' } as const, null)
15
16const basename = (path: string) => path.split('/').pop() || path
17const dirname = (path: string) => path.slice(0, path.lastIndexOf('/')) || '/'
18const message = (error: unknown) => (error instanceof Error ? error.message : String(error))
19// Code rejects control characters other than tab and newline.
20const clean = (line: string) => line.replace(/[\u0000-\u0008\u000a-\u001f\u007f]/g, ' ').slice(0, MAX_CODE)
21const splitLines = (source: string) => {
22  const lines = source.split('\n')
23  if (lines.length > 1 && lines[lines.length - 1] === '') lines.pop()
24  return lines
25}
26
27const HUNK = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/
28
29// Rows of a unified diff, with the old and new line number of each; file headers
30// and "\ No newline" notes are dropped.
31const parseDiff = (text: string): DiffItem[] => {
32  const items: DiffItem[] = []
33  let inHunk = false
34  let o = 0
35  let n = 0
36  let isFirst = false
37  let header = ''
38  for (const raw of text.split('\n')) {
39    const head = HUNK.exec(raw)
40    if (head) {
41      inHunk = true
42      header = clean(raw)
43      isFirst = true
44      o = Number(head[1])
45      n = Number(head[2])
46      continue
47    }
48    if (raw.startsWith('diff ')) inHunk = false
49    if (!inHunk || raw === '' || raw.startsWith('\\')) continue
50    const m = raw[0]
51    if (m !== ' ' && m !== '+' && m !== '-') continue
52    const item: DiffItem = { m, t: clean(raw.slice(1)), o: m === '+' ? 0 : o, n: m === '-' ? 0 : n }
53    if (isFirst) item.header = header
54    isFirst = false
55    items.push(item)
56    if (m !== '+') o += 1
57    if (m !== '-') n += 1
58  }
59
60  return items
61}
62
63type Opened = { error: string } | { text: string; title: string }
64type Engine = EngineInterface
65
66const resolvePath = async ($: Engine, path: string) =>
67  path.startsWith('/') ? path : `${await $.session.cwd()}/${path.replace(/^\.\//, '')}`
68
69const repoRoot = async ($: Engine, dir: string): Promise<string | null> => {
70  const r = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd: dir })
71
72  return r.exitCode === 0 ? r.stdout.trim() : null
73}
74
75// Shows a file whole, or its diff against `base`, and clears the comments.
76const openFile = async ($: Engine, input: { path: string; base?: string; fromList?: boolean }): Promise<Opened> => {
77  const path = await resolvePath($, input.path)
78  const base = input.base === undefined || input.base === '' ? null : input.base
79  if (base !== null && base.startsWith('-')) return { error: `Bad base ${base}.` }
80  let items: DiffItem[] = []
81  let source = ''
82  let note: string | null = null
83
84  if (base !== null) {
85    const root = await repoRoot($, dirname(path))
86    if (root === null) return { error: `${path} is not in a git repository.` }
87    let diff = await $.process.run(['git', 'diff', '--no-color', base, '--', path], { cwd: root })
88    if (diff.exitCode !== 0) return { error: `git diff failed: ${diff.stderr.trim() || `exit ${diff.exitCode}`}` }
89    if (diff.stdout.trim() === '') {
90      const other = await $.process.run(['git', 'ls-files', '--others', '--exclude-standard', '--', path], { cwd: root })
91      if (other.stdout.trim() !== '') {
92        // An untracked file is all added; exit code 1 means "differences" here.
93        diff = await $.process.run(['git', 'diff', '--no-color', '--no-index', '--', '/dev/null', path], { cwd: root })
94        if (diff.exitCode > 1) return { error: `git diff failed: ${diff.stderr.trim() || `exit ${diff.exitCode}`}` }
95      }
96    }
97    if (diff.stdout.length > MAX_FILE) return { error: `The diff of ${path} is too large for this prototype.` }
98    items = parseDiff(diff.stdout)
99    if (items.length === 0) note = `No changes against ${base}`
100  }
101
102  if (base === null || items.length === 0) {
103    try {
104      source = await $.fs.read(path)
105    } catch (error) {
106      return { error: `Cannot read ${path}: ${message(error)}` }
107    }
108    if (source.length > MAX_FILE) {
109      return { error: `${path} is too large for this prototype (${source.length} characters, limit ${MAX_FILE}).` }
110    }
111  }
112
113  const shown: ReviewFile = {
114    path,
115    base,
116    lines: splitLines(source).map(clean),
117    items,
118    note,
119    fromList: input.fromList === true,
120  }
121  await update($, file, () => shown)
122  await update($, comments, () => [])
123  const isDiff = items.length > 0
124
125  return {
126    title: isDiff ? `Diff: ${basename(path)} vs ${base}` : `Review: ${basename(path)}`,
127    text: isDiff ? `Diff of ${path} against ${base}` : `Reviewing ${path}`,
128  }
129}
130
131const openChanges = async ($: Engine): Promise<Opened> => {
132  const root = await repoRoot($, await $.session.cwd())
133  if (root === null) return { error: 'The working directory is not in a git repository.' }
134  const status = await $.process.run(['git', 'status', '--porcelain'], { cwd: root })
135  if (status.exitCode !== 0) return { error: `git status failed: ${status.stderr.trim() || `exit ${status.exitCode}`}` }
136  const list: Change[] = status.stdout
137    .split('\n')
138    .filter(l => l.length > 3)
139    .map(l => {
140      const path = l.slice(3).split(' -> ').pop() as string
141
142      return { code: l.slice(0, 2).trim(), path, abs: `${root}/${path}` }
143    })
144  await update($, file, () => null)
145  await update($, comments, () => [])
146  await update($, changes, () => list)
147
148  return { title: 'Changes', text: list.length === 0 ? 'No changes.' : `${list.length} changed files` }
149}
150
151export const register: Register = on => {
152  on('session.start', async ($, e, next) => {
153    await $.tool.register({
154      name: 'show_file',
155      description:
156        'Show a file in the Review pane so the user can leave inline comments on it. With base (a git ref such as HEAD), show the diff of the file against that ref instead.',
157      inputSchema: {
158        type: 'object',
159        properties: {
160          path: { type: 'string', description: 'Absolute path of the file' },
161          base: { type: 'string', description: 'Optional git ref to diff against; omit for the whole file' },
162        },
163        required: ['path'],
164      },
165    })
166    await $.tool.register({
167      name: 'list_comments',
168      description: "List the user's inline comments on the file shown in the Review pane, then clear them.",
169      inputSchema: { type: 'object', properties: {} },
170    })
171    await $.command.register({
172      name: 'inline-review',
173      description: 'Open the inline review pane, or review a file',
174      argumentHint: '[path]',
175    })
176    await $.command.register({
177      name: 'inline-diff',
178      description: 'Review the diff of a file, or list the changed files',
179      argumentHint: '[path] [base]',
180    })
181
182    return next(e)
183  })
184
185  on('command.run', { command: 'inline-review' }, async ($, e) => {
186    const [path] = e.args.trim().split(/\s+/).filter(Boolean)
187    if (path === undefined) {
188      await $.ui.open({ id: PANE, title: 'Review' })
189
190      return { text: 'Review pane opened.' }
191    }
192    const opened = await openFile($, { path })
193    if ('error' in opened) {
194      $.ui.toast(opened.error)
195
196      return { text: opened.error }
197    }
198    await $.ui.open({ id: PANE, title: opened.title })
199
200    return { text: opened.text }
201  })
202
203  on('command.run', { command: 'inline-diff' }, async ($, e) => {
204    const [path, base] = e.args.trim().split(/\s+/).filter(Boolean)
205    const opened = path === undefined ? await openChanges($) : await openFile($, { path, base: base ?? 'HEAD' })
206    if ('error' in opened) {
207      $.ui.toast(opened.error)
208
209      return { text: opened.error }
210    }
211    await $.ui.open({ id: PANE, title: opened.title })
212
213    return { text: opened.text }
214  })
215
216  on('tool.call', { tool: SHOW }, async ($, e) => {
217    const path = e.path
218    if (typeof path !== 'string' || path === '') return { deny: 'show_file needs an absolute file path.' }
219    const base = typeof e.base === 'string' && e.base !== '' ? e.base : undefined
220    const opened = await openFile($, { path, base })
221    if ('error' in opened) return { deny: opened.error }
222    await $.ui.open({ id: PANE, title: opened.title })
223
224    return {
225      result: `${base === undefined ? `Showing ${path}` : `Showing the diff of ${path} against ${base}`} in the Review pane. Call ${LIST} to read the user's comments.`,
226    }
227  })
228
229  on('tool.call', { tool: LIST }, async $ => {
230    const shown = await read($, file)
231    const list = [...(await read($, comments))].sort((a, b) => a.line - b.line || (a.side === b.side ? 0 : a.side === 'old' ? -1 : 1))
232    if (shown === null || list.length === 0) return { result: 'No comments yet.' }
233    await update($, comments, () => [])
234
235    const isDiff = shown.items.length > 0
236    const quote = (line: number, side: Side) => {
237      if (!isDiff) return shown.lines[line - 1] ?? ''
238      const it = shown.items.find(i => (side === 'old' ? i.m === '-' && i.o === line : i.m !== '-' && i.n === line))
239
240      return it ? `${it.m}${it.t}` : ''
241    }
242
243    return {
244      result: list
245        .map(c => {
246          const head = isDiff ? `${shown.path}:${c.line} (${c.side})` : `${shown.path}:${c.line}`
247
248          return `${head}: ${c.body.replace(/\n/g, '\n    ')}\n    > ${quote(c.line, c.side)}`
249        })
250        .join('\n'),
251    }
252  })
253
254  on('ui.message', { component: 'Pane', requestId: PANE, element: 'file' }, async ($, e) => {
255    const d = e.data as { line?: unknown; side?: unknown; body?: unknown } | null
256    if (d && typeof d.line === 'number' && (d.side === 'old' || d.side === 'new') && typeof d.body === 'string') {
257      const { line, body } = d
258      const side: Side = d.side
259      // One comment per line and side: a body replaces it, an empty body deletes it.
260      await update($, comments, c => {
261        const rest = c.filter(x => !(x.line === line && x.side === side))
262        return body === '' ? rest : [...rest, { line, side, body }]
263      })
264    }
265
266    return {}
267  })
268
269  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
270    // Only the terminal and desktop tables have both Client and Input.
271    if (e.surface !== 'terminal' && e.surface !== 'desktop') return next(e)
272    const { Box, Text, Client, Button } = $.ui.resolve(e)
273    const shown = await read($, file)
274    const changed = await read($, changes)
275
276    const open = async (path: string, base: string, fromList: boolean) => {
277      const opened = await openFile($, { path, base, fromList })
278      if ('error' in opened) $.ui.toast(opened.error)
279      else await $.ui.open({ id: PANE, title: opened.title })
280    }
281
282    if (shown === null && changed !== null) {
283      if (changed.length === 0) return <Text dimColor>No changes.</Text>
284
285      return (
286        <Box flexDirection="column">
287          {changed.map((c, i) => (
288            <Button key={`f-${i}`} plain label={`${c.code.padEnd(3)} ${c.path}`} onPress={() => open(c.abs, 'HEAD', true)} />
289          ))}
290        </Box>
291      )
292    }
293    if (shown === null) {
294      return <Text dimColor>No file shown. The model calls show_file to open one.</Text>
295    }
296    const list = await read($, comments)
297
298    return (
299      <Box flexDirection="column">
300        {shown.fromList && (
301          <Button
302            key="back"
303            plain
304            label="← Changes"
305            onPress={async () => {
306              const opened = await openChanges($)
307              if ('error' in opened) $.ui.toast(opened.error)
308              else await $.ui.open({ id: PANE, title: opened.title })
309            }}
310          />
311        )}
312        {shown.note !== null && <Text dimColor>{shown.note}</Text>}
313        <Client
314          key="file"
315          module="./file-view.tsx"
316          props={{ path: shown.path, isDiff: shown.items.length > 0, lines: shown.lines, items: shown.items, comments: list }}
317        />
318      </Box>
319    )
320  })
321}
322
hooks/file-view.tsx 233 lines
1import type { ClientModule } from 'claude-code'
2
3type Side = 'old' | 'new'
4type Item = { m: string; t: string; o: number; n: number; header?: string }
5type Sel = { line: number; side: Side }
6type Props = {
7  path: string
8  isDiff: boolean
9  lines: string[]
10  items: Item[]
11  comments: { line: number; side: Side; body: string }[]
12}
13// draft is null while no comment row is open.
14type State = { sel: Sel | null; draft: string | null }
15
16const MAX_CODE = 10_000
17const HUNK = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/
18
19// Every key and click commits here at once and then asks for a redraw, so keys
20// that arrive between two draws build on each other instead of on the last
21// drawn state. The module is loaded once per instance and reloads on a save.
22let live: State | null = null
23
24// Breaks at the last space within `width`, else hard-breaks at `width`.
25const wrapText = (text: string, width: number): string[] => {
26  const out: string[] = []
27  let rest = text
28  while (rest.length > width) {
29    const at = rest.lastIndexOf(' ', width)
30    if (at > 0) {
31      out.push(rest.slice(0, at))
32      rest = rest.slice(at + 1)
33    } else {
34      out.push(rest.slice(0, width))
35      rest = rest.slice(width)
36    }
37  }
38  out.push(rest)
39  return out
40}
41
42const keyOf = (it: Item): Sel => (it.m === '-' ? { line: it.o, side: 'old' } : { line: it.n, side: 'new' })
43const same = (a: Sel | null, b: Sel) => a !== null && a.line === b.line && a.side === b.side
44
45// Draws the source one screen row per source line (wrap truncate-end), so a
46// click's row maps to a line through `rowLine`. Comment, draft and hunk-header
47// rows map to null. Typing goes through onKey because a click gives this Client
48// the keys and an Input drawn inside it never receives them.
49const View: ClientModule<Props, State> = (props, s) => {
50  const { Box, Text, Code } = s.elements
51  const { path, isDiff, comments } = props
52  const all: Item[] = isDiff ? props.items : props.lines.map((t, i) => ({ m: '', t, o: 0, n: i + 1 }))
53  if (live === null || s.state === undefined) live = s.state ?? { sel: null, draft: null }
54  const commit = (next: State) => {
55    live = next
56    s.setState(next)
57  }
58  const { sel, draft } = live
59  // 0 before the first layout.
60  const columns = s.columns > 0 ? s.columns : 60
61
62  // Diff gutter: selection bar, old and new number columns, then a rule. Comment rows indent past it.
63  const digits = isDiff ? String(Math.max(1, ...all.map(i => Math.max(i.o, i.n)))).length : 0
64  const lead = isDiff ? ' '.repeat(2 * digits + 4) + '┃' : ''
65  const num = (v: number) => (v > 0 ? String(v) : '').padStart(digits)
66  const rows: JSX.Element[] = []
67  const rowLine: (Sel | null)[] = []
68  // One Text per screen row keeps `rowLine` exact; the continuation indent is the prefix width.
69  const pushWrapped = (key: string, color: string, first: string, text: string, at: Sel | null = null) => {
70    const indent = ' '.repeat(first.length)
71    wrapText(text, Math.max(8, columns - lead.length - first.length)).forEach((piece, k) => {
72      rows.push(<Text key={`${key}-${k}`} color={color} wrap="truncate-end">{`${lead}${k === 0 ? first : indent}${piece}`}</Text>)
73      rowLine.push(at)
74    })
75  }
76  const bodyOf = (k: Sel) => comments.find(c => c.line === k.line && c.side === k.side)?.body
77
78  let chunk: Item[] = []
79  let size = 0
80  // Whole-file view only: a diff row is its own one-row Text, drawn as the raw diff line.
81  const flush = () => {
82    if (chunk.length === 0) return
83    const first = chunk[0] as Item
84    const id = `${first.m}-${first.o}-${first.n}`
85    const code = <Code key={`code-${first.n}`} path={path} startLine={first.n} wrap="truncate-end" source={chunk.map(i => i.t).join('\n')} />
86    rows.push(chunk.length === 1 && same(sel, keyOf(first)) ? <Box key={`sel-${id}`} backgroundColor="blue">{code}</Box> : code)
87    chunk.forEach(i => rowLine.push(keyOf(i)))
88    chunk = []
89  }
90
91  // A diff run is contiguous rows of one tint: one tinted Box holding a gutter column beside one Code.
92  let run: Item[] = []
93  const flushRun = () => {
94    if (run.length === 0) return
95    const first = run[0] as Item
96    // A run is one row when selected, so `isSel` styles the whole run.
97    const isSel = same(sel, keyOf(first))
98    const base = first.m === '+' ? '#1f3d2b' : first.m === '-' ? '#4a2227' : undefined
99    const lit = first.m === '+' ? '#2a5a3c' : first.m === '-' ? '#6a3038' : '#2a3340'
100    const tint = isSel ? lit : base
101    const markColor = first.m === '+' ? 'greenBright' : first.m === '-' ? 'redBright' : undefined
102    rows.push(
103      <Box key={`run-${first.m}-${first.o}-${first.n}`} flexDirection="row" backgroundColor={tint}>
104        {/* Fixed-width, non-shrinking columns of one single-row Text each, so a gutter row can never wrap. */}
105        <Box flexDirection="column" width={1} flexShrink={0}>
106          {run.map((i, r) => (
107            <Text key={`b${r}`} color="cyan" wrap="truncate-end">{same(sel, keyOf(i)) ? '▌' : ' '}</Text>
108          ))}
109        </Box>
110        <Box flexDirection="column" width={2 * digits + 3} flexShrink={0}>
111          {run.map((i, r) => (
112            <Text key={`g${r}`} dimColor={first.m === ' ' && !isSel} bold={isSel} wrap="truncate-end">{`${num(i.m === '+' ? 0 : i.o)} ${num(i.m === '-' ? 0 : i.n)} │`}</Text>
113          ))}
114        </Box>
115        <Box flexDirection="column" width={1} flexShrink={0}>
116          {run.map((i, r) => (
117            <Text key={`m${r}`} color={markColor} bold={isSel} wrap="truncate-end">{i.m}</Text>
118          ))}
119        </Box>
120        <Box flexGrow={1} flexShrink={1}>
121          <Code path={path} wrap="truncate-end" source={run.map(i => i.t).join('\n')} />
122        </Box>
123      </Box>,
124    )
125    run.forEach(i => rowLine.push(keyOf(i)))
126    run = []
127  }
128
129  if (isDiff) {
130    const adds = all.filter(i => i.m === '+').length
131    const dels = all.filter(i => i.m === '-').length
132    rows.push(
133      <Box key="file-head" flexDirection="row">
134        <Box flexGrow={1}>
135          <Text bold wrap="truncate-end">{` ${path.split('/').pop() || path}`}</Text>
136        </Box>
137        <Text color="greenBright">{`+${adds}`}</Text>
138        <Text color="redBright">{` −${dels}`}</Text>
139      </Box>,
140    )
141    rowLine.push(null)
142  }
143
144  all.forEach(it => {
145    const k = keyOf(it)
146    if (isDiff) {
147      if (it.header !== undefined) {
148        flushRun()
149        const hunk = HUNK.exec(it.header)
150        const label = hunk ? `⋯ ${hunk[1]},${hunk[2] ?? 1} → ${hunk[3]},${hunk[4] ?? 1} ` : '⋯ '
151        rows.push(
152          <Box key={`sep-${it.o}-${it.n}`} flexDirection="row">
153            <Box flexShrink={0}>
154              <Text>{` ${label}`}</Text>
155            </Box>
156            <Box flexGrow={1}>
157              <Text dimColor wrap="truncate-end">{'─'.repeat(columns)}</Text>
158            </Box>
159          </Box>,
160        )
161        rowLine.push(null)
162      }
163      if (run.length > 0 && (run[0]?.m !== it.m || same(sel, k) || run.reduce((n, i) => n + i.t.length + 1, 0) + it.t.length > MAX_CODE)) flushRun()
164      run.push(it)
165    } else {
166      if (chunk.length > 0 && (size + it.t.length + 4 > MAX_CODE || same(sel, k))) flush()
167      chunk.push(it)
168      size += it.t.length + 4
169    }
170    const saved = bodyOf(k)
171    const isEditing = same(sel, k) && draft !== null
172    if (saved !== undefined || same(sel, k)) (isDiff ? flushRun : flush)()
173    if (saved !== undefined && !isEditing) {
174      const label = `  ↳ ${k.side === 'old' ? 'old ' : ''}${k.line}: `
175      saved.split('\n').forEach((part, j) => pushWrapped(`c-${k.side}${k.line}-${j}`, 'yellow', j === 0 ? label : ' '.repeat(label.length), part, k))
176    }
177    if (isEditing) {
178      const parts = draft.split('\n')
179      const hint = 'Enter to save'
180      parts.forEach((part, j) =>
181        pushWrapped(`draft-${j}`, 'green', j === 0 ? '  ✎ ' : '    ', j === parts.length - 1 ? `${part}█` : part),
182      )
183      rows.push(
184        <Text key="hint" dimColor wrap="truncate-end">
185          {`    ${hint}`}
186        </Text>,
187      )
188      rowLine.push(null)
189    }
190  })
191  flush()
192  flushRun()
193
194  s.onPointer(e => {
195    if (e.type !== 'down' || e.button !== 'left') return
196    const k = rowLine[e.y]
197    if (k == null) return
198    const cur = live as State
199    // A second click on the open line cancels the draft and keeps the saved comment.
200    commit(same(cur.sel, k) && cur.draft !== null ? { sel: k, draft: null } : { sel: k, draft: bodyOf(k) ?? '' })
201  })
202  s.onKey(e => {
203    const { sel: at, draft: text } = live as State
204    if (text !== null && at !== null) {
205      if (e.ctrl || e.meta) return
206      if (e.key === 'return') {
207        if (e.shift) return commit({ sel: at, draft: text + '\n' })
208        const body = text.trim()
209        if (body === '' && bodyOf(at) === undefined) return
210        s.post({ line: at.line, side: at.side, body })
211        commit({ sel: at, draft: null })
212      } else if (e.key === 'backspace' || e.key === 'delete') {
213        commit({ sel: at, draft: text.slice(0, -1) })
214      } else if (e.key === 'space') {
215        commit({ sel: at, draft: text + ' ' })
216      } else if (e.key.length === 1) {
217        commit({ sel: at, draft: text + e.key })
218      }
219      return
220    }
221    if (e.key !== 'up' && e.key !== 'down') return
222    const idx = at === null ? -1 : all.findIndex(i => same(at, keyOf(i)))
223    const step = e.key === 'down' ? 1 : -1
224    const next = Math.min(Math.max((idx === -1 ? (step === 1 ? -1 : all.length) : idx) + step, 0), all.length - 1)
225    const target = all[next]
226    if (target) commit({ sel: keyOf(target), draft: null })
227  })
228
229  return <Box flexDirection="column">{rows}</Box>
230}
231
232export default View
233
types/index.d.ts 25 lines
1export type Side = 'old' | 'new'
2// One row of a diff: m is the marker, o and n the old and new line numbers
3// (0 where the row has none), header is the hunk header line on the first row of a hunk.
4export type DiffItem = { m: ' ' | '+' | '-'; t: string; o: number; n: number; header?: string }
5export type ReviewFile = {
6  path: string
7  base: string | null
8  lines: string[]
9  items: DiffItem[]
10  note: string | null
11  fromList: boolean
12}
13export type ReviewComment = { line: number; side: Side; body: string }
14export type Change = { code: string; path: string; abs: string }
15
16declare module 'claude-code' {
17  interface PluginState {
18    'inline-review': {
19      file: ReviewFile | null
20      comments: ReviewComment[]
21      changes: Change[] | null
22    }
23  }
24}
25