Comment inline on any part of the conversation, then send all comments to Claude as one prompt (/annotate)

Answer Claude point by point, right on the text. A Claude Code mod: select any part of the conversation, write a comment on it, and send all your comments to Claude as one prompt.

[!WARNING] 100% vibe coded. Claude wrote all of this, and I have not read a single line of the code. Use it at your own discretion. I am not responsible for any weird stuff that happens while you use this mod. Mods run unsandboxed inside Claude Code, so read the source before you install it. The MIT license applies: no warranty of any kind.
The conversation is a workspace you share with Claude, but two things in it are awkward: answering a long reply from Claude full of points, lists and questions, and coming back to something said a few turns ago. Both are awkward for the same reason: you can't point at a specific place in the text and discuss it there. The prompt box only adds to the end, so you copy or restate the part you mean and write under it. This mod lets you point. Select any part of the conversation and comment right on it, and Claude gets each comment next to the exact text it's about.
| You want to… | Without it | With /annotate |
|---|---|---|
| answer several points or questions in one reply | Copy or restate each one in the prompt and write under it | Select each one, comment, type :s |
| answer something Claude said a while ago | Scroll the transcript, copy, paste | Jump there (]m, /, gg), select, comment |
| revise a plan before Claude starts | Describe the step you mean in words | Comment on the step itself |
| keep annotating while Claude works | Wait for the turn to end, or copy passages out as they appear | Open /annotate mid-turn and comment while new text keeps arriving |
Claude gets one prompt that quotes each span with your comment under it, and says which message each comment is on when they aren't all on the last reply.
Requires Claude Code with mods (function hooks), 2.1.286 or later, in the fullscreen layout: switch with /tui fullscreen, in a terminal at least 110 columns wide. Tested on 2.1.286 in the terminal on macOS, inside tmux.
claude plugin marketplace add Malkiz/claude-reply-comments
claude plugin install reply-comments@claude-reply-comments
Or from a clone, for one session:
git clone https://github.com/Malkiz/claude-reply-comments.git
claude --plugin-dir ./claude-reply-comments
To load the clone in every session, add its folder to CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json.
For ctrl-e ctrl-y ctrl-u ctrl-f without clicking the pane first, add this to the bindings list in ~/.claude/keybindings.json (why):
{ "context": "PaneField", "bindings": { "ctrl+e": "select:next", "ctrl+y": "select:previous", "ctrl+f": "select:pageDown", "ctrl+u": "select:pageUp" } }
/annotate. A pane opens beside the transcript with the whole conversation, drawn the way the transcript draws it: ❯ on your prompts, ⏺ on Claude's replies, tool calls (Bash(ls) with its first result line) and plans, markdown rendered. It opens at the bottom, with the cursor on Claude's last reply, and it already has the keyboard.v, V, a text object such as ip, or a mouse drag) and press c. Type the comment, then Enter.o adds an overall comment.:s to send everything, or :p to put the prompt in the prompt box so you can edit it first.? shows or hides the full key list at the bottom of the pane.
| Group | Keys |
|---|---|
| Move | h j k l · w b e ge (W B E gE) · 0 ^ $ · gg G (5G goes to line 5) · { } · ]m m next / previous message · H M L · f t F T ; , · counts (3j, 2w) · ctrl-e ctrl-y ctrl-u ctrl-f (see [Install) |
| Select | v · V · o swaps ends · text objects iw aw iW aW is as ip ap i( a( i[ i{ i< i" i' i\`… |
| Comment | c on a selection · c{motion} comments that span: cip ciw ci( cc c$ c2e… · C comments to the end of the line · o overall comment · r revise / x delete the comment under the cursor · ]c [c next / previous comment |
| Search | /text then Enter (smartcase) · n N |
| Finish | :s or :w send · :p put the prompt in the prompt box · :clear · q / :q close (the draft is kept) · :12 go to line 12 · zz zt zb scroll |
More:
c, o, r, / and : open a field at the bottom. Enter finishes, Esc cancels. An empty comment cancels, or removes the one you're revising. The field grows as a long comment wraps.q closes the pane.ctrl-c cancels it./annotate opens at once, mid-turn. While the pane is open, new prompts, replies and tool calls appear in it a block at a time, without moving your cursor or selection. Click the prompt box to type there while the pane stays open./annotate, comment on the steps, and send.$.session.messages()), when you run /annotate and as new rows are stored while the pane is open.:s, or the text it puts in the prompt box with :p. Its hook on new rows (session.append) passes every row through unchanged.$.prompt.submit, only when you type :s or :w) contains exactly: an opening line ("I left N comments on parts of …"), then for each comment the text you selected, quoted, with the comment you typed under it and which message it's on, then your overall comment if you wrote one. Nothing else from the conversation goes into it. It goes to Claude in this session, like a prompt you type; the mod sends nothing anywhere else./annotate and answers it by opening the pane. It doesn't run slash commands. (The tests in tests/ run /annotate inside Claude Code's test kit; they aren't part of the mod at runtime.)q closes the pane. It touches no permission prompt or setting.session.start: registers /annotate.command.run: answers /annotate only, by opening the pane. Other commands pass through untouched.ui.close: for its own pane only, refuses a close that comes from Esc inside the pane, or that arrives while a selection, field or half-typed command is open (Esc cancels that first). Any other close goes through.ui.scroll: turns the mouse wheel over its own pane into scrolling the conversation inside it.session.append: notices new rows while the pane is open, so the pane can redraw. Every row passes through unchanged.ui.message, ui.render: draw its pane and take what the pane reports back.$.state), gone when the session ends. Nothing is stored across sessions.claude plugin validate . and claude plugin test ../tui fullscreen.ctrl-d or ctrl-b.gg / G reach the very start and end, but line numbers, :12 and / search cover the current section only.The pane draws a Client surface module (hooks/reader.tsx) that holds the conversation as one text and runs a vim engine (hooks/vim.ts) over it. A Client gets keys only after a click, so the pane also draws a text field that holds the keyboard and relays each key to the reader through $.state; it's redrawn with a new zero-width mark after every key so it never fills up.
Esc arrives as the pane's close (closeOnEscape), which the mod refuses before taking the keyboard back. The same delayed $.ui.open({ focus }) gives the pane the keyboard right after /annotate. Every row is exactly one cell tall, so nothing the reader draws pushes the layout around.
The live pane watches session.append: new text at the end is appended in place, and a change further up (a parallel tool call's result landing, a compaction) moves comments and the cursor across, finding a changed comment again by its quote.
docs/layout-approaches.md records every layout tried on 2.1.286 (a cursor on the main transcript, a thin key pane, a floating overlay, and the wide pane chosen), with the requirements and the API limits behind each, so they can be re-tested when the API changes. The test mod behind it is in spikes/transcript-layouts/.
claude plugin validate .
claude plugin test .
hooks/register.tsx: the /annotate command, the pane, its key field, field and legend, Esc, the live refresh, sending and filling the prompt.hooks/reader.tsx: the Client surface module that draws the conversation (gutter, markdown styles, cursor, comments) and handles mouse and keys.hooks/vim.ts: the vim engine, pure functions tested in tests/vim.test.ts.hooks/lib.ts: the conversation as one text (conversation, renderMarkdown), the reader's window, wrapping, word, paragraph and text-object primitives, prompt building and the legend.types/index.d.ts: the $.state contract.tsconfig.json extends .claude-plugin/types/tsconfig.json, which Claude Code writes when it loads the mod. That folder is gitignored.
--resume). What disappears is the anchor. On /annotate, find earlier sent prompts by their format, locate each quote, and draw those comments as dimmed highlights next to the new ones. Weak spot: a quote that appears in more than one place, which anchoring by message and line would fix.The mod collects nothing and sends nothing anywhere except the prompt you send to Claude. See the privacy policy.
MIT. See LICENSE.
hooks/register.tsx 609 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ReplyComment, ReplyCommentsDraft, ReplyCommentsRelayed, ReplyCommentsView, ReplyCommentsWindow } from '../types'
5import { GROUP_COLUMN, buildPrompt, conversation, lastClaudePart, legendLines, mergeComments, offsetMapper, remapComments, sliceConversation, windowAround } from './lib'
6import type { TextWindow } from './lib'
7import type { ReaderPost, ReaderProps } from './reader'
8
9const PANE = 'reply-comments'
10const COMMAND = 'annotate'
11const PANE_OPTIONS = { id: PANE, title: 'Annotate conversation', holdToasts: true, closeOnEscape: true, rows: 30, columns: 160 } as const
12
13// The pane can only take the keyboard once the command that opened it has
14// finished, so it asks again shortly after.
15const FOCUS_DELAY_MS = 300
16// After Escape the keyboard has gone back to the prompt; the pane takes it back.
17const REFOCUS_DELAY_MS = 80
18// An Escape whose close arrives while the pane holds the keyboard, or this soon
19// after it lost it (the redraw and the close race), came from inside the pane.
20const ESCAPE_WINDOW_MS = 500
21
22// A Client's props may serialize to at most 100,000 characters, so the reader
23// is handed the conversation a window at a time: about this much text, made
24// smaller until the props fit under the limit with room for the relayed keys.
25const WINDOW_CHARS = 60_000
26const PROPS_LIMIT = 95_000
27
28const EMPTY: ReplyCommentsDraft = { reply: '', parts: [], marks: [], comments: [], overall: '', rev: 0 }
29const NORMAL: ReplyCommentsView = { mode: 'normal', via: 'reader', target: 'comment', draft: '', pending: '' }
30
31// The key field is redrawn with one of these after every keystroke, so it
32// never holds what was typed. Each draw takes the next mark, so a change can be
33// told apart from one typed before the field was cleared.
34const MARKS = ['\u200b', '\u200c', '\u200d', '\u2060']
35
36const draft = atom({ plugin: 'reply-comments', key: 'draft' } as const, EMPTY)
37const relay = atom({ plugin: 'reply-comments', key: 'relay' } as const, [] as ReplyCommentsRelayed[])
38const view = atom({ plugin: 'reply-comments', key: 'view' } as const, NORMAL)
39const legend = atom({ plugin: 'reply-comments', key: 'legend' } as const, false)
40const mouse = atom({ plugin: 'reply-comments', key: 'mouse' } as const, false)
41const keysDrawn = atom({ plugin: 'reply-comments', key: 'keysDrawn' } as const, MARKS[0]!)
42const fieldMark = atom({ plugin: 'reply-comments', key: 'fieldMark' } as const, MARKS[0]!)
43const fieldRows = atom({ plugin: 'reply-comments', key: 'fieldRows' } as const, 1)
44const win = atom({ plugin: 'reply-comments', key: 'window' } as const, { start: 0, end: 0 } as ReplyCommentsWindow)
45
46/** The field's key: one per mark, so each input gets a field of its own. */
47export const fieldKey = (mark: string) => 'field' + MARKS.indexOf(mark)
48
49const unmarked = (text: string) => text.replace(/[\u200b\u200c\u200d\u2060]/g, '')
50
51
52// How much of each mark's field text has been handed on already.
53const consumed = new Map<string, number>()
54let wasFocused = false
55let blurredAt = 0
56
57function isComment(v: unknown): v is ReplyComment {
58 if (typeof v !== 'object' || v === null) return false
59 const c = v as Record<string, unknown>
60 return (
61 typeof c.start === 'number' &&
62 typeof c.end === 'number' &&
63 typeof c.quote === 'string' &&
64 typeof c.text === 'string'
65 )
66}
67
68function isView(v: unknown): v is ReplyCommentsView {
69 if (typeof v !== 'object' || v === null) return false
70 const w = v as Record<string, unknown>
71 return (
72 (w.mode === 'normal' || w.mode === 'visual' || w.mode === 'vline' || w.mode === 'input') &&
73 (w.inputKind === undefined || w.inputKind === 'comment' || w.inputKind === 'search' || w.inputKind === 'command') &&
74 (w.via === 'pane' || w.via === 'reader') &&
75 (w.target === 'comment' || w.target === 'edit' || w.target === 'overall') &&
76 typeof w.draft === 'string' &&
77 typeof w.pending === 'string'
78 )
79}
80
81const ACTIONS = new Set(['send', 'fill', 'close', 'clear', 'legend', 'window'])
82
83function asPost(data: unknown): ReaderPost | undefined {
84 if (typeof data !== 'object' || data === null) return undefined
85 const d = data as Record<string, unknown>
86 if (!Array.isArray(d.comments) || !d.comments.every(isComment) || typeof d.overall !== 'string' || !isView(d.view)) {
87 return undefined
88 }
89 const action = typeof d.action === 'string' && ACTIONS.has(d.action) ? (d.action as ReaderPost['action']) : undefined
90 const post: ReaderPost = { comments: d.comments, overall: d.overall, view: d.view, action }
91 if (typeof d.at === 'number' && Number.isFinite(d.at)) post.at = d.at
92 if (d.edge === 'start' || d.edge === 'end') post.edge = d.edge
93 if (typeof d.cursor === 'number' && Number.isFinite(d.cursor)) post.cursor = d.cursor
94 return post
95}
96
97const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b)
98
99/** What the reader is handed: the window's piece of the conversation, offsets local to it. */
100function readerProps(d: ReplyCommentsDraft, w: ReplyCommentsWindow, relayed: ReplyCommentsRelayed[]): ReaderProps {
101 const piece = sliceConversation({ text: d.reply, parts: d.parts, marks: d.marks }, d.comments, w)
102 const props: ReaderProps = {
103 reply: piece.text,
104 parts: piece.parts,
105 marks: piece.marks,
106 comments: piece.comments,
107 overall: d.overall,
108 rev: d.rev,
109 relay: relayed,
110 before: w.start > 0,
111 after: w.end < d.reply.length,
112 elsewhere: d.comments.length - piece.comments.length,
113 }
114 if (w.cursor !== undefined && w.cursor >= w.start && w.cursor <= w.end) props.cursor = w.cursor - w.start
115 return props
116}
117
118/** The largest window around `center` whose props fit under the limit. */
119function fitWindow(d: ReplyCommentsDraft, center: number): TextWindow {
120 let size = WINDOW_CHARS
121 for (;;) {
122 const w = windowAround(d.reply, center, size)
123 if (size < 2_000 || JSON.stringify(readerProps(d, w, [])).length <= PROPS_LIMIT) return w
124 size = Math.floor(size * 0.75)
125 }
126}
127
128// While the pane is open, new rows of the conversation are drawn as they are
129// stored: a short wait folds a burst of rows into one refresh.
130const REFRESH_DELAY_MS = 150
131let isRefreshPending = false
132// The reader's cursor, as an offset into the whole conversation, as last posted.
133let lastCursor = 0
134
135/**
136 * Brings the open pane up to date with the conversation. Growth at the end is
137 * taken in place: the comments' offsets stay valid, and the reader keeps its
138 * cursor and selection. A change further up (a tool result landing above a
139 * parallel call's row, a compaction) moves the comments and the cursor across
140 * and re-seeds the reader.
141 */
142async function refresh($: EngineInterface) {
143 isRefreshPending = false
144 const panes = await $.ui.panes()
145 if (!panes.some(p => p.id === PANE)) return
146 const messages = await $.session.messages()
147 if (!Array.isArray(messages)) return
148 const convo = conversation(messages)
149 const d = await read($, draft)
150 if (convo.text === d.reply || d.reply === '') return
151 if (!convo.text.startsWith(d.reply)) {
152 const cursor = offsetMapper(d.reply, convo.text)(lastCursor) ?? Math.min(lastCursor, convo.text.length)
153 const changed: ReplyCommentsDraft = {
154 ...d,
155 reply: convo.text,
156 parts: convo.parts,
157 marks: convo.marks,
158 comments: remapComments(d.comments, d.reply, convo.text),
159 }
160 lastCursor = cursor
161 await update($, win, () => ({ ...fitWindow(changed, cursor), cursor }))
162 await update($, draft, () => ({ ...changed, rev: changed.rev + 1 }))
163 return
164 }
165
166 const grown: ReplyCommentsDraft = { ...d, reply: convo.text, parts: convo.parts, marks: convo.marks }
167 const w = await read($, win)
168 if (w.end < d.reply.length) {
169 await update($, draft, () => grown)
170 return
171 }
172 // The window reached the end: it takes in the new text, or, past the props
173 // limit, moves on with the reader re-seeded where its cursor was.
174 const wider = { start: w.start, end: convo.text.length }
175 if (JSON.stringify(readerProps(grown, wider, [])).length <= PROPS_LIMIT) {
176 await update($, win, () => wider)
177 await update($, draft, () => grown)
178 return
179 }
180 // the window first, then the rev: the reader re-seeds on the rev, from the new window
181 await update($, win, () => ({ ...fitWindow(grown, lastCursor), cursor: lastCursor }))
182 await update($, draft, () => ({ ...grown, rev: grown.rev + 1 }))
183}
184
185function scheduleRefresh($: EngineInterface) {
186 if (isRefreshPending) return
187 isRefreshPending = true
188 $.clock.after(REFRESH_DELAY_MS, () => void refresh($).catch(() => undefined))
189}
190
191/** Moves the reader's window to center on `cursor` (an offset into the whole conversation). */
192async function moveWindow($: EngineInterface, cursor: number) {
193 const d = await read($, draft)
194 const at = Math.max(0, Math.min(cursor, d.reply.length))
195 const w = fitWindow(d, at)
196 await update($, win, () => ({ ...w, cursor: at }))
197 await update($, draft, cur => ({ ...cur, rev: cur.rev + 1 }))
198}
199
200/**
201 * The buttons a mouse user gets, for the reader's state: every basic action
202 * without a key to learn. Each sends what its key would.
203 */
204/** Buttons after which the pane is gone or the prompt box is the point. */
205const LEAVES_PANE = new Set(['send', 'fill', 'close'])
206
207function mouseButtons(v: ReplyCommentsView): { key: string; label: string; keys: () => RelayKey[] }[] {
208 if (v.mode === 'input') {
209 // read at the press: the field's text changes without a redraw
210 const save = (): RelayKey[] => (isTypingInPane(v) ? [{ key: 'submit', text: fieldText }] : [{ key: 'button', text: 'save' }])
211 return [
212 { key: 'save', label: v.inputKind === 'comment' || v.inputKind === undefined ? 'Save' : 'OK', keys: save },
213 { key: 'cancel', label: 'Cancel', keys: () => [{ key: 'button', text: 'cancel' }] },
214 ]
215 }
216 const onComment = v.mode === 'normal' && v.onComment
217 return [
218 ...(onComment
219 ? [
220 { key: 'revise', label: 'Revise', keys: () => [{ key: 'r' }] },
221 { key: 'delete', label: 'Delete', keys: () => [{ key: 'x' }] },
222 ]
223 : []),
224 { key: 'overall', label: 'Overall comment', keys: () => [{ key: 'escape' }, { key: 'o' }] },
225 { key: 'send', label: 'Send', keys: () => [{ key: 'button', text: 'send' }] },
226 { key: 'fill', label: 'To prompt', keys: () => [{ key: 'button', text: 'fill' }] },
227 { key: 'close', label: 'Close', keys: () => [{ key: 'button', text: 'close' }] },
228 ]
229}
230
231const isTypingInPane = (v: ReplyCommentsView) => v.mode === 'input' && v.via === 'pane'
232
233/** Which field the pane draws for a view in input mode. */
234const fieldKindOf = (v: ReplyCommentsView) => (v.inputKind === 'comment' || v.inputKind === undefined ? v.target : v.inputKind)
235
236function refocus($: EngineInterface, delay: number) {
237 $.clock.after(delay, () => {
238 void $.ui.open({ ...PANE_OPTIONS, focus: true }).catch(() => undefined)
239 })
240}
241
242async function clearDraft($: EngineInterface) {
243 await update($, draft, d => ({ ...d, comments: [], overall: '', rev: d.rev + 1 }))
244 await update($, view, () => NORMAL)
245}
246
247async function closePane($: EngineInterface) {
248 await update($, view, () => NORMAL)
249 await $.ui.close({ id: PANE })
250}
251
252async function send($: EngineInterface) {
253 const d = await read($, draft)
254 if (d.comments.length === 0 && !d.overall.trim()) {
255 $.ui.toast('reply-comments: nothing to send yet')
256 return
257 }
258 const text = buildPrompt(d.comments, d.overall, { text: d.reply, parts: d.parts, marks: d.marks })
259 await closePane($)
260 await clearDraft($)
261 void $.prompt.submit({ text, asUser: true }).catch(() => {
262 $.ui.toast('reply-comments: the prompt could not be sent')
263 })
264}
265
266async function fill($: EngineInterface) {
267 const d = await read($, draft)
268 if (d.comments.length === 0 && !d.overall.trim()) {
269 $.ui.toast('reply-comments: nothing to put in the prompt yet')
270 return
271 }
272 const filled = await $.prompt.fill({ text: buildPrompt(d.comments, d.overall, { text: d.reply, parts: d.parts, marks: d.marks }) })
273 if (!filled.isFilled) {
274 $.ui.toast('reply-comments: the prompt box did not take the text; your comments are kept')
275 return
276 }
277 await closePane($)
278 await clearDraft($)
279}
280
281async function toggleLegend($: EngineInterface) {
282 await update($, legend, shown => !shown)
283}
284
285/**
286 * Ctrl keys never reach the key field, so each one the reader scrolls with is
287 * a hidden Button pressed by an engine keybinding action. The person binds
288 * the key to that action in the PaneField context of their keybindings file (see the README)
289 * (the README has the block). These Select actions have no engine handler
290 * mounted while the pane has the focus, so the binding presses the Button.
291 * Ctrl+D is reserved by Claude Code and can never be bound.
292 */
293export const CTRL_ACTIONS = {
294 e: 'select:next',
295 y: 'select:previous',
296 f: 'select:pageDown',
297 u: 'select:pageUp',
298} as const
299
300/** One key handed to the reader: a key typed in the pane, a field's text, or a ctrl key. */
301type RelayKey = { key: string; text?: string; ctrl?: true }
302
303/** Hands keys to the reader, which applies each relayed key once. */
304async function relayKeys($: EngineInterface, keys: RelayKey[]) {
305 if (keys.length === 0) return
306 await update($, relay, list => {
307 let seq = list.reduce((n, r) => Math.max(n, r.seq), 0)
308 const added = keys.map(k => ({ seq: ++seq, key: k.key, ...(k.text === undefined ? {} : { text: k.text }), ...(k.ctrl ? { ctrl: true as const } : {}) }))
309 return [...list, ...added].slice(-40)
310 })
311}
312
313/** The key field changed: hand on what was typed since it was last cleared, then clear it. */
314async function onKeysTyped($: EngineInterface, value: string) {
315 const mark = MARKS.includes(value[0] ?? '') ? value[0]! : ''
316 const body = [...(mark ? value.slice(1) : value)]
317 const done = consumed.get(mark) ?? 0
318 const typed = body.slice(done)
319 consumed.set(mark, body.length)
320 if (typed.length === 0) return
321 await update($, keysDrawn, cur => {
322 const next = MARKS[(MARKS.indexOf(cur) + 1) % MARKS.length]!
323 consumed.set(next, 0)
324 return next
325 })
326 await relayKeys($, typed.map(key => ({ key })))
327}
328
329// The field grows as its text wraps, the reader giving way, up to half the
330// pane's body (never less than this many rows).
331const MIN_FIELD_CAP = 6
332
333// What the field's own frame takes from the row besides its label and
334// submit hint (measured live: an 84-column pane wrapped its text at 64-69).
335const FIELD_CHROME = 4
336
337/** How many rows `text` takes when wrapped at whole words into `width` columns. */
338function wrappedRows(text: string, width: number): number {
339 let rows = 1
340 let used = 0
341 for (const word of text.split(' ')) {
342 let w = word.length
343 const need = used === 0 ? w : used + 1 + w
344 if (need <= width) {
345 used = need
346 continue
347 }
348 rows++
349 while (w > width) {
350 w -= width
351 rows++
352 }
353 used = w
354 }
355 return rows
356}
357
358/**
359 * The rows a field needs for `text`: wrapped as the field wraps it, beside
360 * its label and submit hint, one column narrower than measured so it grows
361 * a little before the text reaches the edge.
362 */
363const rowsFor = (label: string, submit: string, text: string, columns: number) =>
364 Math.max(1, Math.min(Math.max(MIN_FIELD_CAP, Math.floor(bodyRows / 2)), wrappedRows(text, Math.max(10, columns - label.length - submit.length - 2 - FIELD_CHROME - 1))))
365
366/** The hint a field draws beside its text. */
367const submitOf = (v: ReplyCommentsView) => (v.inputKind === 'comment' ? 'save' : 'go')
368
369// The pane body's size, as last drawn, for sizing the field as it is typed in.
370let bodyColumns = 80
371// What the pane's field holds now, for the Save button.
372let fieldText = ''
373let bodyRows = 20
374
375const FIELD_LABEL = {
376 comment: 'Comment',
377 edit: 'Revise',
378 overall: 'Overall',
379 search: '/',
380 command: ':',
381} as const
382
383export const register: Register = on => {
384 on('session.start', async ($, e, next) => {
385 await $.command.register({
386 name: COMMAND,
387 description: 'Comment on parts of the conversation, then send the comments to Claude as one prompt',
388 // runs at once mid-turn: the pane opens while Claude works and fills in live
389 immediate: true,
390 })
391
392 return next(e)
393 })
394
395 on('command.run', { command: COMMAND }, async $ => {
396 const messages = await $.session.messages()
397 const convo = conversation(Array.isArray(messages) ? messages : [])
398 if (!lastClaudePart(convo.parts)) return { text: 'There is no reply to annotate yet.' }
399
400 // Unsent comments carry over while the conversation has only grown since:
401 // what they point at is still where it was.
402 const before = await read($, draft)
403 const isKept = before.reply !== '' && convo.text.startsWith(before.reply)
404 const isResumed = isKept && (before.comments.length > 0 || before.overall.trim() !== '')
405 await update($, draft, d => ({
406 reply: convo.text,
407 parts: convo.parts,
408 marks: convo.marks,
409 comments: isKept ? d.comments : [],
410 overall: isKept ? d.overall : '',
411 rev: d.rev + 1,
412 }))
413 const opened = await read($, draft)
414 await update($, win, () => fitWindow(opened, opened.reply.length))
415 await update($, view, () => NORMAL)
416 await update($, legend, () => false)
417
418 await $.ui.open(PANE_OPTIONS)
419 refocus($, FOCUS_DELAY_MS)
420
421 const keys = 'Vim keys work in the pane; ? shows them all.'
422 return {
423 text: isResumed ? `Picked up your ${before.comments.length} unsent comment(s). ${keys}` : keys,
424 }
425 })
426
427 // Escape reaches the pane as its close (closeOnEscape). From inside the pane
428 // it never closes it: it leaves a selection, an input or a half-typed command,
429 // and in plain normal mode does nothing, as in vim; either way the pane takes
430 // the keyboard back. `q` closes. A close from outside the pane (its mark while
431 // unfocused, Escape at an idle prompt) goes through.
432 on('ui.close', async ($, e, next) => {
433 if (e.id !== PANE || e.origin.kind !== 'person') return next(e)
434 const v = await read($, view)
435 const isFromPane = wasFocused || Date.now() - blurredAt < ESCAPE_WINDOW_MS
436 if (v.mode !== 'normal' || v.pending !== '') {
437 await relayKeys($, [{ key: 'escape' }])
438 } else if (!isFromPane) {
439 return next(e)
440 }
441 refocus($, REFOCUS_DELAY_MS)
442 return { deny: 'Escape stays in the pane; q closes it' }
443 })
444
445 // A row stored in the main conversation (a prompt, a reply block, a tool
446 // call or its result) reaches the open pane shortly after.
447 on('session.append', async ($, e, next) => {
448 const stored = await next(e)
449 if (e.agentId === undefined && (e.message.type === 'user' || e.message.type === 'assistant')) scheduleRefresh($)
450 return stored
451 })
452
453 // The wheel (or trackpad) over the pane scrolls the reply. The pane's own
454 // window never moves: the reader fills it exactly and scrolls its text itself.
455 on('ui.scroll', { requestId: PANE }, async ($, e, next) => {
456 if (e.origin.kind !== 'person') return next(e)
457 if (e.by !== 0) await relayKeys($, [{ key: 'wheel', text: String(e.by) }])
458 return {}
459 })
460
461 on('ui.message', { requestId: PANE }, async ($, e) => {
462 const post = asPost(e.data)
463 if (!post) return {}
464
465 const d = await read($, draft)
466 const w = await read($, win)
467 if (post.cursor !== undefined) lastCursor = w.start + post.cursor
468 const comments = mergeComments(d.comments, post.comments, w)
469 if (!same(d.comments, comments) || d.overall !== post.overall) {
470 await update($, draft, cur => ({ ...cur, comments, overall: post.overall }))
471 }
472 if (post.view.mouse && !(await read($, mouse))) await update($, mouse, () => true)
473 const was = await read($, view)
474 if (!same(was, post.view)) {
475 await update($, view, () => post.view)
476 if (isTypingInPane(post.view) && !isTypingInPane(was)) {
477 // A new mark draws a new field: nothing an earlier, cancelled input
478 // left in it, and its cursor at the end of the text it starts with.
479 const mark = MARKS[(MARKS.indexOf(await read($, fieldMark)) + 1) % MARKS.length]!
480 await update($, fieldMark, () => mark)
481 fieldText = post.view.draft
482 await update($, fieldRows, () => rowsFor(FIELD_LABEL[fieldKindOf(post.view)], submitOf(post.view), post.view.draft, bodyColumns))
483 void $.ui.focus({ requestId: PANE, key: fieldKey(mark) }).catch(() => undefined)
484 } else if (!isTypingInPane(post.view) && isTypingInPane(was)) {
485 void $.ui.focus({ requestId: PANE, key: 'keys' }).catch(() => undefined)
486 }
487 }
488
489 if (post.action === 'send') await send($)
490 if (post.action === 'fill') await fill($)
491 if (post.action === 'clear') await clearDraft($)
492 if (post.action === 'close') await closePane($)
493 if (post.action === 'legend') await toggleLegend($)
494 if (post.action === 'window') {
495 const lastLine = d.reply.lastIndexOf('\n') + 1
496 await moveWindow($, post.edge === 'start' ? 0 : post.edge === 'end' ? lastLine : w.start + (post.at ?? 0))
497 }
498
499 return {}
500 })
501
502 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
503 if (wasFocused && !e.props.isFocused) blurredAt = Date.now()
504 wasFocused = e.props.isFocused
505 const d = await read($, draft)
506
507 if (e.surface === 'terminal' || e.surface === 'desktop') {
508 const els = $.ui.resolve(e)
509 const { Box, Text, Input, Button } = els
510 const v = await read($, view)
511 const keys = await read($, relay)
512 const w = await read($, win)
513 const showLegend = await read($, legend)
514 const drawn = await read($, keysDrawn)
515 const mark = await read($, fieldMark)
516 const legendRows = showLegend ? legendLines(e.props.bodyColumns) : []
517 bodyColumns = e.props.bodyColumns
518 bodyRows = e.props.scroll.bodyRows
519 const fieldKind = fieldKindOf(v)
520 const fieldHeight = isTypingInPane(v) ? await read($, fieldRows) : 1
521 const isMouse = await read($, mouse)
522 const buttonRows = isMouse ? 1 : 0
523 const readerRows = Math.max(3, e.props.scroll.bodyRows - legendRows.length - fieldHeight - buttonRows)
524
525 return (
526 <Box flexDirection="column">
527 <els.Client
528 key="reader"
529 module="./reader.tsx"
530 props={readerProps(d, w, keys)}
531 width="100%"
532 height={readerRows}
533 />
534 <Box flexDirection="row" height={0} overflow="hidden">
535 {!isTypingInPane(v) && Object.entries(CTRL_ACTIONS).map(([key, action]) => (
536 <Button key={'ctrl-' + key} label={'^' + key.toUpperCase()} plain action={action} onPress={() => relayKeys($, [{ key, ctrl: true }])} />
537 ))}
538 </Box>
539 {isMouse ? (
540 <Box flexDirection="row" height={1} overflow="hidden" columnGap={1}>
541 {mouseButtons(v).map(b => (
542 <Button
543 key={'mouse-' + b.key}
544 label={b.label}
545 onPress={async () => {
546 await relayKeys($, b.keys())
547 // A pressed button hands the keyboard to the prompt box; take it
548 // back, so typing goes to the pane (a new field takes it there).
549 if (!LEAVES_PANE.has(b.key)) void $.ui.open({ ...PANE_OPTIONS, focus: true }).catch(() => undefined)
550 }}
551 />
552 ))}
553 {v.mode === 'normal' && !v.onComment ? <Text dimColor wrap="truncate-end">drag over text to comment · click a comment to change it</Text> : null}
554 </Box>
555 ) : null}
556 {legendRows.map(row => (
557 <Box flexDirection="row" height={1} overflow="hidden">
558 <Text bold>{row.group.padEnd(GROUP_COLUMN)}</Text>
559 <Text dimColor wrap="truncate-end">
560 {row.text}
561 </Text>
562 </Box>
563 ))}
564 <Box height={fieldHeight} overflow="hidden">
565 {isTypingInPane(v) ? (
566 // One level deeper than the key field, so the surface draws it as a
567 // field of its own: a reused one keeps the key field's cursor position.
568 <Box flexGrow={1}>
569 <Input
570 key={fieldKey(mark)}
571 label={FIELD_LABEL[fieldKind]}
572 value={v.draft}
573 submitLabel={submitOf(v)}
574 autoFocus
575 onInput={text => {
576 fieldText = unmarked(text)
577 const rows = rowsFor(FIELD_LABEL[fieldKind], submitOf(v), unmarked(text), bodyColumns)
578 void read($, fieldRows).then(was => (was === rows ? undefined : update($, fieldRows, () => rows)))
579 }}
580 onSubmit={text => relayKeys($, [{ key: 'submit', text: unmarked(text) }])}
581 />
582 </Box>
583 ) : (
584 <Input
585 key="keys"
586 label={showLegend ? 'keys' : 'keys (? help)'}
587 value={drawn}
588 submitLabel="enter"
589 autoFocus
590 onInput={value => onKeysTyped($, value)}
591 onSubmit={() => relayKeys($, [{ key: 'return' }])}
592 />
593 )}
594 </Box>
595 </Box>
596 )
597 }
598
599 const { Box, Text, Markdown } = $.ui.resolve(e)
600 const w = await read($, win)
601 return (
602 <Box flexDirection="column">
603 <Text dimColor>Selecting spans needs the terminal or the desktop app.</Text>
604 <Markdown text={d.reply.slice(w.start, w.end)} />
605 </Box>
606 )
607 })
608}
609hooks/lib.ts 769 lines1import type { ReplyComment, ReplyCommentsMark, ReplyCommentsPart } from '../types'
2
3/** One display row of the reply: the text offsets [start, end) it shows. */
4export type Line = { start: number; end: number }
5
6/** A span of the reply: offsets [start, end). */
7export type Range = { start: number; end: number }
8
9/** A message as `$.session.messages()` answers it, the fields read here. */
10type MessageLike = {
11 role: 'user' | 'assistant'
12 text: string
13 toolUses?: readonly ToolUseLike[]
14 toolResults?: readonly unknown[]
15}
16
17/** A tool call as `$.session.messages()` summarises it, the fields read here. */
18type ToolUseLike = { tool: string; input?: Record<string, unknown>; result?: unknown; text?: string; isError?: true }
19
20/** The conversation as one text, where each message sits in it, and how its spans are styled. */
21export type Conversation = { text: string; parts: ReplyCommentsPart[]; marks: ReplyCommentsMark[] }
22
23/** What the engine adds to a message that the person never wrote. */
24const clean = (text: string) =>
25 text
26 .replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '')
27 // the tags around pasted text go; the pasted text stays
28 .replace(/<\\?\/?pasted_content(?:\s[^>]*)?>/g, '')
29 .trim()
30
31/** A slash command's own record (`<command-name>…`): not a message to annotate. */
32const isCommandRecord = (text: string) => /^<(command-|local-command-)/.test(text)
33
34type Block = { role: ReplyCommentsPart['role']; text: string; marks: ReplyCommentsMark[]; isError?: boolean }
35
36/** Inline markdown on one line: `**bold**` and `` `code` `` drawn without their markers. */
37function inline(line: string, at: number, marks: ReplyCommentsMark[]): string {
38 let out = ''
39 const re = /\*\*([^*\n]+)\*\*|`([^`\n]+)`/g
40 let last = 0
41 for (let m = re.exec(line); m; m = re.exec(line)) {
42 out += line.slice(last, m.index)
43 const inner = m[1] ?? m[2]!
44 marks.push({ start: at + out.length, end: at + out.length + inner.length, style: m[1] ? 'bold' : 'code' })
45 out += inner
46 last = m.index + m[0].length
47 }
48 return out + line.slice(last)
49}
50
51/**
52 * Markdown drawn as the transcript draws it, near enough: headings bold, code
53 * fences gone with their code tinted, bold and inline code without markers.
54 * Offsets in `marks` are into the returned text.
55 */
56export function renderMarkdown(source: string): { text: string; marks: ReplyCommentsMark[] } {
57 const marks: ReplyCommentsMark[] = []
58 const lines: string[] = []
59 let length = 0
60 let isCode = false
61 const push = (line: string) => {
62 lines.push(line)
63 length += line.length + 1
64 }
65 for (const raw of source.split('\n')) {
66 if (/^\s*```/.test(raw)) {
67 isCode = !isCode
68 continue
69 }
70 if (isCode) {
71 marks.push({ start: length, end: length + raw.length, style: 'code' })
72 push(raw)
73 continue
74 }
75 const heading = /^#{1,6}\s+(.*)$/.exec(raw)
76 if (heading) {
77 const text = inline(heading[1]!, length, marks)
78 marks.push({ start: length, end: length + text.length, style: 'heading' })
79 push(text)
80 continue
81 }
82 push(inline(raw, length, marks))
83 }
84 return { text: lines.join('\n'), marks }
85}
86
87const TOOL_ARGS = ['command', 'file_path', 'path', 'pattern', 'url', 'query', 'description', 'prompt', 'skill']
88
89/** A plan Claude presents (`ExitPlanMode`): drawn in full, markdown rendered, under a header. */
90function planBlock(use: ToolUseLike, plan: string): Block {
91 const head = 'Plan'
92 const body = renderMarkdown(plan.trim())
93 const marks: ReplyCommentsMark[] = [{ start: 0, end: head.length, style: 'bold' }]
94 for (const m of body.marks) marks.push({ ...m, start: m.start + head.length + 1, end: m.end + head.length + 1 })
95 let text = `${head}\n${body.text}`
96 const first = use.text?.split('\n').find(l => l.trim())?.trim()
97 if (first) {
98 const result = `⎿ ${first.length > 100 ? `${first.slice(0, 99)}…` : first}`
99 marks.push({ start: text.length + 1, end: text.length + 1 + result.length, style: 'dim' })
100 text += `\n${result}`
101 }
102 return { role: 'plan', text, marks, isError: use.isError }
103}
104
105const isPlanFile = (path: string) => /[\\/]plans[\\/][^\\/]+\.md$/.test(path)
106
107/**
108 * The files Claude wrote in this conversation, as its Write and Edit calls
109 * left them, and which one was the plan file last.
110 *
111 * `ExitPlanMode` reaches a mod with an empty input. The plan it presents is
112 * the plan file (`~/.claude/plans/*.md`) Claude wrote just before, so the
113 * conversation replays those writes to know what each plan said then.
114 */
115type Written = { files: Map<string, string>; planPath?: string }
116
117function noteWrite(w: Written, use: ToolUseLike) {
118 const input = use.input ?? {}
119 const path = input.file_path
120 if (typeof path !== 'string' || use.isError) return
121 if (use.tool === 'Write' && typeof input.content === 'string') w.files.set(path, input.content)
122 else if (use.tool === 'Edit' && typeof input.old_string === 'string' && typeof input.new_string === 'string') {
123 const before = w.files.get(path)
124 if (before === undefined) return
125 w.files.set(path, input.replace_all === true ? before.split(input.old_string).join(input.new_string) : before.replace(input.old_string, () => input.new_string as string))
126 } else return
127 if (isPlanFile(path)) w.planPath = path
128}
129
130/** The text of the plan an `ExitPlanMode` call presented, wherever this version keeps it. */
131function planOf(use: ToolUseLike, w: Written): string | undefined {
132 const result = (typeof use.result === 'object' && use.result !== null ? use.result : {}) as Record<string, unknown>
133 const found = [use.input?.plan, result.plan, typeof result.filePath === 'string' ? w.files.get(result.filePath) : undefined, w.planPath && w.files.get(w.planPath)]
134 return found.find((p): p is string => typeof p === 'string' && p.trim() !== '')
135}
136
137/** A tool call's row, as the transcript names it: `Bash(ls -la)`, and its result's first line. */
138function toolBlock(use: ToolUseLike, w: Written): Block {
139 const plan = use.tool === 'ExitPlanMode' ? planOf(use, w) : undefined
140 if (plan) return planBlock(use, plan)
141 const input = use.input ?? {}
142 const key = TOOL_ARGS.find(k => typeof input[k] === 'string') ?? Object.keys(input).find(k => typeof input[k] === 'string')
143 const arg = key ? String(input[key]).replace(/\s+/g, ' ').trim() : ''
144 const head = `${use.tool}(${arg.length > 80 ? `${arg.slice(0, 79)}…` : arg})`
145 const marks: ReplyCommentsMark[] = [{ start: 0, end: use.tool.length, style: 'bold' }]
146 const first = use.text?.split('\n').find(l => l.trim())?.trim()
147 if (!first) return { role: 'tool', text: head, marks, isError: use.isError }
148 const result = `⎿ ${first.length > 100 ? `${first.slice(0, 99)}…` : first}`
149 marks.push({ start: head.length + 1, end: head.length + 1 + result.length, style: 'dim' })
150 return { role: 'tool', text: `${head}\n${result}`, marks, isError: use.isError }
151}
152
153/**
154 * The whole conversation as one text, laid out as the transcript shows it:
155 * each of the person's prompts, each block of Claude's reply text (markdown
156 * drawn), each tool call and each plan Claude presents (in full), a blank
157 * line between them. The reader draws the
158 * `❯` / `⏺` gutter beside each part. A slash command shows nothing itself.
159 */
160export function conversation(messages: readonly MessageLike[]): Conversation {
161 const blocks: Block[] = []
162 const written: Written = { files: new Map() }
163 for (const m of messages) {
164 const text = clean(m.text)
165 if (m.role === 'assistant') {
166 if (text) blocks.push({ role: 'assistant', ...renderMarkdown(text) })
167 for (const use of m.toolUses ?? []) {
168 noteWrite(written, use)
169 blocks.push(toolBlock(use, written))
170 }
171 continue
172 }
173 if (text && !isCommandRecord(text)) blocks.push({ role: 'user', ...renderMarkdown(text) })
174 }
175
176 let text = ''
177 const parts: ReplyCommentsPart[] = []
178 const marks: ReplyCommentsMark[] = []
179 for (const b of blocks) {
180 if (text) text += '\n\n'
181 const start = text.length
182 parts.push(b.isError ? { role: b.role, start, end: start + b.text.length, isError: true } : { role: b.role, start, end: start + b.text.length })
183 for (const mk of b.marks) marks.push({ ...mk, start: mk.start + start, end: mk.end + start })
184 text += b.text
185 }
186 return { text, parts, marks }
187}
188
189/** Claude's latest reply text or plan, where a new annotation opens. */
190export function lastClaudePart(parts: readonly ReplyCommentsPart[]): ReplyCommentsPart | undefined {
191 return [...parts].reverse().find(p => p.role === 'assistant' || p.role === 'plan')
192}
193
194/** The start of Claude's last reply text, where a new annotation starts. */
195export function lastReplyPart(parts: readonly ReplyCommentsPart[]): ReplyCommentsPart | undefined {
196 return [...parts].reverse().find(p => p.role === 'assistant')
197}
198
199// ---------- the reader's window ----------
200
201/** A span of the conversation text the reader is handed, offsets [start, end). */
202export type TextWindow = { start: number; end: number }
203
204/**
205 * About `size` characters of `text` around `center`, cut at line breaks: the
206 * reader's props are bounded, so a long conversation is handed over a piece
207 * at a time.
208 */
209export function windowAround(text: string, center: number, size: number): TextWindow {
210 if (text.length <= size) return { start: 0, end: text.length }
211 let start = Math.max(0, Math.min(center - Math.floor(size / 2), text.length - size))
212 let end = Math.min(text.length, start + size)
213 if (start > 0) {
214 const nl = text.indexOf('\n', start)
215 if (nl >= 0 && nl < end) start = nl + 1
216 }
217 if (end < text.length) {
218 const nl = text.lastIndexOf('\n', end)
219 if (nl > start) end = nl
220 }
221 return { start, end }
222}
223
224/**
225 * The conversation as the reader sees it through a window: the text, parts,
226 * marks and comments, offsets made local. A part cut by the window's start
227 * keeps a negative start, so no gutter mark is drawn for it; a comment is
228 * handed over only when the window holds all of it.
229 */
230export function sliceConversation(
231 convo: Conversation,
232 comments: readonly ReplyComment[],
233 win: TextWindow,
234): Conversation & { comments: ReplyComment[] } {
235 const shift = <T extends { start: number; end: number }>(r: T): T => ({ ...r, start: r.start - win.start, end: r.end - win.start })
236 return {
237 text: convo.text.slice(win.start, win.end),
238 parts: convo.parts.filter(p => p.end > win.start && p.start < win.end).map(shift),
239 marks: convo.marks
240 .filter(m => m.end > win.start && m.start < win.end)
241 .map(m => shift({ ...m, start: Math.max(m.start, win.start), end: Math.min(m.end, win.end) })),
242 comments: comments.filter(c => c.start >= win.start && c.end <= win.end).map(shift),
243 }
244}
245
246/** The reader's comments, local to the window, merged back into the whole draft's. */
247export function mergeComments(all: readonly ReplyComment[], local: readonly ReplyComment[], win: TextWindow): ReplyComment[] {
248 const outside = all.filter(c => !(c.start >= win.start && c.end <= win.end))
249 const inside = local.map(c => ({ ...c, start: c.start + win.start, end: c.end + win.start }))
250 return [...outside, ...inside]
251}
252
253// ---------- display rows ----------
254
255/** Breaks the text into display rows of at most `width` cells, at spaces where it can. */
256export function wrap(text: string, width: number): Line[] {
257 const w = Math.max(1, Math.floor(width))
258 const lines: Line[] = []
259 let base = 0
260
261 for (const para of text.split('\n')) {
262 if (para.length === 0) lines.push({ start: base, end: base })
263 let i = 0
264 while (i < para.length) {
265 let end = Math.min(i + w, para.length)
266 if (end < para.length) {
267 const space = para.lastIndexOf(' ', end - 1)
268 if (space > i) end = space + 1
269 }
270 lines.push({ start: base + i, end: base + end })
271 i = end
272 }
273 base += para.length + 1
274 }
275
276 return lines
277}
278
279/** The index of the display row holding `offset`. */
280export function lineOf(lines: readonly Line[], offset: number): number {
281 let lo = 0
282 let hi = lines.length - 1
283 while (lo < hi) {
284 const mid = (lo + hi + 1) >> 1
285 if (lines[mid]!.start <= offset) lo = mid
286 else hi = mid - 1
287 }
288 return lo
289}
290
291/** The last offset the cursor may rest on in a row (its start when empty). */
292export function lastOf(line: Line): number {
293 return Math.max(line.start, line.end - 1)
294}
295
296/** The offset at `column` of row `index`, clamped to the row. */
297export function offsetAt(lines: readonly Line[], index: number, column: number): number {
298 const line = lines[Math.max(0, Math.min(lines.length - 1, index))]!
299 return Math.min(line.start + Math.max(0, column), lastOf(line))
300}
301
302// ---------- source lines ----------
303
304/** The offset where the source line holding `i` starts. */
305export function lineStart(text: string, i: number): number {
306 return text.lastIndexOf('\n', i - 1) + 1
307}
308
309/** The offset of the newline ending the source line holding `i` (or the text's end). */
310export function lineEnd(text: string, i: number): number {
311 const nl = text.indexOf('\n', i)
312 return nl < 0 ? text.length : nl
313}
314
315/** The last offset the cursor may rest on in the source line holding `i`. */
316export function lineLast(text: string, i: number): number {
317 const s = lineStart(text, i)
318 return Math.max(s, lineEnd(text, i) - 1)
319}
320
321/** The first non-blank offset of the source line holding `i`, as vim's `^`. */
322export function firstNonBlank(text: string, i: number): number {
323 const s = lineStart(text, i)
324 const e = lineEnd(text, i)
325 let j = s
326 while (j < e && (text[j] === ' ' || text[j] === '\t')) j++
327 return j < e ? j : s
328}
329
330/** The start offset of source line `n` (1-based), clamped to the text. */
331export function nthLine(text: string, n: number): number {
332 let at = 0
333 for (let k = 1; k < n; k++) {
334 const nl = text.indexOf('\n', at)
335 if (nl < 0) break
336 at = nl + 1
337 }
338 return Math.min(at, Math.max(0, text.length - 1))
339}
340
341/** The 1-based number of the source line holding `i`. */
342export function lineNumber(text: string, i: number): number {
343 let n = 1
344 for (let k = 0; k < i && k < text.length; k++) if (text[k] === '\n') n++
345 return n
346}
347
348// ---------- words ----------
349
350/** vim's character classes: 0 blank, 1 word (letters, digits, _), 2 other; a WORD is any non-blank. */
351function cls(c: string | undefined, big: boolean): number {
352 if (c === undefined || /\s/.test(c)) return 0
353 if (big) return 1
354 return /[\p{L}\p{N}_]/u.test(c) ? 1 : 2
355}
356
357/** An empty line: a newline that follows a newline (or opens the text). */
358const isEmptyLineAt = (t: string, j: number) => t[j] === '\n' && (j === 0 || t[j - 1] === '\n')
359
360const lastIndex = (t: string) => Math.max(0, t.length - 1)
361
362/** vim's `w` / `W`: the start of the next word (an empty line counts as one). */
363export function wordForward(t: string, i: number, big: boolean): number {
364 let j = i
365 const c = cls(t[j], big)
366 if (c !== 0) while (j < t.length && cls(t[j], big) === c) j++
367 while (j < t.length && cls(t[j], big) === 0) {
368 if (j > i && isEmptyLineAt(t, j)) return j
369 j++
370 }
371 return Math.min(j, lastIndex(t))
372}
373
374/** vim's `b` / `B`: the start of this word, or of the one before. */
375export function wordBackward(t: string, i: number, big: boolean): number {
376 let j = i - 1
377 while (j > 0 && cls(t[j], big) === 0) {
378 if (isEmptyLineAt(t, j)) return j
379 j--
380 }
381 if (j <= 0) return 0
382 const c = cls(t[j], big)
383 while (j > 0 && cls(t[j - 1], big) === c) j--
384 return j
385}
386
387/** vim's `e` / `E`: the end of this word, or of the next. */
388export function wordEnd(t: string, i: number, big: boolean): number {
389 let j = i + 1
390 while (j < t.length && cls(t[j], big) === 0) j++
391 if (j >= t.length) return lastIndex(t)
392 const c = cls(t[j], big)
393 while (j + 1 < t.length && cls(t[j + 1], big) === c) j++
394 return j
395}
396
397/** vim's `ge` / `gE`: the end of the word before. */
398export function wordEndBackward(t: string, i: number, big: boolean): number {
399 let j = i
400 const c = cls(t[j], big)
401 if (c !== 0) while (j > 0 && cls(t[j - 1], big) === c) j--
402 j--
403 while (j > 0 && cls(t[j], big) === 0) j--
404 return Math.max(0, j)
405}
406
407// ---------- paragraphs ----------
408
409/** vim's `}` / `{`: the next / previous empty line, or the text's end / start. */
410export function paragraphMove(t: string, i: number, dir: 1 | -1): number {
411 let j = i
412 if (dir === 1) {
413 while (j < t.length && isEmptyLineAt(t, j)) j++
414 while (j < t.length && !isEmptyLineAt(t, j)) j++
415 return Math.min(j, lastIndex(t))
416 }
417 while (j > 0 && isEmptyLineAt(t, j)) j--
418 while (j > 0 && !isEmptyLineAt(t, j)) j--
419 return j
420}
421
422/** The paragraph (text between empty lines) holding `offset`, trimmed. */
423export function paragraphAt(text: string, offset: number): Range {
424 const before = text.lastIndexOf('\n\n', Math.max(0, offset - 1))
425 const after = text.indexOf('\n\n', offset)
426 return trimRange(text, before < 0 ? 0 : before + 2, after < 0 ? text.length : after)
427}
428
429// ---------- finding ----------
430
431export type FindCommand = 'f' | 'F' | 't' | 'T'
432
433/**
434 * vim's `f` `F` `t` `T` within the source line; undefined when the character
435 * is not there. `t` and `T` stay put when it is right next to the cursor,
436 * unless `isRepeat` (a `;` or `,`), which looks past it.
437 */
438export function findChar(t: string, i: number, cmd: FindCommand, ch: string, isRepeat = false): number | undefined {
439 const skip = isRepeat && (cmd === 't' || cmd === 'T') ? 1 : 0
440 if (cmd === 'f' || cmd === 't') {
441 const at = t.indexOf(ch, i + 1 + skip)
442 if (at < 0 || at >= lineEnd(t, i)) return undefined
443 return cmd === 't' ? at - 1 : at
444 }
445 const from = i - 1 - skip
446 if (from < 0) return undefined
447 const at = t.lastIndexOf(ch, from)
448 if (at < 0 || at < lineStart(t, i)) return undefined
449 return cmd === 'T' ? at + 1 : at
450}
451
452/** Every match of `pattern` in the text, smartcase (ignores case unless the pattern has a capital). */
453export function searchMatches(t: string, pattern: string): number[] {
454 if (!pattern) return []
455 const ignoreCase = pattern === pattern.toLowerCase()
456 const hay = ignoreCase ? t.toLowerCase() : t
457 const needle = ignoreCase ? pattern.toLowerCase() : pattern
458 const found: number[] = []
459 for (let at = hay.indexOf(needle); at >= 0; at = hay.indexOf(needle, at + 1)) found.push(at)
460 return found
461}
462
463/** The next (`dir` 1) or previous match after `i`, wrapping around; undefined when none. */
464export function searchFrom(t: string, i: number, pattern: string, dir: 1 | -1): number | undefined {
465 const found = searchMatches(t, pattern)
466 if (found.length === 0) return undefined
467 if (dir === 1) return found.find(at => at > i) ?? found[0]
468 return [...found].reverse().find(at => at < i) ?? found[found.length - 1]
469}
470
471// ---------- text objects ----------
472
473const PAIRS: Record<string, [string, string]> = {
474 '(': ['(', ')'], ')': ['(', ')'], b: ['(', ')'],
475 '[': ['[', ']'], ']': ['[', ']'],
476 '{': ['{', '}'], '}': ['{', '}'], B: ['{', '}'],
477 '<': ['<', '>'], '>': ['<', '>'],
478}
479const QUOTES = new Set(['"', "'", '`'])
480
481/** The characters that may follow `i` or `a` to name a text object. */
482export const TEXT_OBJECTS = new Set(['w', 'W', 'p', 's', ...Object.keys(PAIRS), ...QUOTES])
483
484/**
485 * vim's text objects around `i`: `iw` `aw` `iW` `aW`, `is` `as` (sentence),
486 * `ip` `ap`, brackets `i(` `a(` (and `[ { <`, `b` `B`), quotes `i"` `a"` (and
487 * `' \``). Undefined when there is none at the cursor.
488 */
489export function textObject(t: string, i: number, around: boolean, obj: string): Range | undefined {
490 if (!t) return undefined
491 if (obj === 'w' || obj === 'W') {
492 const big = obj === 'W'
493 const c = cls(t[i], big)
494 let s = i
495 let e = i + 1
496 while (s > 0 && cls(t[s - 1], big) === c && t[s - 1] !== '\n') s--
497 while (e < t.length && cls(t[e], big) === c && t[e] !== '\n') e++
498 if (around) {
499 const e0 = e
500 while (e < t.length && (t[e] === ' ' || t[e] === '\t')) e++
501 if (e === e0) while (s > 0 && (t[s - 1] === ' ' || t[s - 1] === '\t')) s--
502 }
503 return { start: s, end: e }
504 }
505 if (obj === 'p') {
506 let s = lineStart(t, i)
507 let e = lineEnd(t, i)
508 const blank = s === e
509 while (s > 0 && (lineStart(t, s - 1) === s - 1) === blank) s = lineStart(t, s - 1)
510 while (e < t.length && (lineEnd(t, e + 1) === e + 1) === blank) e = lineEnd(t, e + 1)
511 if (around && !blank) while (e < t.length && lineEnd(t, e + 1) === e + 1) e = lineEnd(t, e + 1)
512 return { start: s, end: e }
513 }
514 if (obj === 's') {
515 const para = paragraphAt(t, i)
516 let s = para.start
517 for (let j = i - 1; j >= para.start; j--) {
518 if (/[.!?]/.test(t[j]!) && /\s/.test(t[j + 1] ?? ' ')) {
519 s = j + 1
520 break
521 }
522 }
523 while (s < para.end && /\s/.test(t[s]!)) s++
524 let e = para.end
525 for (let j = Math.max(i, s); j < para.end; j++) {
526 if (/[.!?]/.test(t[j]!) && /\s|$/.test(t[j + 1] ?? '')) {
527 e = j + 1
528 break
529 }
530 }
531 if (around) while (e < para.end && /\s/.test(t[e]!)) e++
532 return { start: s, end: e }
533 }
534 if (PAIRS[obj]) {
535 const [open, close] = PAIRS[obj]!
536 let depth = 0
537 let s = -1
538 for (let j = i; j >= 0; j--) {
539 if (t[j] === close && j !== i) depth++
540 else if (t[j] === open) {
541 if (depth === 0) {
542 s = j
543 break
544 }
545 depth--
546 }
547 }
548 if (s < 0) {
549 // Not inside a pair: take the next one opening on this line, as vim does for quotes.
550 const next = t.indexOf(open, i)
551 if (next < 0 || next >= lineEnd(t, i)) return undefined
552 s = next
553 }
554 depth = 0
555 let e = -1
556 for (let j = s + 1; j < t.length; j++) {
557 if (t[j] === open) depth++
558 else if (t[j] === close) {
559 if (depth === 0) {
560 e = j
561 break
562 }
563 depth--
564 }
565 }
566 if (e < 0) return undefined
567 return around ? { start: s, end: e + 1 } : { start: s + 1, end: e }
568 }
569 if (QUOTES.has(obj)) {
570 // Quotes pair up along the line, first with second, third with fourth.
571 const marks: number[] = []
572 for (let j = lineStart(t, i); j < lineEnd(t, i); j++) if (t[j] === obj) marks.push(j)
573 for (let k = 0; k + 1 < marks.length; k += 2) {
574 const s = marks[k]!
575 const e = marks[k + 1]!
576 if (i <= e) return around ? { start: s, end: e + 1 } : { start: s + 1, end: e }
577 }
578 return undefined
579 }
580 return undefined
581}
582
583// ---------- ranges ----------
584
585/** The range with its surrounding whitespace removed. */
586export function trimRange(text: string, start: number, end: number): Range {
587 let s = Math.max(0, start)
588 let e = Math.min(text.length, end)
589 while (s < e && /\s/.test(text[s]!)) s++
590 while (e > s && /\s/.test(text[e - 1]!)) e--
591 return { start: s, end: e }
592}
593
594/**
595 * What a selection between `anchor` and `cursor` covers, both ends included;
596 * `linewise` widens it to whole source lines, as vim's `V`.
597 */
598export function selectionRange(text: string, anchor: number, cursor: number, linewise: boolean): Range {
599 let start = Math.min(anchor, cursor)
600 let end = Math.min(text.length, Math.max(anchor, cursor) + 1)
601 if (linewise) {
602 start = lineStart(text, start)
603 end = lineEnd(text, Math.max(start, end - 1))
604 }
605 return trimRange(text, start, end)
606}
607
608/** The index of the comment covering `offset`, or -1. */
609export function commentAt(comments: readonly ReplyComment[], offset: number): number {
610 return comments.findIndex(c => offset >= c.start && offset < c.end)
611}
612
613// ---------- the prompt ----------
614
615const MAX_QUOTE = 1500
616
617function quote(text: string): string {
618 const cut =
619 text.length > MAX_QUOTE
620 ? `${text.slice(0, MAX_QUOTE / 2)}\n[…]\n${text.slice(-MAX_QUOTE / 2)}`
621 : text
622 return cut
623 .split('\n')
624 .map(line => (line ? `> ${line}` : '>'))
625 .join('\n')
626}
627
628const SNIPPET = 60
629
630function snippet(text: string): string {
631 const flat = text.replace(/\s+/g, ' ').trim()
632 return flat.length > SNIPPET ? `${flat.slice(0, SNIPPET - 1)}…` : flat
633}
634
635/** Which message a comment is on, as the prompt names it to Claude. */
636function whereIs(c: ReplyComment, text: string, parts: readonly ReplyCommentsPart[]): string {
637 const first = parts.findIndex(p => c.start >= p.start && c.start <= p.end)
638 const last = parts.findIndex(p => c.end - 1 >= p.start && c.end - 1 <= p.end)
639 if (first < 0) return 'the conversation'
640 if (last <= first) return placeOf(first, text, parts)
641 // a selection across messages names where it starts and where it ends
642 return `${placeOf(first, text, parts)} ${last === first + 1 ? 'and' : 'through'} ${placeOf(last, text, parts)}`
643}
644
645/** How the prompt names one message to Claude. */
646function placeOf(at: number, text: string, parts: readonly ReplyCommentsPart[]): string {
647 const part = parts[at]
648 if (!part) return 'the conversation'
649 if (part.role === 'user') return `my message "${snippet(text.slice(part.start, part.end))}"`
650 if (part.role === 'plan') return 'your plan'
651 if (part.role === 'tool') return `your tool call ${snippet(text.slice(part.start, part.end).split('\n')[0]!)}`
652 if (part === lastReplyPart(parts)) return 'your last reply'
653 const prompt = [...parts.slice(0, at)].reverse().find(p => p.role === 'user')
654 return prompt ? `your reply to "${snippet(text.slice(prompt.start, prompt.end))}"` : 'an earlier reply of yours'
655}
656
657/**
658 * The one prompt that carries every comment, each under the span it is about.
659 * Given the conversation, comments outside Claude's last reply say which
660 * message they are on.
661 */
662export function buildPrompt(
663 comments: readonly ReplyComment[],
664 overall: string,
665 convo: Conversation = { text: '', parts: [], marks: [] },
666): string {
667 const sorted = [...comments].sort((a, b) => a.start - b.start)
668 const parts: string[] = []
669
670 if (sorted.length > 0) {
671 const places = sorted.map(c => (convo.parts.length > 0 ? whereIs(c, convo.text, convo.parts) : 'your last reply'))
672 const isAllLast = places.every(p => p === 'your last reply')
673 const about = isAllLast ? 'your last reply' : 'our conversation'
674 parts.push(
675 sorted.length === 1
676 ? `I left a comment on part of ${about}:`
677 : `I left ${sorted.length} comments on parts of ${about}:`,
678 )
679 sorted.forEach((c, i) =>
680 parts.push(`${i + 1}. On this part${isAllLast ? '' : ` of ${places[i]}`}:\n${quote(c.quote)}\n\n${c.text}`),
681 )
682 }
683 if (overall.trim()) {
684 parts.push(sorted.length > 0 ? `Overall: ${overall.trim()}` : overall.trim())
685 }
686
687 return parts.join('\n\n')
688}
689
690// ---------- the legend ----------
691
692/** The keys, by group, as the legend lists them. */
693export const LEGEND: readonly { group: string; text: string }[] = [
694 { group: 'Move', text: 'h j k l · w b e ge (W B E gE) · 0 ^ $ · gg G · { } · H M L · f t F T ; , · ]m [m next/prev message · counts (3j) · ctrl-e/y/u/f: README' },
695 { group: 'Select', text: 'v V · o other end · text objects iw aw is ip ap i( a( i" a" … · Esc cancels' },
696 { group: 'Comment', text: 'c on a selection · c{motion}: cip ciw cc c$ C · o overall · r revise · x delete · ]c [c next/prev' },
697 { group: 'Search', text: '/text then Enter · n N' },
698 { group: 'Finish', text: ':s send · :p to prompt · :clear · q close · zz zt zb scroll · ? hide keys' },
699]
700
701/** The group-name column, its gap included. */
702const GROUP_COLUMN = 9
703
704/** The legend broken into rows of at most `width` cells, each row a group name (or '') and text. */
705export function legendLines(width: number): { group: string; text: string }[] {
706 const room = Math.max(10, width - GROUP_COLUMN)
707 const rows: { group: string; text: string }[] = []
708 for (const { group, text } of LEGEND) {
709 let row = ''
710 let first = true
711 for (const part of text.split(' · ')) {
712 const next = row ? `${row} · ${part}` : part
713 if (row && next.length > room) {
714 rows.push({ group: first ? group : '', text: row })
715 first = false
716 row = part
717 } else {
718 row = next
719 }
720 }
721 rows.push({ group: first ? group : '', text: row })
722 }
723 return rows
724}
725
726export { GROUP_COLUMN }
727
728// ---------- a conversation that changed under the pane ----------
729
730/**
731 * Maps offsets in `before` to `after` when the text changed in one region (a
732 * tool result landing above the newest row, a rewrite): offsets ahead of the
733 * change stay, offsets past it shift, offsets inside it map to undefined.
734 */
735export function offsetMapper(before: string, after: string): (offset: number) => number | undefined {
736 let head = 0
737 const shortest = Math.min(before.length, after.length)
738 while (head < shortest && before[head] === after[head]) head++
739 let tail = 0
740 while (tail < shortest - head && before[before.length - 1 - tail] === after[after.length - 1 - tail]) tail++
741 const delta = after.length - before.length
742 return offset => (offset <= head ? offset : offset >= before.length - tail ? offset + delta : undefined)
743}
744
745/**
746 * The comments carried over to a changed conversation: moved with the text
747 * around them, or, where their text itself changed, found again by their
748 * quote (the occurrence nearest where they were). A quote no longer in the
749 * text drops its comment.
750 */
751export function remapComments(comments: readonly ReplyComment[], before: string, after: string): ReplyComment[] {
752 const map = offsetMapper(before, after)
753 const kept: ReplyComment[] = []
754 for (const c of comments) {
755 const start = map(c.start)
756 const end = map(c.end)
757 if (start !== undefined && end !== undefined && after.slice(start, end).includes(c.quote)) {
758 kept.push({ ...c, start, end })
759 continue
760 }
761 let best = -1
762 for (let at = after.indexOf(c.quote); at >= 0; at = after.indexOf(c.quote, at + 1)) {
763 if (best < 0 || Math.abs(at - c.start) < Math.abs(best - c.start)) best = at
764 }
765 if (best >= 0) kept.push({ ...c, start: best, end: best + c.quote.length })
766 }
767 return kept
768}
769hooks/reader.tsx 459 lines1import type { ClientModule, ClientPointerEvent, ClientSurface } from 'claude-code'
2
3import type { ReplyComment, ReplyCommentsMark, ReplyCommentsPart, ReplyCommentsRelayed, ReplyCommentsView } from '../types'
4import { commentAt, lastClaudePart, lineOf, offsetAt, searchMatches, selectionRange, wrap } from './lib'
5import type { Line } from './lib'
6import { buttonAction, caretOf, escape, initialState, scrollBy, scrolled, selection, step, submitInput, viewOf } from './vim'
7import type { Action, Layout, StepResult, VimState } from './vim'
8
9/** What the hooks module hands the reader. */
10export type ReaderProps = {
11 /** The conversation as one text. */
12 reply: string
13 parts: ReplyCommentsPart[]
14 marks: ReplyCommentsMark[]
15 comments: ReplyComment[]
16 overall: string
17 rev: number
18 /** Keys typed in the pane's key field, newest last; applied once each, by `seq`. */
19 relay: ReplyCommentsRelayed[]
20 /** Whether the conversation goes on above / below this window of it. */
21 before?: boolean
22 after?: boolean
23 /** Comments outside this window, counted in the status row. */
24 elsewhere?: number
25 /** Where the cursor goes when the window has moved, local to it. */
26 cursor?: number
27}
28
29/** What the reader posts back: the whole draft and its own view, each time. */
30export type ReaderPost = {
31 comments: ReplyComment[]
32 overall: string
33 view: ReplyCommentsView
34 action?: Action | 'window'
35 /** With `window`: the cursor, local, to center the next window on. */
36 at?: number
37 /** With `window`: go to the conversation's very start or end (`gg`, `G`). */
38 edge?: 'start' | 'end'
39 /** The cursor, local to the window, so a window moved for new text keeps it. */
40 cursor?: number
41}
42
43type ReaderState = VimState & { relaySeq: number; isDragging: boolean; mouse?: true }
44
45/** The status row under the text. */
46const FOOTER_ROWS = 1
47/** The `❯` / `⏺` column left of the text, as the transcript draws it. */
48const GUTTER = 2
49/** How near the window's edge (a share of its text) the cursor comes before the window moves. */
50const EDGE_SHARE = 0.15
51
52// The instance's live state: handlers read it so keys pressed faster than a
53// frame build on each other instead of on the last drawn state.
54let live: ReaderState | undefined
55let liveSurface: unknown
56let layout: Layout = { columns: 80, rows: 10 }
57
58const newestSeq = (relay: readonly ReplyCommentsRelayed[] | undefined) =>
59 (relay ?? []).reduce((n, r) => Math.max(n, r.seq), 0)
60
61/**
62 * Where a new annotation opens: the cursor on Claude's last reply or plan, the window
63 * at the bottom of the conversation as the transcript sits, or at the reply's
64 * start when the reply is taller than the window.
65 */
66function opening(props: ReaderProps, at: Layout): { cursor: number; top: number } {
67 const lines = wrap(props.reply, at.columns)
68 if (props.cursor !== undefined) {
69 // the window moved: the cursor stays where it was, mid-screen
70 const cursor = Math.max(0, Math.min(props.cursor, Math.max(0, props.reply.length - 1)))
71 const row = lineOf(lines, cursor)
72 return { cursor, top: Math.max(0, Math.min(row - Math.floor(at.rows / 2), lines.length - at.rows)) }
73 }
74 const cursor = Math.max(0, lastClaudePart(props.parts ?? [])?.start ?? 0)
75 const row = lineOf(lines, cursor)
76 return { cursor, top: Math.max(0, Math.min(row - 1, lines.length - at.rows)) }
77}
78
79/**
80 * The conversation grew while the pane was open: the new text is appended and
81 * the cursor, selection and comments stay. A view that was showing the end
82 * follows it down, as the transcript does, while the cursor stays on screen.
83 */
84function grow(s: ReaderState, reply: string, at: Layout, starts: number[]): ReaderState {
85 const before = wrap(s.reply, at.columns)
86 const after = wrap(reply, at.columns)
87 const wasAtEnd = s.top + at.rows >= before.length
88 const grown = { ...s, reply, starts }
89 if (!wasAtEnd) return grown
90 const top = Math.max(0, after.length - at.rows)
91 return lineOf(after, s.cursor) >= top ? { ...grown, top } : grown
92}
93
94/** Where each message in the window starts (one cut by the window's start has none). */
95const startsOf = (props: ReaderProps) => (props.parts ?? []).filter(p => p.start >= 0).map(p => p.start)
96
97/**
98 * Whether the reader should ask for another window: `gg` / `G` with more of
99 * the conversation beyond, or the cursor near an edge the conversation goes on
100 * past. Only in normal mode, so a selection or an input is never cut short.
101 */
102function windowAsk(prev: ReaderState, next: ReaderState, key: string, props: ReaderProps): Pick<ReaderPost, 'at' | 'edge'> | undefined {
103 if (next.mode !== 'normal' || next.pending !== '') return undefined
104 if (key === 'g' && prev.pending === 'g' && props.before) return { edge: 'start' }
105 if (key === 'G' && prev.pending === '' && props.after) return { edge: 'end' }
106 const edge = next.reply.length * EDGE_SHARE
107 if (next.cursor === prev.cursor) {
108 // ]m / [m with no further message in this window: bring in the next piece
109 if (key === 'm' && prev.pending.endsWith(']') && props.after) return { at: next.reply.length - 1 }
110 if (key === 'm' && prev.pending.endsWith('[') && props.before) return { at: 0 }
111 return undefined
112 }
113 if ((props.before && next.cursor < edge) || (props.after && next.cursor > next.reply.length - edge)) return { at: next.cursor }
114 return undefined
115}
116
117// The rev a window was asked for under, so it is asked for once.
118let askedRev = -1
119
120function fresh(props: ReaderProps, prev: ReaderState | undefined, isSameInstance: boolean, at: Layout): ReaderState {
121 const base = initialState(props.reply, props.comments, props.overall, props.rev)
122 const keep = prev !== undefined && prev.reply === props.reply
123 const open = keep ? undefined : opening(props, at)
124 return {
125 ...base,
126 cursor: open ? open.cursor : Math.min(prev!.cursor, Math.max(0, props.reply.length - 1)),
127 top: open ? open.top : prev!.top,
128 search: keep ? prev.search : '',
129 elsewhere: props.elsewhere ?? 0,
130 starts: startsOf(props),
131 relaySeq: Math.max(isSameInstance ? (prev?.relaySeq ?? 0) : 0, newestSeq(props.relay)),
132 isDragging: false,
133 }
134}
135
136/** Applies one step to the reader's state, keeping its own fields. */
137const apply = (s: ReaderState, r: StepResult): { state: ReaderState; action?: Action } => ({
138 state: { ...s, ...r.state },
139 action: r.action,
140})
141
142const Reader: ClientModule<ReaderProps, ReaderState> = (props, surface) => {
143 // The surface object is the instance: the same one comes back on every call.
144 const isSameInstance = surface === liveSurface
145 const columns = Math.max(10, (surface.columns > 0 ? surface.columns : 80) - GUTTER)
146 const rows = Math.max(1, (surface.rows > 0 ? surface.rows : 12) - FOOTER_ROWS)
147 layout = { columns, rows }
148
149 if (!isSameInstance || live === undefined || live.rev !== props.rev) {
150 live = fresh(props, isSameInstance ? live : surface.state, isSameInstance, layout)
151 liveSurface = surface
152 } else if (props.reply !== live.reply) {
153 // grown at the end: appended in place; changed any other way: re-seeded, so
154 // the text drawn and the parts and marks it is drawn with never disagree
155 live = props.reply.startsWith(live.reply)
156 ? grow(live, props.reply, layout, startsOf(props))
157 : fresh(props, undefined, true, layout)
158 }
159
160 const commit = (next: ReaderState, action?: Action, ask?: Pick<ReaderPost, 'at' | 'edge'>) => {
161 live = next
162 surface.setState(next)
163 const view = viewOf(next)
164 if (next.mouse) view.mouse = true
165 if (next.mode === 'normal' && commentAt(next.comments, next.cursor) >= 0) view.onComment = true
166 const data: ReaderPost = { comments: next.comments, overall: next.overall, view, cursor: next.cursor }
167 if (action) data.action = action
168 else if (ask && askedRev !== next.rev) {
169 askedRev = next.rev
170 Object.assign(data, { action: 'window' }, ask)
171 }
172 surface.post(data)
173 }
174
175 // Keys typed in the pane's key field arrive as props: apply each new one once.
176 const pending = (props.relay ?? []).filter(r => r.seq > live!.relaySeq)
177 if (pending.length > 0) {
178 let s = live
179 let action: Action | undefined
180 let ask: Pick<ReaderPost, 'at' | 'edge'> | undefined
181 for (const r of pending) {
182 const before = s
183 const result =
184 r.key === 'submit'
185 ? apply(s, submitInput(s, r.text ?? '', layout))
186 : r.key === 'button'
187 ? apply(s, buttonAction(s, r.text ?? '', layout))
188 : r.key === 'escape'
189 ? { state: { ...s, ...escape(s) } }
190 : r.key === 'wheel'
191 ? { state: { ...s, ...scrollBy(s, layout, Number(r.text) || 0) } }
192 : apply(s, step(s, r.ctrl ? { key: r.key, ctrl: true } : { key: r.key }, 'pane', layout))
193 s = { ...result.state, relaySeq: r.seq }
194 action = result.action ?? action
195 ask = windowAsk(before, s, r.key, props) ?? ask
196 }
197 commit(s, action, ask)
198 }
199
200 surface.onKey(event => {
201 if (!live) return
202 const result = apply(live, step(live, event, 'reader', layout))
203 commit(result.state, result.action, windowAsk(live, result.state, event.key, props))
204 })
205 surface.onPointer(event => {
206 if (!live) return
207 const next = onPointer(live, event)
208 if (next !== live) commit(next)
209 })
210
211 return draw(surface, live, props, columns, rows)
212}
213
214export default Reader
215
216// ---------- mouse ----------
217
218export function onPointer(s: ReaderState, event: ClientPointerEvent): ReaderState {
219 if (!s.reply) return s
220 if (event.button !== undefined && event.button !== 'left') return s
221 // the first press tells the pane a mouse is in use, and it shows its buttons
222 if (event.type === 'down' && !s.mouse) s = { ...s, mouse: true }
223 // A press while a comment (or a search or command) is open drops it, as Esc
224 // does: a click just leaves it, a drag selects a new span to comment on.
225 // a click on a draft typed in place puts the caret there
226 if (s.mode === 'input' && s.via === 'reader' && event.type === 'down' && event.y === layout.rows) {
227 const shown = draftView(s, layout.columns + GUTTER)
228 const col = event.x - shown.textStart
229 return col < 0 ? s : { ...s, caret: Math.min([...s.draft].length, shown.start + col) }
230 }
231 if (s.mode === 'input') {
232 if (event.type !== 'down' || event.y >= layout.rows) return s
233 s = { ...s, ...escape(s) }
234 }
235
236 const lines = wrap(s.reply, layout.columns)
237 const rowAt = (y: number) => s.top + Math.max(-1, Math.min(layout.rows, y))
238 const at = (y: number, x: number) => offsetAt(lines, rowAt(y), Math.max(0, x - GUTTER))
239
240 if (event.type === 'down') {
241 if (event.y >= layout.rows) return s
242 const pos = at(event.y, event.x)
243 return { ...s, mode: 'visual', pending: '', anchor: pos, cursor: pos, want: -1, isDragging: true, flash: undefined }
244 }
245 if (event.type === 'move' && s.isDragging) {
246 return { ...s, ...scrolled({ ...s, cursor: at(event.y, event.x) }, layout, lines) }
247 }
248 if (event.type === 'up' && s.isDragging) {
249 const pos = at(event.y, event.x)
250 const done: ReaderState = { ...s, cursor: pos, isDragging: false }
251 if (pos === s.anchor) return { ...done, mode: 'normal' }
252 const r = selectionRange(s.reply, s.anchor, pos, false)
253 if (r.end <= r.start) return { ...done, mode: 'normal' }
254 return { ...done, mode: 'input', inputKind: 'comment', via: 'reader', target: r, draft: '', caret: undefined, cursor: r.start }
255 }
256 return s
257}
258
259// ---------- drawing ----------
260
261type Style = 'plain' | 'cursor' | 'selected' | 'commented' | 'match' | ReplyCommentsMark['style']
262
263function styleProps(style: Style) {
264 switch (style) {
265 case 'cursor':
266 return { backgroundColor: 'cyan', color: 'black' }
267 case 'selected':
268 return { inverse: true }
269 case 'commented':
270 return { color: 'yellow', underline: true }
271 case 'match':
272 return { color: 'cyan', underline: true }
273 case 'bold':
274 case 'heading':
275 return { bold: true }
276 case 'code':
277 return { color: 'blue' }
278 case 'dim':
279 return { dimColor: true }
280 default:
281 return {}
282 }
283}
284
285const printable = (t: string) => t.replace(/[\u0000-\u001f\u007f]/g, ' ')
286
287function draw(surface: ClientSurface<ReaderState>, s: ReaderState, props: ReaderProps, columns: number, rows: number) {
288 const { Box, Text } = surface.elements
289
290 if (!s.reply) {
291 return (
292 <Box flexDirection="column">
293 <Text dimColor>No reply to annotate.</Text>
294 </Box>
295 )
296 }
297
298 const lines = wrap(s.reply, columns)
299 const top = Math.max(0, Math.min(s.top, Math.max(0, lines.length - rows)))
300 const sel = selection(s)
301 const showCursor = s.mode !== 'input'
302 const visible = lines.slice(top, top + rows)
303 const from = visible[0]?.start ?? 0
304 const to = visible[visible.length - 1]?.end ?? 0
305 const matchLen = s.search.length
306 const matches = searchMatches(s.reply, s.search).filter(at => at + matchLen > from && at < to)
307 const marks = (props.marks ?? []).filter(m => m.end > from && m.start < to)
308 const parts = props.parts ?? []
309
310 const styleAt = (offset: number): Style => {
311 if (showCursor && offset === s.cursor) return 'cursor'
312 if (sel && offset >= sel.start && offset < sel.end) return 'selected'
313 if (commentAt(s.comments, offset) >= 0) return 'commented'
314 if (matches.some(at => offset >= at && offset < at + matchLen)) return 'match'
315 let mark: Style = 'plain'
316 for (const m of marks) if (offset >= m.start && offset < m.end) mark = m.style
317 return mark
318 }
319
320 // The transcript's gutter: `❯` on a prompt, `⏺` on a reply block or a tool call.
321 const gutter = (line: Line) => {
322 const part = parts.find(p => p.start === line.start)
323 if (!part) return <Text>{' '}</Text>
324 if (part.role === 'user') return <Text dimColor>{'❯ '}</Text>
325 if (part.role === 'tool') return <Text color={part.isError ? 'red' : 'green'}>{'⏺ '}</Text>
326 if (part.role === 'plan') return <Text color="cyan">{'⏺ '}</Text>
327 return <Text>{'⏺ '}</Text>
328 }
329
330 // Every row is exactly one cell tall, so nothing below it ever moves.
331 const textRows = visible.map(line => {
332 if (line.end === line.start) {
333 return (
334 <Box flexDirection="row" height={1} overflow="hidden">
335 {gutter(line)}
336 <Text {...styleProps(showCursor && s.cursor === line.start ? 'cursor' : 'plain')}> </Text>
337 </Box>
338 )
339 }
340 const segments: { style: Style; text: string }[] = []
341 for (let o = line.start; o < line.end; o++) {
342 const style = styleAt(o)
343 const last = segments[segments.length - 1]
344 if (last && last.style === style) last.text += s.reply[o]
345 else segments.push({ style, text: s.reply[o]! })
346 }
347 return (
348 <Box flexDirection="row" height={1} overflow="hidden">
349 {gutter(line)}
350 {segments.map(seg => (
351 <Text {...styleProps(seg.style)} wrap="truncate-end">
352 {printable(seg.text)}
353 </Text>
354 ))}
355 </Box>
356 )
357 })
358 while (textRows.length < rows) {
359 textRows.push(
360 <Box height={1}>
361 <Text dimColor>~</Text>
362 </Box>,
363 )
364 }
365
366 return (
367 <Box flexDirection="column">
368 {textRows}
369 {statusRow(surface, s, lines, top, rows, props.elsewhere ?? 0)}
370 </Box>
371 )
372}
373
374const MODE_LABEL: Record<VimState['mode'], string> = {
375 normal: 'NORMAL',
376 visual: 'VISUAL',
377 vline: 'V-LINE',
378 input: 'INPUT',
379}
380
381const labelOf = (s: ReaderState) =>
382 s.mode !== 'input' ? MODE_LABEL[s.mode] : s.inputKind === 'comment' ? 'COMMENT' : s.inputKind === 'search' ? 'SEARCH' : 'COMMAND'
383const prefixOf = (s: ReaderState) => (s.inputKind === 'search' ? '/' : s.inputKind === 'command' ? ':' : '> ')
384
385/**
386 * The draft typed in place as the status row shows it in `width` columns:
387 * the window of it kept around the caret, with '…' when its start is cut,
388 * and the column its first shown character sits at.
389 */
390export function draftView(s: ReaderState, width: number) {
391 const chars = [...printable(s.draft)]
392 const caret = caretOf(s)
393 // the label (' COMMENT '), a gap, then the prefix
394 const head = labelOf(s).length + 3 + prefixOf(s).length
395 const room = Math.max(4, width - head)
396 let start = 0
397 if (chars.length + 1 > room) start = Math.max(0, Math.min(caret - room + 2, chars.length + 2 - room))
398 const lead = start > 0 ? '…' : ''
399 const visible = chars.slice(start, start + room - lead.length)
400 const at = caret - start
401 return {
402 start,
403 textStart: head + lead.length,
404 lead,
405 before: visible.slice(0, at).join(''),
406 at: visible[at] ?? ' ',
407 after: visible.slice(at + 1).join(''),
408 }
409}
410
411function statusRow(surface: ClientSurface<ReaderState>, s: ReaderState, lines: readonly Line[], top: number, rows: number, elsewhere: number) {
412 const { Box, Text } = surface.elements
413 const label = labelOf(s)
414 const total = s.comments.length + elsewhere
415 const count = `${total} comment${total === 1 ? '' : 's'}${s.overall.trim() ? ' + overall' : ''}`
416 const where = `${lineOf(lines, s.cursor) + 1}/${lines.length}${top + rows < lines.length ? ' ↓' : ''}`
417
418 let info = ''
419 let color: string | undefined
420 if (s.mode === 'input') {
421 info = s.via === 'pane' ? 'type below · Enter finishes · Esc cancels' : ''
422 } else if (s.flash) {
423 info = s.flash
424 color = 'green'
425 } else {
426 const here = commentAt(s.comments, s.cursor)
427 if (here >= 0) {
428 info = `💬 ${printable(s.comments[here]!.text)}`
429 color = 'yellow'
430 }
431 }
432 const right = s.mode === 'input' ? '' : `${s.pending ? `${s.pending} ` : ''}${count} · ${where}`
433
434 return (
435 <Box flexDirection="row" height={1} overflow="hidden" gap={1}>
436 <Text bold inverse>{` ${label} `}</Text>
437 {s.mode === 'input' && s.via === 'reader' ? (
438 (() => {
439 const d = draftView(s, surface.columns > 0 ? surface.columns : 80)
440 return (
441 <Box flexDirection="row" flexGrow={1} flexShrink={1} overflow="hidden">
442 <Text>{prefixOf(s) + d.lead + d.before}</Text>
443 <Text inverse>{d.at}</Text>
444 <Text wrap="truncate-end">{d.after}</Text>
445 </Box>
446 )
447 })()
448 ) : (
449 <Box flexGrow={1} flexShrink={1} overflow="hidden">
450 <Text color={color} dimColor={!color && s.mode === 'input' && s.via === 'pane'} wrap="truncate-end">
451 {info || ' '}
452 </Text>
453 </Box>
454 )}
455 {right ? <Text dimColor>{right}</Text> : null}
456 </Box>
457 )
458}
459hooks/vim.ts 568 lines1import type { ReplyComment, ReplyCommentsView } from '../types'
2import {
3 TEXT_OBJECTS,
4 commentAt,
5 findChar,
6 firstNonBlank,
7 lastOf,
8 lineEnd,
9 lineLast,
10 lineOf,
11 lineStart,
12 nthLine,
13 offsetAt,
14 paragraphMove,
15 searchFrom,
16 selectionRange,
17 textObject,
18 trimRange,
19 wordBackward,
20 wordEnd,
21 wordEndBackward,
22 wordForward,
23 wrap,
24} from './lib'
25import type { FindCommand, Line, Range } from './lib'
26
27/** The reader's size: text columns and the rows the text shows in. */
28export type Layout = { columns: number; rows: number }
29
30export type Mode = ReplyCommentsView['mode']
31export type InputKind = NonNullable<ReplyCommentsView['inputKind']>
32export type Via = ReplyCommentsView['via']
33
34/** What a comment being written is about. */
35export type Target = Range | { overall: true } | { edit: number }
36
37export type VimState = {
38 rev: number
39 reply: string
40 mode: Mode
41 inputKind?: InputKind
42 via: Via
43 cursor: number
44 anchor: number
45 /** The display column `j` and `k` aim for; -1 takes the cursor's own. */
46 want: number
47 target?: Target
48 draft: string
49 /** Where typing in place goes in the draft, in characters; absent is the end. */
50 caret?: number
51 /** Keys typed toward a command not yet complete (`g`, `ci`, `3`). */
52 pending: string
53 lastFind?: { cmd: FindCommand; ch: string }
54 search: string
55 comments: ReplyComment[]
56 overall: string
57 top: number
58 flash?: string
59 /** Comments held outside the text this state covers (a window of a longer conversation). */
60 elsewhere?: number
61 /** Where each message of the conversation starts, for `]m` / `[m`. */
62 starts?: number[]
63}
64
65/** What the pane does on the reader's behalf. */
66export type Action = 'send' | 'fill' | 'close' | 'clear' | 'legend'
67
68export type StepResult = { state: VimState; action?: Action }
69
70/** A key as the reader's keyboard or the pane's key field hands it. */
71export type KeyIn = { key: string; ctrl?: true; meta?: true }
72
73export function initialState(reply: string, comments: ReplyComment[], overall: string, rev: number): VimState {
74 return {
75 rev,
76 reply,
77 mode: 'normal',
78 via: 'reader',
79 cursor: 0,
80 anchor: 0,
81 want: -1,
82 draft: '',
83 pending: '',
84 search: '',
85 comments,
86 overall,
87 top: 0,
88 }
89}
90
91export function viewOf(s: VimState): ReplyCommentsView {
92 const target = s.target && 'overall' in s.target ? 'overall' : s.target && 'edit' in s.target ? 'edit' : 'comment'
93 const view: ReplyCommentsView = {
94 mode: s.mode,
95 via: s.via,
96 target,
97 draft: s.mode === 'input' ? s.draft : '',
98 pending: s.pending,
99 }
100 if (s.mode === 'input' && s.inputKind) view.inputKind = s.inputKind
101 return view
102}
103
104// ---------- layout ----------
105
106const linesOf = (s: VimState, layout: Layout) => wrap(s.reply, layout.columns)
107
108/** Keeps the cursor's row inside the window, scrolling no more than needed. */
109export function scrolled(s: VimState, layout: Layout, lines: readonly Line[] = linesOf(s, layout)): VimState {
110 const row = lineOf(lines, s.cursor)
111 let top = s.top
112 if (row < top) top = row
113 if (row >= top + layout.rows) top = row - layout.rows + 1
114 top = Math.max(0, Math.min(top, Math.max(0, lines.length - layout.rows)))
115 return top === s.top ? s : { ...s, top }
116}
117
118/** Moves the window `rows` down (negative: up), the cursor kept on screen, as vim's `ctrl-e` / `ctrl-y`. */
119export function scrollBy(s: VimState, layout: Layout, rows: number): VimState {
120 const lines = linesOf(s, layout)
121 const maxTop = Math.max(0, lines.length - layout.rows)
122 const top = Math.max(0, Math.min(maxTop, s.top + rows))
123 const row = lineOf(lines, s.cursor)
124 const col = s.cursor - lines[row]!.start
125 const kept = Math.max(top, Math.min(top + layout.rows - 1, row))
126 return { ...s, top, cursor: kept === row ? s.cursor : offsetAt(lines, kept, col) }
127}
128
129// ---------- motions ----------
130
131type Motion = { to: number; kind: 'exclusive' | 'inclusive' | 'linewise'; want?: number; find?: VimState['lastFind'] }
132
133const PREFIXES = new Set(['g', ']', '[', 'f', 'F', 't', 'T', 'z'])
134
135/** The motion `keys` names from the cursor, 'pending' while it needs more keys, or undefined. */
136export function motion(s: VimState, keys: string, count: number | undefined, layout: Layout): Motion | 'pending' | undefined {
137 const t = s.reply
138 const n = count ?? 1
139 const lines = linesOf(s, layout)
140 const row = lineOf(lines, s.cursor)
141 const col = s.want >= 0 ? s.want : s.cursor - lines[row]!.start
142 const repeat = (f: (i: number) => number) => {
143 let i = s.cursor
144 for (let k = 0; k < n; k++) i = f(i)
145 return i
146 }
147
148 switch (keys) {
149 case 'h':
150 return { to: Math.max(lineStart(t, s.cursor), s.cursor - n), kind: 'exclusive' }
151 case 'l':
152 return { to: Math.min(lineLast(t, s.cursor), s.cursor + n), kind: 'exclusive' }
153 case 'j':
154 return { to: offsetAt(lines, row + n, col), kind: 'linewise', want: col }
155 case 'k':
156 return { to: offsetAt(lines, row - n, col), kind: 'linewise', want: col }
157 case 'w':
158 case 'W':
159 return { to: repeat(i => wordForward(t, i, keys === 'W')), kind: 'exclusive' }
160 case 'b':
161 case 'B':
162 return { to: repeat(i => wordBackward(t, i, keys === 'B')), kind: 'exclusive' }
163 case 'e':
164 case 'E':
165 return { to: repeat(i => wordEnd(t, i, keys === 'E')), kind: 'inclusive' }
166 case 'ge':
167 case 'gE':
168 return { to: repeat(i => wordEndBackward(t, i, keys === 'gE')), kind: 'inclusive' }
169 case '0':
170 return { to: lineStart(t, s.cursor), kind: 'exclusive' }
171 case '^':
172 return { to: firstNonBlank(t, s.cursor), kind: 'exclusive' }
173 case '$': {
174 let i = s.cursor
175 for (let k = 1; k < n; k++) i = Math.min(t.length - 1, lineEnd(t, i) + 1)
176 return { to: lineLast(t, i), kind: 'inclusive', want: Number.MAX_SAFE_INTEGER }
177 }
178 case 'gg':
179 return { to: firstNonBlank(t, count ? nthLine(t, count) : 0), kind: 'linewise' }
180 case 'G':
181 return { to: firstNonBlank(t, count ? nthLine(t, count) : t.length - 1), kind: 'linewise' }
182 case '}':
183 return { to: repeat(i => paragraphMove(t, i, 1)), kind: 'exclusive' }
184 case '{':
185 return { to: repeat(i => paragraphMove(t, i, -1)), kind: 'exclusive' }
186 case 'H':
187 return { to: offsetAt(lines, s.top + (count ? count - 1 : 0), 0), kind: 'linewise' }
188 case 'M':
189 return { to: offsetAt(lines, s.top + Math.floor((Math.min(layout.rows, lines.length - s.top) - 1) / 2), 0), kind: 'linewise' }
190 case 'L':
191 return { to: offsetAt(lines, Math.min(lines.length, s.top + layout.rows) - 1 - (count ? count - 1 : 0), 0), kind: 'linewise' }
192 case ';':
193 case ',': {
194 if (!s.lastFind) return undefined
195 const flip: Record<FindCommand, FindCommand> = { f: 'F', F: 'f', t: 'T', T: 't' }
196 const cmd = keys === ';' ? s.lastFind.cmd : flip[s.lastFind.cmd]
197 return findMotion(s, cmd, s.lastFind.ch, n, false)
198 }
199 case 'n':
200 case 'N': {
201 let i: number | undefined = s.cursor
202 for (let k = 0; k < n && i !== undefined; k++) i = searchFrom(t, i, s.search, keys === 'n' ? 1 : -1)
203 return i === undefined ? undefined : { to: i, kind: 'exclusive' }
204 }
205 case ']c':
206 case '[c':
207 case ']m':
208 case '[m': {
209 // the next / previous comment, or the start of the next / previous
210 // message (`[m` from inside a message goes to its own start first)
211 const marks = keys[1] === 'c' ? s.comments.map(c => c.start) : (s.starts ?? [])
212 let i = s.cursor
213 for (let k = 0; k < n; k++) {
214 const hit = keys[0] === ']' ? marks.find(at => at > i) : [...marks].reverse().find(at => at < i)
215 if (hit === undefined) break
216 i = hit
217 }
218 return i === s.cursor ? undefined : { to: i, kind: 'exclusive' }
219 }
220 }
221
222 if (keys.length === 1 && PREFIXES.has(keys)) return 'pending'
223 if (keys.length === 2 && 'fFtT'.includes(keys[0]!)) {
224 return findMotion(s, keys[0] as FindCommand, keys[1]!, n, true)
225 }
226 return undefined
227}
228
229function findMotion(s: VimState, cmd: FindCommand, ch: string, n: number, remember: boolean): Motion | undefined {
230 let i: number | undefined = s.cursor
231 for (let k = 0; k < n && i !== undefined; k++) i = findChar(s.reply, i, cmd, ch, !remember || k > 0)
232 if (i === undefined) return undefined
233 const m: Motion = { to: i, kind: cmd === 'f' || cmd === 't' ? 'inclusive' : 'exclusive' }
234 if (remember) m.find = { cmd, ch }
235 return m
236}
237
238/** The span an operator covers from the cursor over a motion, as vim's `c{motion}`. */
239function motionRange(s: VimState, m: Motion): Range {
240 const t = s.reply
241 let start = Math.min(s.cursor, m.to)
242 let end = Math.max(s.cursor, m.to)
243 if (m.kind === 'inclusive') end += 1
244 if (m.kind === 'linewise') {
245 start = lineStart(t, start)
246 end = lineEnd(t, end)
247 }
248 return { start, end: Math.min(end, t.length) }
249}
250
251// ---------- comments ----------
252
253function startComment(s: VimState, range: Range, via: Via): VimState {
254 const r = trimRange(s.reply, range.start, range.end)
255 if (r.end <= r.start) return { ...s, mode: 'normal', pending: '', flash: 'Nothing to comment on there' }
256 return { ...s, mode: 'input', inputKind: 'comment', via, target: r, draft: '', caret: undefined, pending: '', cursor: r.start, want: -1 }
257}
258
259function saveComment(s: VimState, text: string): VimState {
260 const body = text.trim()
261 const base: VimState = { ...s, mode: 'normal', inputKind: undefined, target: undefined, draft: '', caret: undefined }
262 const target = s.target
263 if (!target) return base
264 if ('overall' in target) {
265 return { ...base, overall: body, flash: body ? 'Overall comment saved' : 'Overall comment removed' }
266 }
267 if ('edit' in target) {
268 const comments = body
269 ? s.comments.map((c, i) => (i === target.edit ? { ...c, text: body } : c))
270 : s.comments.filter((_, i) => i !== target.edit)
271 return { ...base, comments, flash: body ? 'Comment updated' : 'Comment removed' }
272 }
273 if (!body) return { ...base, flash: 'Empty comment discarded' }
274 const comment: ReplyComment = { start: target.start, end: target.end, quote: s.reply.slice(target.start, target.end), text: body }
275 const comments = [...s.comments, comment].sort((a, b) => a.start - b.start)
276 return { ...base, comments, flash: `Comment ${comments.length} saved` }
277}
278
279function runCommand(s: VimState, text: string, layout: Layout): StepResult {
280 const cmd = text.trim()
281 const base: VimState = { ...s, mode: 'normal', inputKind: undefined, draft: '', caret: undefined }
282 const hasAny = s.comments.length > 0 || (s.elsewhere ?? 0) > 0 || s.overall.trim() !== ''
283 if (/^\d+$/.test(cmd)) return { state: scrolled({ ...base, cursor: firstNonBlank(s.reply, nthLine(s.reply, Number(cmd))) }, layout) }
284 switch (cmd) {
285 case '':
286 return { state: base }
287 case 's':
288 case 'w':
289 case 'wq':
290 case 'x':
291 case 'send':
292 return hasAny ? { state: base, action: 'send' } : { state: { ...base, flash: 'Nothing to send yet' } }
293 case 'p':
294 case 'prompt':
295 return hasAny ? { state: base, action: 'fill' } : { state: { ...base, flash: 'Nothing to put in the prompt yet' } }
296 case 'q':
297 case 'q!':
298 case 'close':
299 return { state: base, action: 'close' }
300 case 'clear':
301 return { state: base, action: 'clear' }
302 case 'h':
303 case 'help':
304 return { state: base, action: 'legend' }
305 default:
306 return { state: { ...base, flash: `Not a command: :${cmd}` } }
307 }
308}
309
310/** The text a pane field (or the reader, in place) handed over for the input under way. */
311/** What a pane button does, as the key or command it stands for. */
312export function buttonAction(s: VimState, act: string, layout: Layout): StepResult {
313 const command = (cmd: string) => submitInput({ ...s, mode: 'input', inputKind: 'command', draft: cmd, caret: undefined }, cmd, layout)
314 switch (act) {
315 case 'send':
316 return command('s')
317 case 'fill':
318 return command('p')
319 case 'close':
320 return command('q')
321 case 'save':
322 return submitInput(s, s.draft, layout)
323 case 'cancel':
324 return { state: escape(s) }
325 default:
326 return { state: s }
327 }
328}
329
330export function submitInput(s: VimState, text: string, layout: Layout): StepResult {
331 if (s.mode !== 'input') return { state: s }
332 if (s.inputKind === 'comment') return { state: saveComment(s, text) }
333 if (s.inputKind === 'command') return runCommand(s, text, layout)
334 const pattern = text || s.search
335 const base: VimState = { ...s, mode: 'normal', inputKind: undefined, draft: '', caret: undefined, search: pattern }
336 const at = pattern ? searchFrom(s.reply, s.cursor, pattern, 1) : undefined
337 if (at === undefined) return { state: { ...base, flash: pattern ? `Not found: ${pattern}` : '' } }
338 return { state: scrolled({ ...base, cursor: at, want: -1 }, layout) }
339}
340
341// ---------- keys ----------
342
343/** Escape: leaves an input, a selection or a half-typed command. In plain normal mode it does nothing. */
344export function escape(s: VimState): VimState {
345 if (s.mode === 'input') return { ...s, mode: 'normal', inputKind: undefined, target: undefined, draft: '', caret: undefined, pending: '', flash: 'Cancelled' }
346 if (s.mode === 'visual' || s.mode === 'vline') return { ...s, mode: 'normal', pending: '' }
347 return s.pending ? { ...s, pending: '' } : s
348}
349
350const NAMED: Record<string, string> = {
351 left: 'h', right: 'l', up: 'k', down: 'j', backspace: 'h', space: 'l', home: '0', end: '$',
352 return: '\r', enter: '\r',
353}
354
355/** Keys the surface names rather than types; typed text, even a pasted word, is anything else. */
356const NAMED_KEYS = new Set(['up', 'down', 'left', 'right', 'tab', 'escape', 'pageup', 'pagedown', 'home', 'end', 'insert', 'delete', 'backspace', 'return', 'enter', 'space'])
357
358/** The caret of a draft typed in place: a character index, the end when unset. */
359export const caretOf = (s: Pick<VimState, 'draft' | 'caret'>) => {
360 const length = [...s.draft].length
361 return s.caret === undefined ? length : Math.max(0, Math.min(length, s.caret))
362}
363
364function typeInPlace(s: VimState, ev: KeyIn, layout: Layout): StepResult {
365 const { key } = ev
366 const chars = [...s.draft]
367 const at = caretOf(s)
368 const edit = (next: string[], caret: number) => ({ state: { ...s, draft: next.join(''), caret } })
369 const move = (caret: number) => ({ state: { ...s, caret: Math.max(0, Math.min(chars.length, caret)) } })
370 if (key === 'return' || key === 'enter') return submitInput(s, s.draft, layout)
371 if (ev.ctrl) {
372 if (key === 'u') return edit([], 0)
373 if (key === 'a') return move(0)
374 if (key === 'e') return move(chars.length)
375 return { state: s }
376 }
377 if (key === 'backspace') {
378 if (chars.length === 0) return { state: escape(s) }
379 if (at === 0) return { state: s }
380 return edit([...chars.slice(0, at - 1), ...chars.slice(at)], at - 1)
381 }
382 if (key === 'delete') {
383 if (chars.length === 0) return { state: escape(s) }
384 return edit([...chars.slice(0, at), ...chars.slice(at + 1)], at)
385 }
386 if (key === 'left') return move(at - 1)
387 if (key === 'right') return move(at + 1)
388 if (key === 'home') return move(0)
389 if (key === 'end') return move(chars.length)
390 const typed = key === 'space' ? ' ' : ev.meta || NAMED_KEYS.has(key) ? '' : key.replace(/[\r\n\t]+/g, ' ')
391 if (!typed) return { state: s }
392 const added = [...typed]
393 return edit([...chars.slice(0, at), ...added, ...chars.slice(at)], at + added.length)
394}
395
396function ctrlKey(s: VimState, key: string, layout: Layout): StepResult {
397 const half = Math.max(1, Math.floor(layout.rows / 2))
398 const lines = linesOf(s, layout)
399 const row = lineOf(lines, s.cursor)
400 const col = s.cursor - lines[row]!.start
401 const jump = (rows: number) => {
402 const moved = scrollBy(s, layout, rows)
403 return { state: scrolled({ ...moved, cursor: offsetAt(lines, row + rows, col) }, layout, lines) }
404 }
405 switch (key) {
406 case 'u':
407 return jump(-half)
408 case 'f':
409 return jump(layout.rows)
410 case 'e':
411 return { state: scrollBy(s, layout, 1) }
412 case 'y':
413 return { state: scrollBy(s, layout, -1) }
414 case 'c':
415 return { state: escape(s) }
416 default:
417 return { state: s }
418 }
419}
420
421/**
422 * One key. `via` says where it came from: the reader's own keyboard (after a
423 * click) or the pane's key field. Pure: the state after it, and anything the
424 * pane must do.
425 */
426export function step(prev: VimState, ev: KeyIn, via: Via, layout: Layout): StepResult {
427 const s: VimState = prev.flash !== undefined ? { ...prev, flash: undefined } : prev
428 if (ev.key === 'escape' || (ev.ctrl && (ev.key === 'c' || ev.key === '['))) return { state: escape(s) }
429 if (s.mode === 'input') return via === 'reader' ? typeInPlace(s, ev, layout) : { state: s }
430 if (!s.reply) return { state: s }
431 if (ev.ctrl) return ctrlKey(s, ev.key, layout)
432
433 const key = NAMED[ev.key] ?? ev.key
434 if (key.length !== 1) return { state: s }
435 const seq = s.pending + key
436 const parsed = /^([1-9]\d*)?([\s\S]*)$/.exec(seq)!
437 const count = parsed[1] ? Number(parsed[1]) : undefined
438 const rest = parsed[2]!
439 if (rest === '') return { state: { ...s, pending: seq } }
440
441 const isVisual = s.mode === 'visual' || s.mode === 'vline'
442 const wait = { state: { ...s, pending: seq } }
443 const drop = (flash?: string) => ({ state: flash ? { ...s, pending: '', flash } : { ...s, pending: '' } })
444
445 // C: comment from the cursor to the end of the line ([count] lines), as c$
446 if (!isVisual && rest === 'C') {
447 let le = lineEnd(s.reply, s.cursor)
448 for (let k = 1; k < (count ?? 1); k++) le = lineEnd(s.reply, Math.min(s.reply.length, le + 1))
449 return le > s.cursor ? { state: startComment(s, { start: s.cursor, end: le }, via) } : drop('Nothing to the end of the line')
450 }
451
452 // c{motion}, cc, c{text object}: comment on that span
453 if (!isVisual && rest[0] === 'c') {
454 const after = rest.slice(1)
455 if (after === '') return wait
456 if (after === 'c') {
457 const ls = lineStart(s.reply, s.cursor)
458 let le = lineEnd(s.reply, s.cursor)
459 for (let k = 1; k < (count ?? 1); k++) le = lineEnd(s.reply, Math.min(s.reply.length, le + 1))
460 return { state: startComment(s, { start: ls, end: le }, via) }
461 }
462 if (after[0] === 'i' || after[0] === 'a') {
463 if (after.length === 1) return wait
464 const r = TEXT_OBJECTS.has(after[1]!) ? textObject(s.reply, s.cursor, after[0] === 'a', after[1]!) : undefined
465 return r ? { state: startComment(s, r, via) } : drop('No such text object here')
466 }
467 const inner = /^([1-9]\d*)?([\s\S]*)$/.exec(after)!
468 const n = (count ?? 1) * (inner[1] ? Number(inner[1]) : 1)
469 if (inner[2] === '') return wait
470 const keys = inner[2] === 'w' ? 'e' : inner[2] === 'W' ? 'E' : inner[2]!
471 const m = motion(s, keys, n === 1 && !count && !inner[1] ? undefined : n, layout)
472 if (m === 'pending') return wait
473 if (!m) return drop()
474 return { state: startComment(s, motionRange(s, m), via) }
475 }
476
477 // visual text objects: iw, ap, i( …
478 if (isVisual && (rest[0] === 'i' || rest[0] === 'a')) {
479 if (rest.length === 1) return wait
480 const r = TEXT_OBJECTS.has(rest[1]!) ? textObject(s.reply, s.cursor, rest[0] === 'a', rest[1]!) : undefined
481 if (!r || r.end <= r.start) return drop('No such text object here')
482 return { state: scrolled({ ...s, mode: 'visual', pending: '', anchor: r.start, cursor: r.end - 1, want: -1 }, layout) }
483 }
484
485 // zz zt zb
486 if (rest[0] === 'z' && rest.length === 2) {
487 const lines = linesOf(s, layout)
488 const row = lineOf(lines, s.cursor)
489 const maxTop = Math.max(0, lines.length - layout.rows)
490 const top = rest[1] === 'z' ? row - Math.floor(layout.rows / 2) : rest[1] === 't' ? row : rest[1] === 'b' ? row - layout.rows + 1 : s.top
491 return { state: { ...s, pending: '', top: Math.max(0, Math.min(maxTop, top)) } }
492 }
493
494 const m = motion(s, rest, count, layout)
495 if (m === 'pending') return wait
496 if (m) {
497 const moved: VimState = { ...s, pending: '', cursor: m.to, want: m.want ?? -1 }
498 if (m.find) moved.lastFind = m.find
499 return { state: scrolled(moved, layout) }
500 }
501
502 if (rest.length > 1) return drop()
503 return command(s, rest, via, layout)
504}
505
506function command(s0: VimState, key: string, via: Via, layout: Layout): StepResult {
507 const s: VimState = { ...s0, pending: '' }
508 const isVisual = s.mode === 'visual' || s.mode === 'vline'
509 const here = commentAt(s.comments, s.cursor)
510 const hasAny = s.comments.length > 0 || (s.elsewhere ?? 0) > 0 || s.overall.trim() !== ''
511
512 if (key === '?') return { state: s, action: 'legend' }
513
514 if (isVisual) {
515 switch (key) {
516 case 'c':
517 case '\r':
518 return { state: startComment(s, selectionRange(s.reply, s.anchor, s.cursor, s.mode === 'vline'), via) }
519 case 'o':
520 case 'O':
521 return { state: scrolled({ ...s, anchor: s.cursor, cursor: s.anchor }, layout) }
522 case 'v':
523 return { state: { ...s, mode: s.mode === 'visual' ? 'normal' : 'visual' } }
524 case 'V':
525 return { state: { ...s, mode: s.mode === 'vline' ? 'normal' : 'vline' } }
526 default:
527 return { state: s }
528 }
529 }
530
531 switch (key) {
532 case 'v':
533 return { state: { ...s, mode: 'visual', anchor: s.cursor } }
534 case 'V':
535 return { state: { ...s, mode: 'vline', anchor: s.cursor } }
536 case '\r': {
537 const m = motion(s, 'j', undefined, layout)
538 if (!m || m === 'pending') return { state: s }
539 return { state: scrolled({ ...s, cursor: firstNonBlank(s.reply, m.to), want: -1 }, layout) }
540 }
541 case 'o':
542 return { state: { ...s, mode: 'input', inputKind: 'comment', via, target: { overall: true }, draft: s.overall, caret: undefined } }
543 case 'r':
544 if (here < 0) return { state: { ...s, flash: 'No comment under the cursor' } }
545 return { state: { ...s, mode: 'input', inputKind: 'comment', via, target: { edit: here }, draft: s.comments[here]!.text, caret: undefined } }
546 case 'x':
547 if (here < 0) return { state: { ...s, flash: 'No comment under the cursor' } }
548 return { state: { ...s, comments: s.comments.filter((_, i) => i !== here), flash: 'Comment removed' } }
549 case '/':
550 return { state: { ...s, mode: 'input', inputKind: 'search', via, draft: '', caret: undefined } }
551 case ':':
552 return { state: { ...s, mode: 'input', inputKind: 'command', via, draft: '', caret: undefined } }
553 case 'q':
554 return { state: s, action: 'close' }
555 default:
556 return { state: s }
557 }
558}
559
560/** The span the reader shows selected now, if any. */
561export function selection(s: VimState): Range | undefined {
562 if (s.mode === 'visual' || s.mode === 'vline') return selectionRange(s.reply, s.anchor, s.cursor, s.mode === 'vline')
563 if (s.mode === 'input' && s.target && 'start' in s.target) return s.target
564 return undefined
565}
566
567export { lastOf }
568types/index.d.ts 101 lines1export type ReplyComment = {
2 /** Offset of the first selected character in the conversation text. */
3 start: number
4 /** Offset just past the last selected character. */
5 end: number
6 /** The selected text, trimmed. */
7 quote: string
8 /** What the person wrote about it. */
9 text: string
10}
11
12/** One message of the conversation, where it sits in the annotated text. */
13export type ReplyCommentsPart = {
14 /** The person's prompt, a block of Claude's reply text, one tool call, or a plan Claude presents. */
15 role: 'user' | 'assistant' | 'tool' | 'plan'
16 start: number
17 end: number
18 /** A tool call that ended in an error. */
19 isError?: true
20}
21
22/** A styled span of the annotated text: markdown drawn, a tool name, a result line. */
23export type ReplyCommentsMark = {
24 start: number
25 end: number
26 style: 'bold' | 'heading' | 'code' | 'dim'
27}
28
29export type ReplyCommentsDraft = {
30 /** The conversation being annotated, as one text. */
31 reply: string
32 /** Where each message sits in `reply`. */
33 parts: ReplyCommentsPart[]
34 marks: ReplyCommentsMark[]
35 comments: ReplyComment[]
36 overall: string
37 /** Bumped whenever the hooks module replaces the draft, so the reader resets. */
38 rev: number
39}
40
41/**
42 * The span of the conversation the reader is handed (its props are bounded),
43 * and where its cursor goes when the span moves.
44 */
45export type ReplyCommentsWindow = {
46 start: number
47 end: number
48 /** The cursor, as an offset into the whole conversation; absent on a new annotation. */
49 cursor?: number
50}
51
52/** One key typed in the pane's key field, handed on to the reader. */
53export type ReplyCommentsRelayed = {
54 seq: number
55 key: string
56 /** Held with Ctrl: a key pressed through a keybinding (see CTRL_ACTIONS). */
57 ctrl?: true
58 /** The text of a field, for the `submit` key; the rows, for the `wheel` key. */
59 text?: string
60}
61
62/** What the reader reports about itself, so the pane draws around it. */
63export type ReplyCommentsView = {
64 mode: 'normal' | 'visual' | 'vline' | 'input'
65 /** In input mode: a comment, a `/` search or a `:` command. */
66 inputKind?: 'comment' | 'search' | 'command'
67 /** Where the input is typed: the pane's field, or the reader itself. */
68 via: 'pane' | 'reader'
69 /** What a comment is about. */
70 target: 'comment' | 'edit' | 'overall'
71 /** The text the field starts with. */
72 draft: string
73 /** Keys typed toward a command not yet complete (`g`, `ci`, `3`). */
74 pending: string
75 /** The person has clicked or dragged in the reader: the pane shows its buttons. */
76 mouse?: true
77 /** In normal mode, the cursor is on a comment: Revise and Delete apply. */
78 onComment?: true
79}
80
81declare module 'claude-code' {
82 interface PluginState {
83 'reply-comments': {
84 draft: ReplyCommentsDraft
85 window: ReplyCommentsWindow
86 relay: ReplyCommentsRelayed[]
87 view: ReplyCommentsView
88 /** Whether the key legend is shown. */
89 legend: boolean
90 /** Once the person uses the mouse in the reader, the pane shows buttons for every basic action. */
91 mouse: boolean
92 /** What the key field holds as drawn: '' or a zero-width space, alternating so each draw clears it. */
93 keysDrawn: string
94 /** The zero-width mark the field (comment, search, command) starts with, new for each input. */
95 fieldMark: string
96 /** How many rows the field takes, grown as its text wraps. */
97 fieldRows: number
98 }
99 }
100}
101