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

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.
In a Claude Code session, run:
/plugin install redpen --marketplace joonhyukyim/redpen
Answer y to the "Add marketplace?" question, then pick a scope.
claude plugin install redpen --marketplace joonhyukyim/redpen
It installs for the user scope; pick another with -s.
| Command | What it does |
|---|---|
/redpen | Opens 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.
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
| Hotkey | Action |
|---|---|
↑↓, Enter | Move to an entry (Claude's last reply is always the first) and open it |
1 | Open another file (see below) |
0 | Send 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
| Key | Action |
|---|---|
↓ / ↑ | Move ▶ to the next / previous line or comment row. |
Enter on a line, or clicking its number | Comment 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 |
| Hotkey | Action |
|---|---|
1 | Back to the list |
2 | On a diff, switch between the changes alone (3 lines around each, the rest folded into ⋯) and the whole file. Switching cancels a range |
3 | Start a range from the ▶ line; Enter to comment or press 3 again to cancel |
0 | Send 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.
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.
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.
1 or /redpen <path>..ipynb JSON.Esc doesn't cancel; it returns focus to the prompt. Cancel a range with 3, a new comment with an empty Enter.claude -p or WSL. Not tested in the Desktop app.hooks/register.tsx 1081 lines1import { 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}
1081hooks/diff.ts 79 lines1export 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}
79hooks/paths.ts 27 lines1// 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}
27hooks/text.ts 64 lines1// 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}
64types/index.d.ts 72 lines1export 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