SLOPSHOPPER

reply-comments

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

newpanecommandtoasttimer
★ 3v0.5.18MITupdated 2026-10-15Malkiz/claude-reply-comments
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · reply-comments
│ ┃ reply-comments ✕ › fix the failing auth test and add an audit log call │ ┃ ▣ client module ./reader.tsx │ ┃ ^E^Y^F^U ⏺ Read(src/auth.ts) │ ┃ keys (? help): ⏎ enter ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /annotate │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · reply-comments
▣ client module ./reader.tsx ^E^Y^F^U keys (? help): ⏎ enter
README

claude-reply-comments

Claude Code 2.1.286+ License: MIT

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.

Claude replies with a four-step plan and two questions. /annotate opens a pane beside the conversation; the cursor moves to step 1, step 3 and the first question, each gets a comment, and s sends all three as one prompt that Claude answers point by point.

[!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.

Why

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 itWith /annotate
answer several points or questions in one replyCopy or restate each one in the prompt and write under itSelect each one, comment, type :s
answer something Claude said a while agoScroll the transcript, copy, pasteJump there (]m, /, gg), select, comment
revise a plan before Claude startsDescribe the step you mean in wordsComment on the step itself
keep annotating while Claude worksWait for the turn to end, or copy passages out as they appearOpen /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.

Install

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

Use

  1. Type /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.
  2. Move with vim keys, or the mouse wheel.
  3. Select a span (v, V, a text object such as ip, or a mouse drag) and press c. Type the comment, then Enter.
  4. Repeat. o adds an overall comment.
  5. Type :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.

GroupKeys
Moveh 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)
Selectv · V · o swaps ends · text objects iw aw iW aW is as ip ap i( a( i[ i{ i< i" i' i\`…
Commentc 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:

  • Fields. 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.
  • Esc never takes you out of the pane. It leaves visual mode, a field or a half-typed command. q closes the pane.
  • The mouse. The wheel scrolls. Dragging over text selects it and starts a comment. While a comment is open, a new drag moves it to the new text, and a click drops it, as Esc does. After your first click or drag, a row of buttons appears under the conversation, so you can use the mod without learning any keys: Save and Cancel while you type a comment, Revise and Delete when you click a comment, and Overall comment, Send, To prompt and Close the rest of the time. Clicking the conversation hands it the keyboard directly, which adds the ctrl keys; the comment is then typed in place, and ctrl-c cancels it.
  • While Claude works. /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.
  • Plans. A plan Claude presents in plan mode is drawn in full. Reject the plan dialog, run /annotate, comment on the steps, and send.
  • Drafts. Unsent comments are kept when you close the pane, and are still there when you reopen it after the conversation has grown.
  • Width. The pane docks beside the transcript at the width Claude Code keeps for every pane. Drag the divider to make it wider.

What it reads and runs

  • Reads: the conversation, through the hooks API ($.session.messages()), when you run /annotate and as new rows are stored while the pane is open.
  • Writes: nothing to the conversation except the prompt you send with :s, or the text it puts in the prompt box with :p. Its hook on new rows (session.append) passes every row through unchanged.
  • The prompt it sends ($.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.
  • Commands: it registers /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.)
  • Escape key: Esc in the pane arrives as a request to close it, which the mod refuses so that Esc can leave visual mode or cancel a field; q closes the pane. It touches no permission prompt or setting.
  • Hooks, each limited to the mod's own command and pane:
  • 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.
  • Keeps: the draft and the pane's state in session state ($.state), gone when the session ends. Nothing is stored across sessions.
  • Doesn't: read or write files, run commands, call a model or make network requests.
  • Takes over: its own pane only. The transcript, the prompt box and every other component are left as they are.

Limitations

  • Early access. Mods are an early-access Claude Code surface that may change between releases. This version is checked against 2.1.286 with claude plugin validate . and claude plugin test ..
  • Terminal on macOS. Built and tested in the terminal on macOS. The desktop Code tab draws the same reader; mobile shows the conversation as plain markdown, without selection.
  • Fullscreen layout only. On the default layout, or a terminal narrower than 110 columns, Claude Code opens the pane inline above the prompt with room for only a line or two of the conversation. Switch with /tui fullscreen.
  • One width for every pane. Claude Code keeps a single dock width for all panes, and a width you've dragged wins over what a mod asks for.
  • Ctrl keys need a binding or a click. The pane catches keys through a text field, which ctrl keys don't reach. They need the keybindings from Install or a click on the conversation; there is no ctrl-d or ctrl-b.
  • Long conversations come in sections. The reader is handed about 60,000 characters at a time (a mod's pane data is capped at 100,000 characters). The section moves by itself as the cursor nears its edge, and gg / G reach the very start and end, but line numbers, :12 and / search cover the current section only.
  • Plain-text rendering. The mod renders markdown itself: headings, bold and code are styled, while tables and lists show as text. Tool calls show expanded where the transcript collapses them.

How it works

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

Develop

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.

Ideas

  • ☐ Show past comments over the conversation. Sent comments aren't lost: each reaches Claude as a prompt and stays in the session (and --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.
  • ☐ Later, threads: a comment, Claude's answer, a follow-up on the same spot, and a resolved state, as in a PR review.
  • Not planned: storing comments across sessions. A conversation doesn't outlive its session; comments on lasting artifacts (a PR, a doc) belong on the artifact.

Privacy

The mod collects nothing and sends nothing anywhere except the prompt you send to Claude. See the privacy policy.

License

MIT. See LICENSE.

Source 5 files
hooks/register.tsx 609 lines
1import { 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}
609
hooks/lib.ts 769 lines
1import 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}
769
hooks/reader.tsx 459 lines
1import 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}
459
hooks/vim.ts 568 lines
1import 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 }
568
types/index.d.ts 101 lines
1export 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