SLOPSHOPPER

redpen

Line-by-line review pane: comment on what Claude changed, then send every comment back as one prompt.

newpaneguardcommandtoastprompt
★ 4v0.4.0MITupdated 2026-10-08joonhyukyim/redpen
A shopper browsing a rack in a slop shop
README

redpen

redpen is a Claude Code mod for reviewing what Claude changed, line by line, in a pane. You leave comments on single lines or ranges, then send all of them back to Claude as one prompt.

Requires Claude Code 2.1.287 or later.

Installation

From the marketplace

In a Claude Code session, run:

/plugin install redpen --marketplace joonhyukyim/redpen

Answer y to the "Add marketplace?" question, then pick a scope.

From a shell

claude plugin install redpen --marketplace joonhyukyim/redpen

It installs for the user scope; pick another with -s.

Usage

CommandWhat it does
/redpenOpens a list of Claude's last reply, the files Claude changed in its most recent turn that changed files, and the files with comments waiting to be sent
/redpen <path>Opens a file. If Claude changed it in that recent turn, the diff opens; otherwise the whole file. A directory is refused. The path completes in the prompt's typeahead

The list has two parts. Under 최근 수정 are the files of Claude's most recent turn that changed files, each opening as that turn's diff; a turn that changes nothing leaves them as they are, and a turn you interrupt counts. Under 전송 대기 are the other files with comments, latest comment first, each opening whole. The session's other files are offered by 1 (see below). The list starts empty after /clear, a resume or a restart.

The pane takes keyboard focus when it opens. If focus doesn't move to it (the prompt has text in it, a dialog is open, or another pane holds the keys), press Ctrl+X Tab, or click the pane in the fullscreen terminal. Esc returns focus to the prompt, and Ctrl+X Tab moves it back to the pane. To close the pane, press Ctrl+X X; /redpen opens it again.

Hotkeys

The hotkey list is shown at the bottom of the pane (it wraps on narrow panes); while you write a comment, the input takes its place. Under it, a second line shows the pane keys described above, on every screen and while you write a comment. In a document a rule above the hotkeys sets them apart from the lines; a pane too short for the rule and that line leaves both out.

List screen

HotkeyAction
↑↓, EnterMove to an entry (Claude's last reply is always the first) and open it
1Open another file (see below)
0Send all comments

1 (파일 열기) opens an input in place of the hotkeys. Type a path as for /redpen <path> and press Enter; an empty Enter closes it, and a path that isn't a file keeps it open with the reason. Under it are up to 8 offers, drawn dim. While the input is empty, they are the session's files not in the list, those Claude changed and those you opened by path (latest first, marked ↺); once you type, the entries of the typed directory that complete it. Move to one with ↓ and press Enter: a directory fills the input, a file opens.

Document screen

KeyAction
↓ / ↑Move ▶ to the next / previous line or comment row.
Enter on a line, or clicking its numberComment on the line or the range; on a line that already has a comment, edit that comment
Enter on a comment row, or clicking its └Edit that comment
HotkeyAction
1Back to the list
2On a diff, switch between the changes alone (3 lines around each, the rest folded into ⋯) and the whole file. Switching cancels a range
3Start a range from the ▶ line; Enter to comment or press 3 again to cancel
0Send all comments

Clicking works only in Claude Code's fullscreen layout (the alternate screen), whatever the size of the terminal window. On the main screen (CLAUDE_CODE_NO_FLICKER=0, and tmux by default), clicks don't reach the mod.

Writing a comment: Enter opens the input, and Enter again saves. An empty Enter cancels. To edit, the input starts with the current text; clear it and press Enter to delete the comment.

Each line holds one comment. A range's comment goes on its last line; if that line already has a comment, Enter edits it.

Comments whose lines aren't on screen are listed under 표시되지 않은 코멘트 N개 (comments not shown); Enter on one edits it.

Sending

0 sends all comments to Claude as one prompt, as if you typed it.

다음 리뷰 코멘트를 모두 반영해서 수정해줘. 코멘트가 지적한 부분 외에는 건드리지 마.

1. src/foo.ts:12-18
   > (the lines commented on, up to 8)
   This helper is used only once; inline it.
2. src/bar.ts:40 (삭제된 줄, 변경 전 기준)
   > ...
   Remove the comment.
3. src/baz.ts (위치 불명, 원래 7행)
   > ...
   ...

삭제된 줄, 변경 전 기준: a deleted line, numbered as in the old version. 위치 불명, 원래 N행: the lines are no longer in the file; the comment was on line N.

When the file changes, a comment follows the text of its lines. If that text is gone, the comment is marked 위치 불명 and still sent.

Privacy

redpen reads the files in its list (at the start and end of each turn, and when it shows them) and Claude's last reply, and sends a prompt only when you press 0. It doesn't write files, run commands or use the network. Comments are kept until the session ends.

Limitations

  • Files Claude changes with Edit, Write or NotebookEdit are listed. A file changed earlier in the session, or with comments, is also listed when it changes any other way (Bash, an MCP tool, a subagent), once the turn ends. Any other file isn't; open it with 1 or /redpen <path>.
  • A file deleted during a turn leaves the list, unless it has comments.
  • If a file was edited several times in one turn, the diff shows the whole turn's change.
  • Files over 4 MiB can't be opened. A very large change is shown as one block of deleted lines and one block of added lines.
  • A notebook's diff is its raw .ipynb JSON.
  • In a short pane, rows other than the lines and entries are left out so that ↑↓ keep moving, and a long list shows only the entries around the focus. A pane under 6 rows (a document) or 5 rows (the list) shows only a notice.
  • Esc doesn't cancel; it returns focus to the prompt. Cancel a range with 3, a new comment with an empty Enter.
  • Works in the terminal. Not supported on mobile, the VS Code panel, claude -p or WSL. Not tested in the Desktop app.
Source 5 files
hooks/register.tsx 1081 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Comment, Composing, Held, RecentTurn, Sent, SessionFile, Source, View } from '../types'
5import { diffRows, splitLines } from './diff'
6import { completions, expand, splitTyped } from './paths'
7import type { Row } from './diff'
8import { cells, cut, wrapRows } from './text'
9
10type Engine = EngineInterface
11type Doc = Extract<View, { screen: 'doc' }>
12
13const PANE = 'redpen'
14const REPLY = 'Claude 마지막 답변'
15const EXCERPT_MAX = 8
16// Keys described beside the hotkey Buttons, drawn as a Button draws its own: the key in a
17// theme color (a theme key, so it follows the theme), a colon, the text.
18type KeyHint = readonly [key: string, text: string]
19const LIST_KEYS: KeyHint[] = [
20  ['↑↓', '이동'],
21  ['Enter', '열기'],
22]
23const DOC_KEYS: KeyHint[] = [
24  ['↑↓', '이동'],
25  ['Enter', '코멘트 작성/수정'],
26]
27// Claude Code's keys for the pane itself, the same on every screen, under each screen's own.
28const PANE_KEYS: KeyHint[] = [
29  ['Esc', '프롬프트로'],
30  ['Ctrl+X Tab', 'Redpen으로'],
31  ['Ctrl+X X', 'Redpen 닫기'],
32]
33const hintWidth = ([key, text]: KeyHint) => cells(`${key}: ${text}`)
34const HEADER = '다음 리뷰 코멘트를 모두 반영해서 수정해줘. 코멘트가 지적한 부분 외에는 건드리지 마.'
35
36// The turn running now, the turn that last changed files, every file changed this session, and
37// what the files the list knows held when the running turn began. All last the session: /clear,
38// a resume and a restart start them anew.
39const turn = atom({ plugin: 'redpen', key: 'turn' } as const, null as string | null)
40const recent = atom({ plugin: 'redpen', key: 'recent' } as const, null as RecentTurn | null)
41const edited = atom({ plugin: 'redpen', key: 'edited' } as const, [] as SessionFile[])
42const snapshot = atom({ plugin: 'redpen', key: 'snapshot' } as const, [] as Held[])
43// Files opened by path, and when: offered again, with the files changed this session.
44const opened = atom({ plugin: 'redpen', key: 'opened' } as const, [] as SessionFile[])
45// 0.3.1 kept bare paths there, and a session's state outlives a /reload-plugins: such an entry
46// reads as opened at time 0.
47const openedFiles = (list: readonly (SessionFile | string)[]): SessionFile[] =>
48  list.map(f => (typeof f === 'string' ? { path: f, at: 0 } : f))
49const OPENED_MAX = 10
50const OFFERS_MAX = 8
51const view = atom({ plugin: 'redpen', key: 'view' } as const, { screen: 'list' } as View)
52const comments = atom({ plugin: 'redpen', key: 'comments' } as const, [] as Comment[])
53const sent = atom({ plugin: 'redpen', key: 'sent' } as const, null as Sent | null)
54
55// The path's stat with where it lands: absolute, links followed, . and .. folded.
56const statPath = ($: Engine, path: string) => $.fs.stat(path, { resolve: true }).catch(() => undefined)
57
58// Puts a change into the turn running now, the moment it is made, so a turn cut short keeps its
59// changes: the turn's first change makes it the recent turn, and a file keeps what it held
60// before the turn first changed it. The session's list takes the file as changed last.
61async function recordEdit($: Engine, filePath: string, base: string | null) {
62  // Kept as /redpen <path> resolves it, so both name a file the same way.
63  await record($, (await statPath($, filePath))?.realPath ?? filePath, base)
64}
65
66async function record($: Engine, path: string, base: string | null) {
67  const turnId = (await read($, turn)) ?? ''
68  await update($, recent, r => {
69    const files = r?.turnId === turnId ? r.files : []
70    return { turnId, files: files.some(f => f.path === path) ? files : [...files, { path, base }] }
71  })
72  const at = await $.clock.now()
73  await update($, edited, list => [...list.filter(f => f.path !== path), { path, at }])
74}
75
76// A file gone from disk leaves the list and the offers.
77async function forget($: Engine, path: string) {
78  await update($, recent, r => (r === null ? r : { ...r, files: r.files.filter(f => f.path !== path) }))
79  await update($, edited, list => list.filter(f => f.path !== path))
80  await update($, opened, list => openedFiles(list).filter(f => f.path !== path))
81}
82
83// The files the list knows: those changed this session and those with comments.
84async function known($: Engine) {
85  const commented = (await read($, comments)).filter(c => c.kind !== 'reply').map(c => c.path)
86  return [...new Set([...(await read($, edited)).map(f => f.path), ...commented])]
87}
88
89// The last diff computed, by what it was computed from. A module variable: a reload drops it.
90let lastDiff: { path: string; base: string; text: string; whole: boolean; rows: Row[] } | null = null
91
92const plainRows = (lines: string[]): Row[] => lines.map((text, i) => ({ kind: 'ctx', text, newLine: i + 1 }))
93
94type Loaded = { lines: string[]; rows: Row[]; note?: string }
95
96async function loadDoc($: Engine, source: Source, whole = false): Promise<Loaded> {
97  if (source.kind === 'reply') {
98    const lines = splitLines(source.text)
99    return { lines, rows: plainRows(lines) }
100  }
101  let text: string
102  try {
103    text = await $.fs.read(source.path)
104  } catch {
105    return { lines: [], rows: [], note: '파일을 읽을 수 없습니다 (없거나 4 MiB 초과).' }
106  }
107  const lines = splitLines(text)
108  if (source.kind === 'file') return { lines, rows: plainRows(lines) }
109  if (source.base === null) return { lines, rows: plainRows(lines), note: '변경 전 내용이 없어 파일 전체를 보여줍니다.' }
110  // Every arrow key redraws; the file is read again, the diff reused while nothing changed.
111  const key = { path: source.path, base: source.base, text, whole }
112  if (!lastDiff || Object.entries(key).some(([k, value]) => lastDiff![k as keyof typeof key] !== value)) {
113    lastDiff = { ...key, rows: diffRows(splitLines(source.base), lines, whole ? Infinity : 3) }
114  }
115  const rows = lastDiff.rows
116  return { lines, rows, note: rows.every(r => r.kind === 'ctx') ? '변경분이 없습니다.' : undefined }
117}
118
119// The document as last drawn. The pane redraws on state, not when a file changes, so the
120// keys act on these rows, the ones on screen, rather than on the file read again.
121// A reply is not kept: its text is in the view already.
122let drawn: { key: string | null; doc: Loaded } | null = null
123const drawnKey = (v: Doc) => (v.source.kind === 'reply' ? null : `${v.source.kind}:${v.whole === true}:${v.source.path}`)
124
125async function shown($: Engine, v: Doc): Promise<Loaded> {
126  const key = drawnKey(v)
127  return key !== null && drawn?.key === key ? drawn.doc : loadDoc($, v.source, v.whole === true)
128}
129
130const docPath = (source: Source) => (source.kind === 'reply' ? REPLY : source.path)
131
132// Where the comment's excerpt stands now: its saved line, else the nearest exact match.
133// `lines` undefined means the text cannot have moved (a deleted line, a reply snapshot).
134function locate(c: Comment, lines: string[] | null | undefined): number | null {
135  if (lines === undefined || c.side === 'old') return c.start
136  if (lines === null) return null
137  const at = (i: number) => c.excerpt.every((text, k) => lines[i + k] === text)
138  if (at(c.start - 1)) return c.start
139  let best: number | null = null
140  for (let i = 0; i + c.excerpt.length <= lines.length; i++) {
141    if (at(i) && (best === null || Math.abs(i + 1 - c.start) < Math.abs(best - c.start))) best = i + 1
142  }
143  return best
144}
145
146const span = (start: number, c: Comment) => (c.end === c.start ? `${start}` : `${start}-${start + c.end - c.start}`)
147
148function relative(path: string, cwd: string) {
149  return path.startsWith(`${cwd}/`) ? path.slice(cwd.length + 1) : path
150}
151
152function where(c: Comment, start: number | null, cwd: string) {
153  const name = c.kind === 'reply' ? REPLY : relative(c.path, cwd)
154  if (start === null) return `${name} (위치 불명, 원래 ${span(c.start, c)}행)`
155  if (c.kind === 'reply') return `${name} ${span(start, c)}행`
156  if (c.side === 'old') return `${name}:${span(start, c)} (삭제된 줄, 변경 전 기준)`
157  return `${name}:${span(start, c)}`
158}
159
160export async function buildPrompt($: Engine, list: Comment[]): Promise<string> {
161  const cwd = await $.session.cwd()
162  const files = new Map<string, string[] | null>()
163  const items: string[] = []
164  for (const [i, c] of list.entries()) {
165    let lines: string[] | null | undefined
166    if (c.kind !== 'reply' && c.side === 'new') {
167      if (!files.has(c.path)) files.set(c.path, await $.fs.read(c.path).then(splitLines, () => null))
168      lines = files.get(c.path)
169    }
170    const shown = c.excerpt.slice(0, EXCERPT_MAX).map(text => `   > ${text}`)
171    if (c.excerpt.length > EXCERPT_MAX) shown.push(`   > …(${c.excerpt.length - EXCERPT_MAX}줄 생략)`)
172    items.push(
173      [
174        `${i + 1}. ${where(c, locate(c, lines), cwd)}`,
175        ...shown,
176        ...c.text.split('\n').map(text => `   ${text}`),
177      ].join('\n'),
178    )
179  }
180  return [HEADER, '', ...items].join('\n')
181}
182
183async function submit($: Engine) {
184  // Taken in the same write that empties the list: a second 0 finds nothing to send, and a
185  // comment saved while the prompt is built stays for the next send.
186  let list: Comment[] = []
187  await update($, comments, now => ((list = now), []))
188  if (list.length === 0) {
189    $.ui.toast('보낼 코멘트가 없습니다.')
190    return
191  }
192  const putBack = () => update($, comments, now => [...list, ...now])
193  let text: string
194  try {
195    text = await buildPrompt($, list)
196  } catch {
197    await putBack()
198    $.ui.toast('프롬프트를 만들지 못했습니다.')
199    return
200  }
201  const at = await $.clock.now()
202  await update($, sent, () => ({ at, count: list.length }))
203  const undo = async (message: string) => {
204    await putBack()
205    await update($, sent, () => null)
206    $.ui.toast(message)
207  }
208  // Resolves only when the turn starts, so it is not awaited while Claude may be working.
209  void $.prompt.submit({ text, asUser: true }).then(
210    result => ('drop' in result && result.drop !== undefined ? undo(`전송이 거절되었습니다: ${result.drop}`) : undefined),
211    () => undo('전송하지 못했습니다.'),
212  )
213}
214
215async function lastReply($: Engine): Promise<string | null> {
216  const messages = await $.session.messages()
217  for (let i = messages.length - 1; i >= 0; i--) {
218    const m = messages[i]!
219    if (m.role === 'assistant' && m.text.trim() !== '') return m.text
220  }
221  return null
222}
223
224async function openDoc($: Engine, source: Source) {
225  await update($, view, (): View => ({ screen: 'doc', source, cursor: 0, anchor: null, composing: null, focused: null }))
226}
227
228async function openReply($: Engine) {
229  const text = await lastReply($)
230  if (text === null) $.ui.toast('Claude의 답변이 아직 없습니다.')
231  else await openDoc($, { kind: 'reply', text })
232}
233
234// The Buttons the pane drew last, in order, and whether the next try at putting the ring
235// back is the one retry a denied try gets. See pinRing.
236let lastRing = ''
237let retrying = false
238
239// Opens the file a typed path names: the recent turn's diff if that turn changed it, else the
240// whole file, and keeps it in the opened files. Says why when it opens nothing.
241async function openPath($: Engine, typed: string): Promise<string | null> {
242  const given = expand(typed, await $.session.cwd(), (await $.env.get('HOME')) ?? '')
243  const stat = await statPath($, given)
244  if (stat === undefined) return `${typed} 파일이 없습니다.`
245  if (stat.kind !== 'file') return `${typed} 은(는) 파일이 아닙니다.`
246  const path = stat.realPath ?? given
247  const at = await $.clock.now()
248  await update($, opened, list => [{ path, at }, ...openedFiles(list).filter(f => f.path !== path)].slice(0, OPENED_MAX))
249  const changed = (await read($, recent))?.files.find(f => f.path === path)
250  await openDoc($, changed ? { kind: 'diff', path, base: changed.base } : { kind: 'file', path })
251  return null
252}
253
254// `recent`: a file this session changed or opened by path, marked ↺.
255type Offer = { label: string; open: string; isDir: boolean; recent: boolean }
256
257// What a typed path offers. With nothing typed, the files this session changed or opened by
258// path, latest first, those the list shows left out (the recent turn's and those waiting to be
259// sent). Once a path is typed, the entries of its directory that complete it, read anew each
260// time so a new file shows at once. A directory's offer fills the input, a file's opens.
261async function pathOffers($: Engine, typed: string, cwd: string, home: string): Promise<Offer[]> {
262  if (typed !== '') {
263    const split = splitTyped(typed)
264    const entries = await $.fs.list(expand(split.dir, cwd, home)).catch(() => [])
265    return completions(split, entries, OFFERS_MAX).map(c => ({ label: c.text, open: c.text, isDir: c.isDir, recent: false }))
266  }
267  const listed = new Set([
268    ...((await read($, recent))?.files.map(f => f.path) ?? []),
269    ...(await read($, comments)).filter(c => c.kind !== 'reply').map(c => c.path),
270  ])
271  const latest = new Map<string, number>()
272  for (const f of [...(await read($, edited)), ...openedFiles(await read($, opened))]) {
273    if (!listed.has(f.path)) latest.set(f.path, Math.max(latest.get(f.path) ?? 0, f.at))
274  }
275  return [...latest]
276    .sort((a, b) => b[1] - a[1])
277    .slice(0, OFFERS_MAX)
278    .map(([path]) => ({ label: tilde(relative(path, cwd), home), open: path, isDir: false, recent: true }))
279}
280
281type PathInput = Extract<View, { screen: 'list' }>['path']
282
283async function setPath($: Engine, path: PathInput) {
284  await update($, view, v => (v.screen === 'list' ? { ...v, path } : v))
285}
286
287async function submitPath($: Engine, typed: string) {
288  const text = typed.trim()
289  // An empty Enter closes the input, as it cancels a new comment.
290  if (text === '') return setPath($, undefined)
291  const error = await openPath($, text)
292  if (error !== null) {
293    await setPath($, { text: typed, error })
294    focus($, 'path-input')
295  }
296}
297
298async function pickOffer($: Engine, offer: Offer) {
299  if (!offer.isDir) return submitPath($, offer.open)
300  await setPath($, { text: offer.open, error: null })
301  focus($, 'path-input')
302}
303
304async function openReview($: Engine, args: string) {
305  const arg = args.trim()
306  if (arg === '') {
307    await update($, view, (): View => ({ screen: 'list' }))
308  } else {
309    const error = await openPath($, arg)
310    if (error !== null) return { text: error }
311  }
312  // Above the prompt the pane asks for 20 rows rather than a third of the screen.
313  await $.ui.open({ id: PANE, title: 'redpen', focus: true, rows: 20 })
314  return {}
315}
316
317const isLine = (row: Row | undefined) => row !== undefined && row.kind !== 'gap'
318
319function step(rows: Row[], from: number, by: number) {
320  let i = from + by
321  while (i >= 0 && i < rows.length && !isLine(rows[i])) i += by
322  return i < 0 || i >= rows.length ? from : i
323}
324
325function normalize(rows: Row[], cursor: number) {
326  const c = Math.max(0, Math.min(cursor, rows.length - 1))
327  if (isLine(rows[c])) return c
328  const down = step(rows, c, 1)
329  return down !== c ? down : step(rows, c, -1)
330}
331
332async function setDoc($: Engine, fn: (v: Doc) => Doc) {
333  await update($, view, v => (v.screen === 'doc' ? fn(v) : v))
334}
335
336// `{ deny }` when the ring did not move (the pane not holding the keyboard, the element not
337// drawn in time, another move first); null when the engine could not take the call at all.
338const tryFocus = ($: Engine, key: string) => $.ui.focus({ requestId: PANE, key }).catch(() => null)
339
340const focus = ($: Engine, key: string) => void tryFocus($, key)
341
342// The pane keeps the ring on a Button by its place among the Buttons drawn, not by its key:
343// when a window gains or loses a Button above the cursor's, the ring lands on a neighbour and
344// no ui.focus is raised. Whenever the drawn Buttons (`ring`, their keys in order) change, put
345// the ring back on `start` once this drawing is on screen. A denied try gets one more after a
346// fresh drawing, aimed at the cursor as it stands then.
347function pinRing($: Engine, ring: string, start: string | null, isFocused: boolean) {
348  if (ring !== lastRing && isFocused && start !== null) {
349    const key = start
350    const retry = retrying
351    retrying = false
352    void $.clock
353      .sleep(0)
354      .then(() => tryFocus($, key))
355      .then(result => {
356        if (result?.deny === undefined || retry) return
357        retrying = true
358        lastRing = ''
359        $.ui.invalidate('ui.render')
360      })
361  }
362  lastRing = ring
363}
364
365const clock = (at: number) => {
366  const d = new Date(at)
367  return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
368}
369
370// Fits a path into `width` cells, keeping the file name: the home directory becomes ~,
371// and the directories that do not fit give way to one … from the middle.
372// The home directory as ~.
373const tilde = (path: string, home: string) => (home !== '' && path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path)
374
375function fitPath(path: string, home: string, width: number) {
376  const shown = tilde(path, home)
377  if (cells(shown) <= width) return shown
378  const parts = shown.split('/')
379  const name = parts.pop()!
380  const tail = `…/${name}`
381  if (cells(tail) > width) return cut(name, width)
382  let head = ''
383  for (const part of parts) {
384    if (cells(`${head}${part}/${tail}`) > width) break
385    head += `${part}/`
386  }
387  return head + tail
388}
389
390// Rows a wrapping row of items takes: each item `widths[i]` cells, `gap` cells apart.
391function flowRows(widths: number[], gap: number, width: number) {
392  let rows = 1
393  let used = 0
394  for (const w of widths) {
395    if (used > 0 && used + gap + w > width) {
396      rows++
397      used = 0
398    }
399    used += (used > 0 ? gap : 0) + w
400  }
401  return rows
402}
403
404// Rows the pane's keys take at `width` columns, laid out as the hotkeys are.
405const paneKeyRows = (width: number) => flowRows(PANE_KEYS.map(hintWidth), 2, width)
406
407function layout(v: Doc, all: Comment[], doc: Loaded) {
408  const { rows, lines } = doc
409  const cursor = normalize(rows, v.cursor)
410  const [selFrom, selTo] = v.anchor === null ? [cursor, cursor] : [Math.min(v.anchor, cursor), Math.max(v.anchor, cursor)]
411
412  const attached = new Map<number, { c: Comment; start: number }[]>()
413  const away: { c: Comment; why: string }[] = []
414  // A comment sits on its last line: by new line number, or a deleted line's old one.
415  const byNew = new Map<number, number>()
416  const byOld = new Map<number, number>()
417  rows.forEach((r, i) => {
418    if (r.kind === 'gap') return
419    if (r.newLine !== undefined && !byNew.has(r.newLine)) byNew.set(r.newLine, i)
420    if (r.kind === 'del' && r.oldLine !== undefined && !byOld.has(r.oldLine)) byOld.set(r.oldLine, i)
421  })
422  const path = docPath(v.source)
423  for (const c of all.filter(c => c.path === path)) {
424    const start = locate(c, c.kind === 'reply' && v.source.kind !== 'reply' ? undefined : lines)
425    const end = start === null ? null : start + c.end - c.start
426    const row = end === null ? -1 : ((c.side === 'old' ? byOld : byNew).get(end) ?? -1)
427    if (row === -1) away.push({ c, why: start === null ? '위치 불명' : `${span(start, c)}행, 표시 범위 밖` })
428    else attached.set(row, [...(attached.get(row) ?? []), { c, start: start! }])
429  }
430
431  // Line numbers rise down the rows, so a selection's first and last numbers bound it.
432  const selected = rows.slice(selFrom, selTo + 1).filter(r => r.kind !== 'gap')
433  const newLines = selected.flatMap(r => (r.newLine === undefined ? [] : [r.newLine]))
434  const range =
435    newLines.length > 0
436      ? { side: 'new' as const, start: newLines[0]!, end: newLines[newLines.length - 1]! }
437      : { side: 'old' as const, start: selected[0]?.oldLine ?? 0, end: selected[selected.length - 1]?.oldLine ?? 0 }
438  // The row a comment on the range sits on, by the same rule the comments above are placed.
439  const endRow = (range.side === 'old' ? byOld : byNew).get(range.end) ?? -1
440  const excerpt =
441    range.side === 'new'
442      ? lines.slice(range.start - 1, range.end)
443      : selected.filter(r => r.kind === 'del').map(r => r.text)
444  return { doc, rows, lines, cursor, selFrom, selTo, attached, away, range, endRow, excerpt }
445}
446
447const lineSpan = (r: { start: number; end: number }) => (r.end === r.start ? `${r.start}` : `${r.start}-${r.end}`)
448
449async function compose($: Engine, composing: Composing) {
450  await setDoc($, d => ({ ...d, composing }))
451  focus($, 'comment-input')
452}
453
454async function save($: Engine, text: string) {
455  const v = await read($, view)
456  if (v.screen !== 'doc' || v.composing === null) return
457  const composing = v.composing
458  const body = text.trim()
459  if ('editId' in composing) {
460    const { editId } = composing
461    // Emptying a comment's text and saving deletes it.
462    await update($, comments, list =>
463      body === '' ? list.filter(c => c.id !== editId) : list.map(c => (c.id === editId ? { ...c, text: body } : c)),
464    )
465  } else if ('lines' in composing && body !== '') {
466    const comment: Comment = {
467      id: Math.random().toString(36).slice(2, 10),
468      kind: v.source.kind,
469      path: docPath(v.source),
470      ...composing.lines,
471      text: body,
472      at: await $.clock.now(),
473    }
474    await update($, comments, list => [...list, comment])
475  }
476  await setDoc($, d => ({ ...d, composing: null, anchor: null, focused: 'editId' in composing ? null : d.focused }))
477  focus($, `L${normalize((await shown($, v)).rows, v.cursor)}`)
478}
479
480// Enter on (or a click of) a line number: ▶ moves there and the line's comment opens. One
481// comment per line, on the selection's last line: that line's comment opens for editing if it
482// has one; else the line or range gets a new one, its lines and excerpt taken now from the
483// rows on screen. One write, since a drawing in between would read the file again.
484async function pressLine($: Engine, row: number) {
485  const v = await read($, view)
486  if (v.screen !== 'doc') return
487  const at: Doc = { ...v, cursor: row, focused: null }
488  const l = layout(at, await read($, comments), await shown($, at))
489  const here = l.attached.get(l.endRow)?.[0]?.c
490  const composing: Composing = here ? { editId: here.id } : { lines: { ...l.range, excerpt: l.excerpt } }
491  await setDoc($, d => ({ ...d, cursor: row, focused: null, composing }))
492  focus($, 'comment-input')
493}
494
495// Switches a diff between its changes alone and the whole file, ▶ staying on its line.
496async function toggleWhole($: Engine) {
497  const v = await read($, view)
498  if (v.screen !== 'doc' || v.composing !== null || v.source.kind !== 'diff') return
499  const { rows } = await shown($, v)
500  const at = rows[normalize(rows, v.cursor)]
501  const whole = !v.whole
502  const next = (await loadDoc($, v.source, whole)).rows
503  const same = (r: Row) =>
504    at !== undefined && at.kind !== 'gap' && r.kind === at.kind && r.newLine === at.newLine && r.oldLine === at.oldLine
505  const cursor = Math.max(0, next.findIndex(same))
506  await setDoc($, d => ({ ...d, whole, cursor, anchor: null, focused: null }))
507}
508
509async function toggleRange($: Engine) {
510  const v = await read($, view)
511  if (v.screen !== 'doc' || v.composing !== null) return
512  const { rows } = await shown($, v)
513  await setDoc($, d => ({ ...d, anchor: d.anchor === null ? normalize(rows, d.cursor) : null }))
514}
515
516export const register: Register = on => {
517  on('session.start', async ($, e, next) => {
518    await $.command.register({
519      name: 'redpen',
520      description: '마지막 턴의 변경이나 파일을 줄 단위로 리뷰하고 코멘트를 한 번에 보냄',
521      argumentHint: '[path]',
522    })
523    return next(e)
524  })
525
526  on('command.run', { command: 'redpen' }, ($, e) => openReview($, e.args))
527
528  // In the prompt, /redpen <path> completes the path typed: the session's files that hold it and
529  // the entries of its directory join the typeahead, a directory's taken to type on in.
530  on('prompt.autocomplete', async ($, e, next) => {
531    const answered = await next(e)
532    if (e.text.slice(0, e.start).trimEnd() !== '/redpen') return answered
533    const offers = await pathOffers($, e.token, await $.session.cwd(), (await $.env.get('HOME')) ?? '')
534    return {
535      suggestions: [
536        ...answered.suggestions,
537        ...offers.map(o => ({ text: o.label, ...(o.isDir ? { description: '디렉토리' } : {}) })),
538      ],
539    }
540  })
541
542  // Edit, Write and NotebookEdit say what they changed; a file the list knows that Bash or an
543  // MCP tool changed shows only in its content. So what each known file holds is kept as the
544  // turn begins, and compared as the turn ends.
545  on('turn.start', async ($, e, next) => {
546    await update($, turn, () => e.turnId)
547    const paths = await known($)
548    const texts = await Promise.all(paths.map(path => $.fs.read(path).catch(() => null)))
549    await update($, snapshot, () => paths.flatMap((path, i) => (texts[i] === null ? [] : [{ path, text: texts[i]! }])))
550    return next(e)
551  })
552
553  on('turn.complete', async ($, e, next) => {
554    if (e.agentId === undefined) {
555      const held = await read($, snapshot)
556      await update($, snapshot, () => [])
557      const turnId = await read($, turn)
558      const r = await read($, recent)
559      const recorded = r !== null && r.turnId === turnId ? r.files.map(f => f.path) : []
560      const now = await Promise.all(held.map(({ path }) => $.fs.read(path).catch(() => null)))
561      for (const [i, { path, text }] of held.entries()) {
562        if (now[i] === null) {
563          if ((await statPath($, path)) === undefined) await forget($, path)
564        } else if (now[i] !== text && !recorded.includes(path)) {
565          // A file the turn's own tools recorded keeps the content before their first change.
566          await record($, path, text)
567        }
568      }
569    }
570    return next(e)
571  })
572
573  on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
574    const ran = await next(e)
575    if (ran.deny === undefined && ran.isError !== true && ran.result.staged !== true) {
576      await recordEdit($, ran.result.filePath, ran.result.originalFile ?? '')
577    }
578    return ran
579  }).catch(($, e, next) => next(e))
580
581  on('tool.call', { tool: 'Write' }, async ($, e, next) => {
582    const ran = await next(e)
583    if (ran.deny === undefined && ran.isError !== true && ran.result.staged !== true) {
584      await recordEdit($, ran.result.filePath, ran.result.type === 'create' ? '' : ran.result.originalFile)
585    }
586    return ran
587  }).catch(($, e, next) => next(e))
588
589  on('tool.call', { tool: 'NotebookEdit' }, async ($, e, next) => {
590    const ran = await next(e)
591    if (ran.deny === undefined && ran.isError !== true && ran.result.error === undefined) {
592      await recordEdit($, ran.result.notebook_path, ran.result.original_file)
593    }
594    return ran
595  }).catch(($, e, next) => next(e))
596
597  on('ui.focus', { requestId: PANE }, async ($, e, next) => {
598    // The footer keys answer their hotkeys only; the ring stays on the lines.
599    if (e.element?.startsWith('key-')) return { deny: 'redpen: 단축키 안내는 포커스를 받지 않습니다.' }
600    // The ring moves first, ▶ after it, and only where it landed. Moving ▶ first redraws the
601    // window before the move, and the move then lands by its place among the redrawn Buttons.
602    const moved = await next(e)
603    if (moved.deny !== undefined) return moved
604    // ▶ is where the ring is: one position, so Enter always acts where ▶ points.
605    const line = /^L(\d+)$/.exec(e.element ?? '')
606    const comment = /^C(.+)$/.exec(e.element ?? '')
607    if (line) await setDoc($, v => ({ ...v, cursor: Number(line[1]), focused: null }))
608    // The list keeps its own cursor, so its window can follow the ring.
609    const file = /^F(\d+)$/.exec(e.element ?? '')
610    const entry = e.element === 'reply' ? 0 : file ? Number(file[1]) + 1 : null
611    if (entry !== null) await update($, view, v => (v.screen === 'list' ? { ...v, cursor: entry } : v))
612    if (comment) {
613      const v = await read($, view)
614      if (v.screen === 'doc') {
615        // The cursor goes to the comment's line, so the title and a range follow it.
616        const l = layout(v, await read($, comments), await shown($, v))
617        const row =[...l.attached].find(([, list]) => list.some(({ c }) => c.id === comment[1]))?.[0]
618        await setDoc($, d => ({ ...d, cursor: row ?? d.cursor, focused: comment[1]! }))
619      }
620    }
621    return moved
622  })
623
624  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
625    if (e.surface === 'mobile') {
626      const { Text } = $.ui.resolve(e)
627      return <Text dimColor>redpen은 터미널과 데스크톱에서만 동작합니다.</Text>
628    }
629    const { Box, Text, Button, Input } = $.ui.resolve(e)
630    const width = e.props.bodyColumns
631    const bodyRows = e.props.scroll.bodyRows
632    const v = await read($, view)
633    const all = await read($, comments)
634    const last = await read($, sent)
635    const cwd = await $.session.cwd()
636
637    // The prompt sent is in the conversation; the header only says when, and how many.
638    const header = (
639      <Text bold wrap="truncate-end">
640        Redpen · 코멘트 {all.length}개{last ? ` · 마지막 전송 ${clock(last.at)} (${last.count}개)` : ''}
641      </Text>
642    )
643    // A described key, as Texts: a Button would join the ring that ↑ and ↓ move along.
644    const keyHint = ([key, text]: KeyHint) => (
645      <Box>
646        <Text color="suggestion">{key}</Text>
647        <Text>{`: ${text}`}</Text>
648      </Box>
649    )
650    const paneKeys = (
651      <Box flexWrap="wrap" columnGap={2}>
652        {PANE_KEYS.map(keyHint)}
653      </Box>
654    )
655    const paneKeysRows = paneKeyRows(width)
656    // Too short to draw a screen in: one row saying so (and what must stay, as an open input).
657    const tooShort = (stays?: JSX.Element) => {
658      lastRing = ''
659      return (
660        <Box flexDirection="column">
661          <Text color="warning" wrap="truncate-end">
662            pane 높이가 부족합니다. 창을 키우거나 Ctrl+X X로 닫으세요.
663          </Text>
664          {stays}
665        </Box>
666      )
667    }
668
669    if (v.screen === 'list') {
670      // Two parts: the files the recent turn changed, each opening as that turn's diff; then the
671      // other files with comments, waiting to be sent, latest comment first, each opening whole.
672      // The session's other files are offered by 1, as files to open.
673      const changed = (await read($, recent))?.files ?? []
674      const lastComment = new Map<string, number>()
675      for (const c of all) {
676        if (c.kind !== 'reply') lastComment.set(c.path, Math.max(lastComment.get(c.path) ?? 0, c.at ?? 0))
677      }
678      const earlier = [...lastComment.keys()]
679        .filter(path => !changed.some(f => f.path === path))
680        .sort((a, b) => lastComment.get(b)! - lastComment.get(a)!)
681      const entries: { path: string; source: Source }[] = [
682        ...changed.map(f => ({ path: f.path, source: { kind: 'diff', path: f.path, base: f.base } as Source })),
683        ...earlier.map(path => ({ path, source: { kind: 'file', path } as Source })),
684      ]
685      const counts = new Map<string, number>()
686      for (const c of all) counts.set(c.path, (counts.get(c.path) ?? 0) + 1)
687      const suffix = (path: string) => ` · 코멘트 ${counts.get(path) ?? 0}`
688      const home = (await $.env.get('HOME')) ?? ''
689      // The count always shows; the path takes the room left.
690      const entryLabel = (path: string) =>
691        `${fitPath(relative(path, cwd), home, Math.max(1, width - 1 - cells(suffix(path))))}${suffix(path)}`
692      // The entries the ring moves along, one row each: the reply, then the files.
693      const total = 1 + entries.length
694      const cursor = Math.max(0, Math.min(v.cursor ?? 0, total - 1))
695      const keyOf = (k: number) => (k === 0 ? 'reply' : `F${k - 1}`)
696      const typing = v.path
697      const offers = typing === undefined ? [] : await pathOffers($, typing.text, cwd, home)
698      // The ring starts on the cursor's entry, as it starts on the cursor line in a document;
699      // on the path input while it is open.
700      const item = (k: number) => (
701        <Button
702          plain
703          key={keyOf(k)}
704          autoFocus={(k === cursor && typing === undefined) || undefined}
705          label={k === 0 ? cut(`${REPLY}${suffix(REPLY)}`, Math.max(1, width - 1)) : entryLabel(entries[k - 1]!.path)}
706          onPress={() => (k === 0 ? openReply($) : openDoc($, entries[k - 1]!.source))}
707        />
708      )
709
710      // As in a document, the tree must never be taller than the pane, or the arrows scroll it.
711      // The plans below, tried in order: the list whole, then its entries in a window around
712      // the cursor, the window keeping five entries while the pane's keys show, then the least
713      // ↑ and ↓ need, the cursor's entry and one past each end of it.
714      type Parts = { header: boolean; gaps: boolean; hint: boolean; keys: boolean }
715      const pathTitle = typing?.error ?? '파일 열기 · 경로 입력 또는 ↓로 선택 · 빈 Enter 취소'
716      const pathTitleRows = wrapRows(pathTitle, width).length
717      const keyRows = (hint: boolean) =>
718        flowRows([...(hint ? LIST_KEYS.map(hintWidth) : []), cells('1: 파일 열기'), cells(`0: 전송 (${all.length})`)], 2, width)
719      const [withHint, withoutHint] = [keyRows(true), keyRows(false)]
720      const footerRows = (p: Parts) =>
721        typing !== undefined ? pathTitleRows + 1 + (p.keys ? offers.length : 0) : p.hint ? withHint : withoutHint
722      // Each part's title, the first's note when it is empty; a blank row before the second.
723      const titleRows = (changed.length === 0 ? 2 : 1) + (earlier.length > 0 ? 1 : 0)
724      const gapRows = earlier.length > 0 ? 4 : 3
725      const chrome = (p: Parts, windowed: boolean) =>
726        (p.header ? 1 : 0) +
727        (p.gaps ? gapRows : 0) +
728        (windowed ? 0 : titleRows) +
729        footerRows(p) +
730        (p.keys ? paneKeysRows : 0)
731      const every: Parts = { header: true, gaps: true, hint: true, keys: true }
732      const bare: Parts = { ...every, gaps: false }
733      const least = Math.min(3, total)
734      const plans: [Parts, boolean, number][] = [
735        [every, false, total],
736        [bare, false, total],
737        [bare, true, Math.min(5, total)],
738        [{ ...bare, keys: false }, true, least],
739        [{ ...bare, keys: false, header: false }, true, least],
740        [{ header: false, gaps: false, hint: false, keys: false }, true, least],
741      ]
742      // One spare row in case the footer wraps one row more than counted.
743      const plan = plans.find(([p, windowed, need]) => bodyRows - chrome(p, windowed) - 1 >= need)
744      if (plan === undefined) return tooShort()
745      const [parts, windowed] = plan
746      // The window: as many entries as the room holds, the cursor's away from its ends.
747      const room = windowed ? Math.min(total, bodyRows - chrome(parts, true) - 1) : total
748      const top = Math.max(0, Math.min(cursor - Math.floor((room - 1) / 2), total - room))
749      const shown = Array.from({ length: room }, (_, k) => top + k)
750      // While the path input is open the ring is the person's to move, as in a comment input.
751      pinRing($, shown.map(keyOf).join(' '), typing === undefined ? keyOf(cursor) : null, e.props.isFocused)
752
753      return (
754        <Box flexDirection="column">
755          {parts.header && header}
756          {parts.gaps && <Text> </Text>}
757          {windowed ? (
758            shown.map(item)
759          ) : (
760            <Box flexDirection="column">
761              {item(0)}
762              {parts.gaps && <Text> </Text>}
763              <Text bold>최근 수정</Text>
764              {changed.length === 0 && <Text dimColor>  없음.</Text>}
765              {changed.map((_, i) => item(i + 1))}
766              {earlier.length > 0 && parts.gaps && <Text> </Text>}
767              {earlier.length > 0 && <Text bold>전송 대기</Text>}
768              {earlier.map((_, i) => item(changed.length + i + 1))}
769            </Box>
770          )}
771          {parts.gaps && <Text> </Text>}
772          {typing !== undefined ? (
773            <Box flexDirection="column">
774              <Text color={typing.error === null ? undefined : 'warning'}>{pathTitle}</Text>
775              <Input
776                key="path-input"
777                placeholder="경로 입력 후 Enter"
778                value={typing.text}
779                submitLabel="열기"
780                autoFocus
781                onInput={value => void setPath($, { text: value, error: null })}
782                onSubmit={value => void submitPath($, value)}
783              />
784              {parts.keys &&
785                offers.map((offer, i) => (
786                  <Button
787                    plain
788                    key={`O${i}`}
789                    dimColor
790                    label={cut(`${offer.recent ? '↺' : ' '} ${offer.label}`, Math.max(1, width - 1))}
791                    onPress={() => void pickOffer($, offer)}
792                  />
793                ))}
794            </Box>
795          ) : (
796            <Box flexWrap="wrap" columnGap={2}>
797              {parts.hint && LIST_KEYS.map(keyHint)}
798              <Button
799                plain
800                key="key-1"
801                hotkey="1"
802                label="파일 열기"
803                onPress={() => void (setPath($, { text: '', error: null }).then(() => focus($, 'path-input')))}
804              />
805              <Button plain key="key-0" hotkey="0" label={`전송 (${all.length})`} onPress={() => void submit($)} />
806            </Box>
807          )}
808          {parts.keys && paneKeys}
809        </Box>
810      )
811    }
812
813    const loaded = await loadDoc($, v.source, v.whole === true)
814    drawn = { key: drawnKey(v), doc: loaded }
815    const { doc, rows, cursor, selFrom, selTo, attached, away, range } = layout(v, all, loaded)
816
817    // Where the ring starts when the pane takes the keyboard (ctrl+x tab, a click):
818    // the focused comment, else the cursor line. An open Input keeps its own autoFocus.
819    // ▶ sits on the focused comment's row while a comment row has the ring, else on the cursor line.
820    const focusedHere = all.some(c => c.id === v.focused)
821    const start = v.composing !== null ? null : focusedHere ? `C${v.focused}` : `L${cursor}`
822
823    // A comment's rows: the marker takes the focus ring, the text in its own color so it
824    // reads apart from the document, split to the pane's width under the marker.
825    const commentRows = (c: Comment, indent: string, text: string) =>
826      wrapRows(`${text}${c.text.replace(/\n/g, ' ')}`, width - cells(indent) - 5)
827    // A comment as screen rows, one element each.
828    const commentLines = (c: Comment, indent: string, mark: string, text: string) => {
829      const style = { color: 'suggestion', bold: c.id === v.focused, wrap: 'truncate-end' } as const
830      const [first, ...more] = commentRows(c, indent, text)
831      return [
832        <Box>
833          <Text color="claude">{c.id === v.focused ? '▶' : ' '}</Text>
834          <Text>{indent.slice(1)}</Text>
835          <Button
836            plain
837            key={`C${c.id}`}
838            autoFocus={start === `C${c.id}` || undefined}
839            dimColor={c.id !== v.focused}
840            label={mark}
841            onPress={() => compose($, { editId: c.id })}
842          />
843          <Text {...style}>{` ${first}`}</Text>
844        </Box>,
845        ...more.map(row => <Text {...style}>{`${indent}  ${row}`}</Text>),
846      ]
847    }
848
849    // The footer, with or without the hint before the hotkeys, and the rows each takes.
850    let footer: (hint: boolean) => JSX.Element
851    let footerRows: (hint: boolean) => number
852    const rangeText = lineSpan(range)
853    if (v.composing !== null) {
854      const composing = v.composing
855      const editing = 'editId' in composing ? all.find(c => c.id === composing.editId) : undefined
856      // The lines fixed when Enter opened the input, which ▶ moving since does not change.
857      const target = 'lines' in composing ? composing.lines : range
858      const composeTitle = editing
859        ? '코멘트 수정 · 모두 지우고 Enter 삭제'
860        : `코멘트 ${target.side === 'old' ? '(삭제된 줄) ' : ''}L${lineSpan(target)} · 빈 Enter 취소`
861      const input = (
862        <Box flexDirection="column">
863          <Text>{composeTitle}</Text>
864          <Input
865            key="comment-input"
866            placeholder="코멘트 입력 후 Enter"
867            value={editing?.text ?? ''}
868            submitLabel="저장"
869            autoFocus
870            onSubmit={value => save($, value)}
871          />
872        </Box>
873      )
874      const inputRows = wrapRows(composeTitle, width).length + 1
875      footer = () => input
876      footerRows = () => inputRows
877    } else {
878      // Digits, which the Korean input method passes through as typed. Moving and
879      // commenting take ↑↓ and Enter.
880      const keys: [string, string, () => unknown][] = [
881        ['1', '목록', () => update($, view, (): View => ({ screen: 'list' }))],
882        ...(v.source.kind === 'diff'
883          ? [['2', v.whole ? '바뀐 부분만' : '전체 보기', () => toggleWhole($)] as [string, string, () => unknown]]
884          : []),
885        ['3', v.anchor === null ? '범위' : '범위 해제', () => toggleRange($)],
886        ['0', `전송 (${all.length})`, () => submit($)],
887      ]
888      const keyWidths = keys.map(([hotkey, label]) => cells(`${hotkey}: ${label}`))
889      const [withHint, withoutHint] = [
890        flowRows([...DOC_KEYS.map(hintWidth), ...keyWidths], 2, width),
891        flowRows(keyWidths, 2, width),
892      ]
893      footer = hint => (
894        <Box flexWrap="wrap" columnGap={2}>
895          {hint && DOC_KEYS.map(keyHint)}
896          {keys.map(([hotkey, label, run]) => (
897            <Button plain key={`key-${hotkey}`} hotkey={hotkey} label={label} onPress={() => void run()} />
898          ))}
899        </Box>
900      )
901      footerRows = hint => (hint ? withHint : withoutHint)
902    }
903
904    const lineNo = (r: Row) => (r.kind === 'gap' ? '' : String(r.newLine ?? r.oldLine))
905    // reduce, not Math.max(...): spreading a hundred thousand rows overflows the call stack.
906    const gutter = rows.reduce((n, r) => Math.max(n, lineNo(r).length), 1)
907    const title = v.source.kind === 'reply' ? REPLY : relative(v.source.path, cwd)
908    // The note and the count of comments that lost their place.
909    const noteRows = (doc.note ? wrapRows(doc.note, width).length : 0) + (away.length > 0 ? 1 : 0)
910    const awayLines = away.slice(0, 5).flatMap(({ c, why }) => commentLines(c, '  ', '?', `${why} · `))
911    // The tree must never be taller than the pane: past it the arrows scroll the pane instead
912    // of moving the ring, and the ring leaves ▶ behind. The plans below, tried in order, give
913    // way part by part; the first two keep five lines' room, the rest the ▶ line and one past
914    // each end of it, the least ↑ and ↓ need to move.
915    type Parts = { away: boolean; keys: boolean; notes: boolean; header: boolean; hint: boolean }
916    const chrome = (p: Parts) =>
917      (p.header ? 1 : 0) +
918      1 +
919      (p.notes ? noteRows : 0) +
920      (p.away ? awayLines.length : 0) +
921      footerRows(p.hint) +
922      (p.keys ? 1 + paneKeysRows : 0)
923    const every: Parts = { away: true, keys: true, notes: true, header: true, hint: true }
924    // Up to three lines are needed; counting stops at the third, whatever the file's length.
925    let least = 0
926    for (const r of rows) if (isLine(r) && ++least === 3) break
927    least = Math.max(1, least)
928    const plans: [Parts, number][] = [
929      [every, 5],
930      [{ ...every, away: false }, 5],
931      [{ ...every, away: false, keys: false }, least],
932      [{ ...every, away: false, keys: false, notes: false }, least],
933      [{ ...every, away: false, keys: false, notes: false, header: false }, least],
934      [{ away: false, keys: false, notes: false, header: false, hint: false }, least],
935    ]
936    // One spare row in case the footer wraps one row more than counted.
937    const parts = plans.find(([p, need]) => bodyRows - chrome(p) - 1 >= need)?.[0]
938    // A comment being written stays, its input under the notice.
939    if (parts === undefined) return tooShort(v.composing !== null ? footer(false) : undefined)
940    const room = Math.max(1, bodyRows - chrome(parts) - 1)
941    // Line i's text after "▶", the number and " + ", split to the pane's width; each line
942    // is split once per drawing, however often the window and the blocks ask for it.
943    const split = new Map<number, string[]>()
944    const lineRows = (i: number) => {
945      let out = split.get(i)
946      if (!out) {
947        const row = rows[i]!
948        out = row.kind === 'gap' ? [''] : wrapRows(row.text.replace(/\t/g, '  ') || ' ', width - gutter - 5)
949        split.set(i, out)
950      }
951      return out
952    }
953    const indent = ' '.repeat(gutter + 2)
954    const heights = new Map<number, number>()
955    const height = (i: number) => {
956      let h = heights.get(i)
957      if (h === undefined) {
958        h =
959          lineRows(i).length +
960          (attached.get(i) ?? []).reduce((n, { c, start }) => n + commentRows(c, indent, `L${span(start, c)} `).length, 0)
961        heights.set(i, h)
962      }
963      return h
964    }
965    // ↑ and ↓ move the ring only between the Buttons drawn, so the window always shows a line
966    // past each end of it: the rows up to the nearest line there (a gap first, if one is in the
967    // way), each cut to its first row. These are the edges a window from t to b needs; `tight`
968    // ones leave the gap out, the line's number telling the lines between are folded.
969    const above = (t: number, tight = false) =>
970      t === 0 ? [] : rows[t - 1]!.kind !== 'gap' || t === 1 ? [t - 1] : tight ? [t - 2] : [t - 2, t - 1]
971    const below = (b: number, tight = false) =>
972      b === rows.length - 1
973        ? []
974        : rows[b + 1]!.kind !== 'gap' || b === rows.length - 2
975          ? [b + 1]
976          : tight
977            ? [b + 2]
978            : [b + 1, b + 2]
979    const edges = (t: number, b: number, tight = false) => above(t, tight).length + below(b, tight).length
980    let top = cursor
981    let bottom = cursor
982    let used = rows.length > 0 ? height(cursor) : 0
983    for (let grew = rows.length > 0; grew; ) {
984      grew = false
985      if (bottom + 1 < rows.length && used + height(bottom + 1) + edges(top, bottom + 1) <= room)
986        used += height(++bottom), (grew = true)
987      if (top > 0 && used + height(top - 1) + edges(top - 1, bottom) <= room) used += height(--top), (grew = true)
988    }
989    // In a room too small for them, the edges drop their gaps, then give way to the ▶ line.
990    const loose = room > edges(top, bottom)
991    const tight = !loose && room > edges(top, bottom, true)
992    const withEdges = rows.length > 0 && (loose || tight)
993    const edgeTop = withEdges ? above(top, tight) : []
994    const edgeBottom = withEdges ? below(bottom, tight) : []
995    const inner = room - edgeTop.length - edgeBottom.length
996
997    // One line and its comments as screen rows, one element each.
998    const block = (i: number) => {
999      const row = rows[i]!
1000      if (row.kind === 'gap') return [<Text dimColor>{' '.repeat(gutter + 2)}⋯</Text>]
1001      const mark = row.kind === 'add' ? '+' : row.kind === 'del' ? '-' : ' '
1002      const style = {
1003        wrap: 'truncate-end',
1004        inverse: v.anchor !== null && i >= selFrom && i <= selTo,
1005        bold: i === cursor,
1006        color: row.kind === 'add' ? 'success' : row.kind === 'del' ? 'error' : undefined,
1007      } as const
1008      const [first, ...more] = lineRows(i)
1009      return [
1010        <Box>
1011          <Text color="claude">{i === cursor && !focusedHere ? '▶' : ' '}</Text>
1012          <Button
1013            plain
1014            key={`L${i}`}
1015            autoFocus={start === `L${i}` || undefined}
1016            dimColor={i !== cursor}
1017            label={lineNo(row).padStart(gutter)}
1018            onPress={() => pressLine($, i)}
1019          />
1020          <Text {...style}>{` ${mark} ${first}`}</Text>
1021        </Box>,
1022        ...more.map(text => <Text {...style}>{`${' '.repeat(gutter + 4)}${text}`}</Text>),
1023        ...(attached.get(i) ?? []).flatMap(({ c, start }) => commentLines(c, indent, '└', `L${span(start, c)} `)),
1024      ]
1025    }
1026    let body = rows.length > 0 ? Array.from({ length: bottom - top + 1 }, (_, k) => block(top + k)).flat() : []
1027    if (body.length > inner) {
1028      // The ▶ line alone is taller than the room: show the part of it that holds ▶, the
1029      // line's first row or the focused comment's.
1030      let at = 0
1031      if (focusedHere) {
1032        at = lineRows(cursor).length
1033        for (const { c, start } of attached.get(cursor) ?? []) {
1034          if (c.id === v.focused) break
1035          at += commentRows(c, indent, `L${span(start, c)} `).length
1036        }
1037      }
1038      const from = at >= inner ? at - inner + 1 : 0
1039      body = block(cursor).slice(from, from + inner)
1040    }
1041    body = [...edgeTop.map(i => block(i)[0]!), ...body, ...edgeBottom.map(i => block(i)[0]!)]
1042
1043    const edgeKeys = (list: number[]) => list.filter(i => isLine(rows[i])).map(i => `L${i}`)
1044    const ring = [
1045      ...(parts.away ? away.slice(0, 5).map(({ c }) => `C${c.id}`) : []),
1046      ...edgeKeys(edgeTop),
1047      ...Array.from({ length: Math.max(0, bottom - top + 1) }, (_, k) => [
1048        `L${top + k}`,
1049        ...(attached.get(top + k) ?? []).map(({ c }) => `C${c.id}`),
1050      ]).flat(),
1051      ...edgeKeys(edgeBottom),
1052    ].join(' ')
1053    pinRing($, ring, start, e.props.isFocused)
1054
1055    return (
1056      <Box flexDirection="column">
1057        {parts.header && header}
1058        <Text bold wrap="truncate-end">
1059          {title}
1060          {v.whole ? ' · 전체' : ''}
1061          {rows.length > 0 ? ` · L${lineNo(rows[cursor]!)}` : ''}
1062          {v.anchor !== null ? ` · 범위 ${rangeText}` : ''}
1063        </Text>
1064        {parts.notes && doc.note && <Text color="warning">{doc.note}</Text>}
1065        {parts.notes && away.length > 0 && <Text color="warning">표시되지 않은 코멘트 {away.length}개</Text>}
1066        {parts.away && awayLines}
1067        {body}
1068        {/* The document runs down to the footer, so a rule sets the keys apart from it. ASCII,
1069            one cell wide on every terminal, so it spans the pane and never wraps. */}
1070        {parts.keys && (
1071          <Text dimColor wrap="truncate-end">
1072            {'-'.repeat(width)}
1073          </Text>
1074        )}
1075        {footer(parts.hint)}
1076        {parts.keys && paneKeys}
1077      </Box>
1078    )
1079  })
1080}
1081
hooks/diff.ts 79 lines
1export type Row =
2  | { kind: 'gap' }
3  | { kind: 'ctx' | 'add' | 'del'; text: string; oldLine?: number; newLine?: number }
4
5// Above this many LCS cells the changed middle is shown as a block replace.
6const MAX_CELLS = 2_000_000
7
8export function splitLines(text: string): string[] {
9  return text === '' ? [] : text.replace(/\n$/, '').split('\n')
10}
11
12// `context` lines are kept around each change, the rest folded into gaps; Infinity keeps all.
13export function diffRows(before: string[], after: string[], context = 3): Row[] {
14  let head = 0
15  while (head < before.length && head < after.length && before[head] === after[head]) head++
16  let tail = 0
17  while (
18    tail < before.length - head &&
19    tail < after.length - head &&
20    before[before.length - 1 - tail] === after[after.length - 1 - tail]
21  )
22    tail++
23
24  const a = before.slice(head, before.length - tail)
25  const b = after.slice(head, after.length - tail)
26  const ops: ('=' | '-' | '+')[] = []
27  if (a.length * b.length > MAX_CELLS) {
28    // A loop, not push(...): spreading a hundred thousand lines overflows the call stack.
29    for (let i = 0; i < a.length; i++) ops.push('-')
30    for (let j = 0; j < b.length; j++) ops.push('+')
31  } else {
32    const w = b.length + 1
33    const lcs = new Uint32Array((a.length + 1) * w)
34    for (let i = a.length - 1; i >= 0; i--)
35      for (let j = b.length - 1; j >= 0; j--)
36        lcs[i * w + j] =
37          a[i] === b[j] ? lcs[(i + 1) * w + j + 1]! + 1 : Math.max(lcs[(i + 1) * w + j]!, lcs[i * w + j + 1]!)
38    let i = 0
39    let j = 0
40    while (i < a.length || j < b.length) {
41      if (i < a.length && j < b.length && a[i] === b[j]) {
42        ops.push('=')
43        i++
44        j++
45      } else if (j < b.length && (i === a.length || lcs[i * w + j + 1]! >= lcs[(i + 1) * w + j]!)) {
46        ops.push('+')
47        j++
48      } else {
49        ops.push('-')
50        i++
51      }
52    }
53  }
54
55  const all: Row[] = []
56  let oldLine = 1
57  let newLine = 1
58  for (const op of [...Array<'='>(head).fill('='), ...ops, ...Array<'='>(tail).fill('=')]) {
59    if (op === '=') all.push({ kind: 'ctx', text: after[newLine - 1]!, oldLine: oldLine++, newLine: newLine++ })
60    else if (op === '-') all.push({ kind: 'del', text: before[oldLine - 1]!, oldLine: oldLine++ })
61    else all.push({ kind: 'add', text: after[newLine - 1]!, newLine: newLine++ })
62  }
63
64  if (context === Infinity) return all
65  const keep = all.map(() => false)
66  all.forEach((row, i) => {
67    if (row.kind === 'ctx') return
68    for (let k = Math.max(0, i - context); k <= Math.min(all.length - 1, i + context); k++) keep[k] = true
69  })
70  const rows: Row[] = []
71  all.forEach((row, i) => {
72    if (!keep[i]) return
73    if (i > 0 && !keep[i - 1]) rows.push({ kind: 'gap' })
74    rows.push(row)
75  })
76  if (rows.length > 0 && !keep[all.length - 1]) rows.push({ kind: 'gap' })
77  return rows
78}
79
hooks/paths.ts 27 lines
1// A path as typed: absolute, under ~, or relative to the working directory.
2export function expand(typed: string, cwd: string, home: string) {
3  if (typed.startsWith('/')) return typed
4  if ((typed === '~' || typed.startsWith('~/')) && home !== '') return home + typed.slice(1)
5  return `${cwd}/${typed}`
6}
7
8// The directory part of a typed path, as typed (with its last /, or empty), and the start of
9// the name being typed in it.
10export function splitTyped(typed: string) {
11  const slash = typed.lastIndexOf('/')
12  return { dir: typed.slice(0, slash + 1), stem: typed.slice(slash + 1) }
13}
14
15export type Entry = { name: string; kind: 'file' | 'dir' | 'other' }
16
17// The entries of the typed directory that complete the typed name, as the typed path would
18// read with them: directories first, then by name, each directory ending in /. A hidden entry
19// shows only once its leading dot is typed.
20export function completions({ dir, stem }: ReturnType<typeof splitTyped>, entries: Entry[], max: number) {
21  return entries
22    .filter(e => e.name.startsWith(stem) && (stem.startsWith('.') || !e.name.startsWith('.')))
23    .sort((a, b) => Number(b.kind === 'dir') - Number(a.kind === 'dir') || a.name.localeCompare(b.name))
24    .slice(0, max)
25    .map(e => ({ text: `${dir}${e.name}${e.kind === 'dir' ? '/' : ''}`, isDir: e.kind === 'dir' }))
26}
27
hooks/text.ts 64 lines
1// Terminal cells a character takes: Hangul and other wide glyphs take two.
2// East Asian Ambiguous characters (·, …, ↑, ▶, └, ①, ...) count as two as well: a terminal
3// set for Korean may draw them two wide, and a row one cell too wide wraps and pushes every
4// row below it down, parting ▶ from the line it marks.
5const WIDE =
6  /[¡¤§¨ª­®°-´¶-º¼-¿×÷ᄀ-ᅟ‐-‧‰-‾←-⇿∀-⋿①-⓿─-◿☀-⛿❶-❿⺀-꓏가-힣豈-﫿︰-﹏＀-⦆¢-₩]/
7
8// Every wide range starts at U+00A1, so ASCII skips the regex.
9export const cellOf = (ch: string) => (ch < '¡' ? 1 : WIDE.test(ch) ? 2 : 1)
10
11export const cells = (text: string) => {
12  let n = 0
13  for (const ch of text) n += cellOf(ch)
14  return n
15}
16
17// Splits text into rows of at most `width` cells, breaking at a space in the row's second
18// half when there is one. redpen splits rows itself so it knows how many each line takes.
19export function wrapRows(text: string, width: number): string[] {
20  const w = Math.max(1, width)
21  const out: string[] = []
22  let rest = text
23  for (;;) {
24    let row = ''
25    let n = 0
26    let over = false
27    for (const ch of rest) {
28      const c = cellOf(ch)
29      if (n + c > w) {
30        over = true
31        break
32      }
33      row += ch
34      n += c
35    }
36    if (!over) break
37    // A two-cell character wider than the row still takes a row of its own, so the loop ends.
38    if (row === '') row = [...rest][0]!
39    const space = row.lastIndexOf(' ')
40    const take = space > row.length / 2 ? space : row.length
41    out.push(row.slice(0, take))
42    rest = rest.slice(take === space ? take + 1 : take)
43  }
44  if (rest !== '' || out.length === 0) out.push(rest)
45  return out
46}
47
48// Cuts text to `width` cells, ending in … when it does not fit. … itself takes two cells,
49// so a width below two leaves nothing.
50export function cut(text: string, width: number) {
51  if (cells(text) <= width) return text
52  const room = width - cells('…')
53  if (room < 0) return ''
54  let out = ''
55  let n = 0
56  for (const ch of text) {
57    const c = cellOf(ch)
58    if (n + c > room) break
59    out += ch
60    n += c
61  }
62  return `${out}…`
63}
64
types/index.d.ts 72 lines
1export type TurnFile = { path: string; base: string | null }
2
3/** The turn that last changed files, and each file's content before the turn first changed it. */
4export type RecentTurn = { turnId: string; files: TurnFile[] }
5
6/** A file changed this session, and when it was changed last. */
7export type SessionFile = { path: string; at: number }
8
9/** A file's content when the running turn began. */
10export type Held = { path: string; text: string }
11
12export type Source =
13  | { kind: 'diff'; path: string; base: string | null }
14  | { kind: 'file'; path: string }
15  | { kind: 'reply'; text: string }
16
17/**
18 * What the open comment input saves: an existing comment's new text, or a new comment on the
19 * lines and excerpt fixed when Enter opened it, wherever ▶ or the file goes meanwhile.
20 */
21export type Composing = { editId: string } | { lines: Pick<Comment, 'side' | 'start' | 'end' | 'excerpt'> }
22
23export type View =
24  | {
25      screen: 'list'
26      /** The entry the focus ring is on: 0 the reply, i + 1 the i-th file. */
27      cursor?: number
28      /** The path input, while open: the text typed, and why the last Enter opened nothing. */
29      path?: { text: string; error: string | null }
30    }
31  | {
32      screen: 'doc'
33      source: Source
34      cursor: number
35      anchor: number | null
36      composing: Composing | null
37      /** The comment row the focus ring is on: ▶ is drawn there instead of on the cursor line. */
38      focused: string | null
39      /** A diff shown whole, every unchanged line in place, rather than its changes alone. */
40      whole?: boolean
41    }
42
43export type Comment = {
44  id: string
45  kind: Source['kind']
46  path: string
47  side: 'new' | 'old'
48  start: number
49  end: number
50  excerpt: string[]
51  text: string
52  /** When the comment was written: a file with comments alone takes its place in the list by it. */
53  at?: number
54}
55
56export type Sent = { at: number; count: number }
57
58declare module 'claude-code' {
59  interface PluginState {
60    redpen: {
61      turn: string | null
62      recent: RecentTurn | null
63      edited: SessionFile[]
64      snapshot: Held[]
65      opened: SessionFile[]
66      view: View
67      comments: Comment[]
68      sent: Sent | null
69    }
70  }
71}
72