A pane beside the transcript that shows the model's newest open draft and sends your comments on it, span by span, as one prompt.

A Claude Code plugin (a Claude Mod). Its skill, draft-pane:draft, makes Claude write a requested draft inside a block. The pane shows the newest open draft. The person drags over a span and types a comment, types one comment on the whole draft, or approves it. One Submit sends every comment as one prompt that quotes each span. The aim is feedback in place, with fewer turns.
D3 README, the Install section
## Install
Run the two commands below.
[ x ] > Run the two commands below. 1 sentence is enough
The first one adds the marketplace, the second one installs the plugin.
> the second one installs the plugin
comment: [ ]
[ whole draft ] [ Approve ] [ Submit ] 1 comment
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1claude plugin marketplace add meganemura/draft-pane
claude plugin install draft-pane@draft-pane
To develop against a checkout, run the plugin from its working tree:
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir /path/to/draft-pane/plugin
To set CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 for each session, add it to the env of settings.json:
{
"env": {
"CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1"
}
}
Start with /draft-pane:draft <what to draft>. /draft-pane shows or hides the pane and gives it keyboard focus. Drag the mouse over a span of the draft; a line with the quoted span appears right under the line it ends on, and under that an input labeled comment. Type a comment and press Enter to add it; press Enter with an empty input to drop the span. Each added comment stays right under the line of the words it quotes, in the order those lines appear in the draft. A selection cannot cross a line that already carries a comment — drag within the lines between two comments instead. Press whole draft to open the field for one comment on the whole draft, type, and press Enter to add it; pressing the button again and typing a second time replaces it. Press x beside a comment to remove it. Press Submit to send every comment, or Approve to send approval; both refuse while an input still holds text that Enter has not added. The arrow keys move between controls, Enter presses, Esc returns focus to the prompt box. After you show the pane once, it opens on its own when a draft arrives, without taking the keyboard; hide it with /draft-pane to stop that.
The plugin's skill writes each draft inside a fenced block labeled draft:
D3: README, the Install section
## Install
Run the two commands below. The first one adds the marketplace, the second one installs the plugin.
The first line is D<n>: <title>. The lines after it are the draft itself. Numbers run in one sequence for the whole conversation and are never reused. A revision is a new block with a new number; its first line is D<m> (revises D<n>): <title>. The pane shows only the drafts in the newest assistant message that has a draft block; an older draft leaves the pane once a later message posts one, answered or not.
When the draft already exists as a file, the block names it instead of carrying its text:
D5: The article on pane plugins
file: /work/notes/article.md
The line right after the header, file: <path>, makes the draft a file draft; any further lines in the block are ignored. The path is absolute, or relative to the working directory the session runs in. There is no ~ expansion: write the path out in full. On macOS, a file under a folder the system protects (Documents, Desktop, Downloads) can raise a permission dialog for the terminal application on the first read.
Pressing Submit sends one prompt:
Feedback (draft-pane) on D3:
> Run the two commands below.
1 sentence is enough. "below" is not needed.
> the second one installs the plugin
Name the plugin, not "the plugin".
(whole draft) Make the whole section shorter.
A > line quotes the span; the next line is the comment. Spans come in the order they appear in the draft. A comment on the whole draft comes last.
For a file draft, the second line names the file the quotes came from:
Feedback (draft-pane) on D5:
file: /work/notes/article.md
> a quoted span from the file
Shorten this sentence.
Approve sends Feedback (draft-pane) on D3: then (approved): use the draft as it is. Approve refuses while comments are pending. Submit refuses with zero comments. Both refuse while an input still holds text that Enter has not added. The status line says which.
Every draft the pane shows is text inside an assistant message. Every piece of feedback the pane sends is a prompt that quotes the draft. A person with the plugin uninstalled can still read every draft and every piece of feedback in the transcript alone.
Three gates run before a commit and in CI: claude plugin validate plugin, npx -p typescript tsc -p plugin/hooks, and CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude plugin test plugin.
MIT. See LICENSE.
hooks/mod.ts 674 lines1// draft-pane's one function-hooks module (the validator admits one per plugin). Reads the
2// ```draft blocks the model posts in the transcript, shows every one still open in a pane
3// beside it, and lets the person drag a span of the body to comment on it, or write one comment
4// on the whole draft. A body is drawn as a column of segments, cut at each line that already
5// carries a comment, so a comment and its input sit right under the line they belong to; one
6// `Client` draws each segment. One Submit sends every comment as one prompt that quotes each
7// span; one Approve sends nothing but a plain approval line. The transcript is the source of
8// truth: every draft shown here is an assistant message, and every piece of feedback sent is a
9// prompt that quotes it back.
10//
11// A block may name a file instead of carrying the draft's text (block.ts's `Draft.file`). Every
12// `reparse` rereads that file for each open file draft and fills `draft.body` from it, so the
13// pane always shows what is on disk as of the newest parse; a read failure is kept in
14// `state.readErrors` and drawn in place of the body rather than logged, since it is the person's
15// to see, not the operator's.
16//
17// Must NOT know about: what a draft says, or how a ```draft block or a feedback prompt is
18// written — block.ts owns that syntax, and this file only calls its exported functions; how a
19// drag maps to a character range — draft-selection.ts owns that, and this file only reads what
20// it posts; how a body splits into segments or how an offset moves between a segment's own text
21// and the whole body — segments.ts owns that, and this file only calls its exported functions.
22//
23// It loads only where Claude Code has function hooks enabled. The engine's validator reads this
24// file statically, so every call on `$` is spelled `$.noun.event(...)` and `$` is handed only
25// to the function declaration at the top of the file (`hostOf`); the rest of the module holds a
26// `Host`, a bundle of closures built once at `session.start`. The transcript is parsed on
27// `session.start` and `turn.complete` only, kept in `state.open`; `ui.render` draws from state
28// alone and never reads the transcript itself.
29
30import type { Elements, On, RenderElement, SessionMessage } from 'claude-code'
31import { approvalTextOf, feedbackTextOf, identityOf, openDraftsOf, trimmedBodyOf } from './block'
32import type { Draft, OpenDraft } from './block'
33import {
34 EMPTY_FEEDBACK,
35 commentCountOf,
36 hasUnsentTextOf,
37 selectionMessageOf,
38 shortQuoteOf,
39 withoutTrailingNewlinesOf,
40 withSelection,
41 withSpanCommitted,
42 withSpanRemoved,
43 withSpanText,
44 withWholeCommitted,
45 withWholeOpen,
46 withWholeRemoved,
47 withWholeText,
48} from './feedback'
49import type { Feedback } from './feedback'
50import { anchorLineOf, segmentsOf, toAbsolute, toLocal } from './segments'
51import type { Segment } from './segments'
52
53const PANE_ID = 'draft-pane'
54const COMMAND = 'draft-pane'
55const STORE_KEY = 'wantsOpen'
56
57type Host = {
58 messages: () => Promise<readonly SessionMessage[]>
59 submit: (text: string) => Promise<{ drop?: string }>
60 status: (text: string | undefined) => void
61 open: (focus: boolean) => Promise<void>
62 close: () => Promise<void>
63 invalidate: () => void
64 log: (text: string) => void
65 register: () => Promise<unknown>
66 storeGet: (key: string) => Promise<unknown>
67 storeSet: (key: string, value: unknown) => Promise<void>
68 focus: (key: string) => Promise<{ deny?: string }>
69 sleep: (ms: number) => Promise<void>
70 readFile: (path: string) => Promise<string>
71}
72
73type State = {
74 host: Host | null
75 isOpen: boolean
76 wantsOpen: boolean
77 open: OpenDraft[]
78 // The session's working directory (`e.cwd` at `session.start`), the base a file draft's
79 // relative path reads against.
80 cwd: string
81 // A draft's pending feedback, by identity (block.ts's `identityOf`).
82 feedback: Map<string, Feedback>
83 // Identities a Submit or Approve already sent, this process only (see `submit`'s own note).
84 sent: Set<string>
85 isSubmitting: boolean
86 // A file draft's read failure, by identity, computed from the draft as left after the failed
87 // read (empty body). Drawn in place of the body; cleared once that identity is no longer open.
88 readErrors: Map<string, string>
89}
90
91// The host is a bundle of closures over `$`, built once at `session.start`, so the rest of this
92// file never holds `$` itself — the validator's rule, and also the seam a test fakes.
93function hostOf($: any): Host {
94 return {
95 messages: () => $.session.messages(),
96 submit: (text) => $.prompt.submit({ text }),
97 status: (text) => $.ui.status(text),
98 open: (focus) => $.ui.open({ id: PANE_ID, title: PANE_ID, ...(focus ? { focus: true } : {}) }),
99 close: () => $.ui.close({ id: PANE_ID }),
100 invalidate: () => $.ui.invalidate('ui.render'),
101 log: (text) => $.ui.log(text),
102 register: () => $.command.register({ name: COMMAND, description: 'Show or hide the draft-pane' }),
103 storeGet: (key) => $.store.get(key),
104 storeSet: (key, value) => $.store.set(key, value),
105 focus: (key) => $.ui.focus({ requestId: PANE_ID, key }),
106 sleep: (ms) => $.clock.sleep(ms),
107 readFile: (path) => $.fs.read(path),
108 }
109}
110
111function messageOf(error: unknown): string {
112 return error instanceof Error ? error.message : String(error)
113}
114
115// A drag's release, or a `whole draft` button press, posts before the redraw that draws the
116// comment `Input` exists: `$.ui.invalidate` only schedules that redraw (at most thirty a second
117// for the shown pane), so a `focus` call right after can find no element under the key yet. The
118// `deny` names that missing element rather than any other failure, so it is safe to retry on; ten
119// tries at 50ms apart cover the slowest redraw cadence with room, without retrying a `deny` that
120// means something else.
121async function focusInput(host: Host, key: string): Promise<void> {
122 try {
123 for (let attempt = 0; attempt < 10; attempt += 1) {
124 const result = await host.focus(key)
125 if (result.deny === undefined) return
126 if (attempt < 9) {
127 await host.sleep(50)
128 } else {
129 host.log(`focus denied: ${result.deny}`)
130 }
131 }
132 } catch (error) {
133 host.log(`focus failed: ${messageOf(error)}`)
134 }
135}
136
137// The drafts still worth drawing: `state.open` less whatever a Submit or Approve already sent.
138// `sent` survives a `reparse` on purpose (see `submit`'s own note) so it, not `state.open`, is
139// the filter applied here.
140function visibleOf(state: State): OpenDraft[] {
141 return state.open.filter((od) => !state.sent.has(identityOf(od.draft)))
142}
143
144function updateStatus(state: State): void {
145 const host = state.host
146 if (host === null) return
147 const visible = visibleOf(state)
148 if (state.isOpen || visible.length === 0) {
149 host.status(undefined)
150 return
151 }
152 const word = visible.length === 1 ? 'draft' : 'drafts'
153 host.status(`${visible.length} open ${word} (/draft-pane)`)
154}
155
156// `$.store` is the plugin's own file, kept across sessions and hot reloads and shared by every
157// session in every directory — one flag in it cannot mean "the pane is open", or a pane left
158// open in one session would reopen empty at the start of every later one. It means the person
159// wants the pane; the pane itself opens only once there is a draft worth showing it for. Used
160// from `session.start` (by way of `reparse`, below) and from `reparse` itself on
161// `turn.complete`, so a draft that appears mid-session opens the pane the same way one already
162// open at session start does.
163async function openIfWanted(state: State): Promise<void> {
164 const host = state.host
165 if (host === null || !state.wantsOpen || state.isOpen || visibleOf(state).length === 0) return
166 try {
167 // Not focused: opening on its own, unasked, must not take the keyboard away from whatever
168 // the person is about to type.
169 await host.open(false)
170 state.isOpen = true
171 } catch (error) {
172 host.log(`reopen failed: ${messageOf(error)}`)
173 }
174}
175
176// A file draft's body read fresh from disk, in parallel with every other open file draft's, so
177// one missing file does not delay or fail the rest. Mutates `draft.body` in place (the draft is
178// this `reparse`'s own, freshly parsed, so nothing else holds a reference yet) and records or
179// clears `state.readErrors` under the identity that body leaves the draft with.
180async function readFileDraft(state: State, host: Host, draft: Draft): Promise<void> {
181 const path = draft.file
182 if (path === null) return
183 try {
184 const text = await host.readFile(path)
185 draft.body = trimmedBodyOf(text)
186 state.readErrors.delete(identityOf(draft))
187 } catch (error) {
188 draft.body = ''
189 state.readErrors.set(identityOf(draft), messageOf(error))
190 }
191}
192
193// Re-reads the transcript into `state.open`, rereads every open file draft's file, drops any
194// pending feedback or read error whose draft is no longer open (approved, submitted or gone from
195// the transcript, on some other path), and redraws. Wrapped whole in a try/catch: a hook is
196// fail-open, so a parse failure must not vanish silently — it goes to `host.log` once instead.
197async function reparse(state: State): Promise<void> {
198 const host = state.host
199 if (host === null) return
200 try {
201 state.open = openDraftsOf(await host.messages())
202 await Promise.all(state.open.map((od) => readFileDraft(state, host, od.draft)))
203 const openIdentities = new Set(state.open.map((od) => identityOf(od.draft)))
204 for (const identity of state.feedback.keys()) {
205 if (!openIdentities.has(identity)) state.feedback.delete(identity)
206 }
207 for (const identity of state.readErrors.keys()) {
208 if (!openIdentities.has(identity)) state.readErrors.delete(identity)
209 }
210 host.invalidate()
211 await openIfWanted(state)
212 updateStatus(state)
213 } catch (error) {
214 host.log(`reparse failed: ${messageOf(error)}`)
215 }
216}
217
218function feedbackOf(state: State, identity: string): Feedback {
219 return state.feedback.get(identity) ?? EMPTY_FEEDBACK
220}
221
222// A draft's body cut into segments, each ending at the line under which a comment sits (its
223// anchor line): one per committed span, plus the pending selection when there is one. Called
224// from both sides of the `ui.message` handler and `draftBoxOf`, so a segment index posted by one
225// always means the same segment to the other.
226function segmentsForDraft(draft: Draft, feedback: Feedback): Segment[] {
227 const lines = draft.body.split('\n')
228 const anchorLines = feedback.spans.map((span) => anchorLineOf(lines, span))
229 if (feedback.selection !== null) anchorLines.push(anchorLineOf(lines, feedback.selection))
230 return segmentsOf(lines, anchorLines)
231}
232
233// `isSubmitting` guards a press arriving while a previous submit is still in flight. Sending
234// nothing when there is no comment guards the other press pattern: focus already sitting on
235// Submit, one Enter with nothing said, spending a whole turn on an empty prompt nobody chose.
236async function submit(state: State, host: Host, draft: Draft): Promise<void> {
237 if (state.isSubmitting) return
238 const identity = identityOf(draft)
239 const feedback = feedbackOf(state, identity)
240 if (hasUnsentTextOf(feedback)) {
241 host.status(`D${draft.number} has text in an input; press Enter to add it, or clear it`)
242 return
243 }
244 if (commentCountOf(feedback) === 0) {
245 host.status('add a comment or press Approve before Submit')
246 return
247 }
248
249 state.isSubmitting = true
250 try {
251 const result = await host.submit(feedbackTextOf(draft, feedback.spans, feedback.whole))
252 if (result.drop === undefined) {
253 state.sent.add(identity)
254 state.feedback.delete(identity)
255 host.status(undefined)
256 host.invalidate()
257 }
258 // A `drop` leaves the draft and its pending feedback exactly where they were, so the person
259 // can press Submit again without redoing anything.
260 } finally {
261 state.isSubmitting = false
262 }
263}
264
265// Approve refuses while there is a pending comment: a comment left unsent under an Approve
266// would read, on the transcript, as if the person had nothing to say about it.
267async function approve(state: State, host: Host, draft: Draft): Promise<void> {
268 if (state.isSubmitting) return
269 const identity = identityOf(draft)
270 const feedback = feedbackOf(state, identity)
271 // The kit cannot type into an Input, so this guard is never reached from mod.test.ts.
272 if (hasUnsentTextOf(feedback)) {
273 host.status(`D${draft.number} has text in an input; press Enter to add it, or clear it`)
274 return
275 }
276 if (commentCountOf(feedback) > 0) {
277 host.status(`D${draft.number} has comments; press Submit, or remove them first`)
278 return
279 }
280
281 state.isSubmitting = true
282 try {
283 const result = await host.submit(approvalTextOf(draft))
284 if (result.drop === undefined) {
285 state.sent.add(identity)
286 state.feedback.delete(identity)
287 host.status(undefined)
288 host.invalidate()
289 }
290 } finally {
291 state.isSubmitting = false
292 }
293}
294
295// The transitions behind an `Input` or a `Button`, each one line so the closure drawn beside it
296// stays one line too (the test kit cannot type into an `Input`, so these are what feedback.test.ts
297// covers instead).
298// No `host.invalidate()` here: the `Input` already shows what the person types, so redrawing on
299// every keystroke is wasted work, and it can move the cursor out from under the person's hands.
300// The mirror kept in `state.feedback` exists so that a redraw triggered by something else (a
301// `turn.complete`, another draft's own action) hands the typed text back rather than losing it.
302function onSpanTextInput(state: State, host: Host, identity: string, text: string): void {
303 state.feedback.set(identity, withSpanText(feedbackOf(state, identity), text))
304}
305
306function onSpanTextSubmit(state: State, host: Host, identity: string, text: string): void {
307 state.feedback.set(identity, withSpanCommitted(feedbackOf(state, identity), text))
308 host.invalidate()
309}
310
311function onSpanRemove(state: State, host: Host, identity: string, index: number): void {
312 state.feedback.set(identity, withSpanRemoved(feedbackOf(state, identity), index))
313 host.invalidate()
314}
315
316// No `host.invalidate()` here: the `Input` already shows what the person types, so redrawing on
317// every keystroke is wasted work, and it can move the cursor out from under the person's hands.
318// The mirror kept in `state.feedback` exists so that a redraw triggered by something else (a
319// `turn.complete`, another draft's own action) hands the typed text back rather than losing it.
320function onWholeTextInput(state: State, host: Host, identity: string, text: string): void {
321 state.feedback.set(identity, withWholeText(feedbackOf(state, identity), text))
322}
323
324function onWholeTextSubmit(state: State, host: Host, identity: string, text: string): void {
325 state.feedback.set(identity, withWholeCommitted(feedbackOf(state, identity), text))
326 host.invalidate()
327}
328
329function onWholeRemove(state: State, host: Host, identity: string): void {
330 state.feedback.set(identity, withWholeRemoved(feedbackOf(state, identity)))
331 host.invalidate()
332}
333
334// Behind the `whole draft` button: opens the field the same way a drag opens the span one — ask
335// for the keyboard, then move the ring onto the `Input` the next redraw will draw. `host.open`'s
336// own failure is logged and swallowed, same as `ui.message`'s drag handler, so a denied request
337// leaves the field open and drawn, just without focus.
338async function openWholeInput(state: State, host: Host, identity: string, key: string): Promise<void> {
339 state.feedback.set(identity, withWholeOpen(feedbackOf(state, identity), true))
340 host.invalidate()
341 try {
342 await host.open(true)
343 } catch (error) {
344 host.log(`focus: open failed: ${messageOf(error)}`)
345 }
346 await focusInput(host, key)
347}
348
349// The real element types, so the typecheck refuses a prop the engine would refuse. One unknown
350// prop drops the whole tree with no message. `Text` takes no `key`.
351type Ui = Pick<Elements['terminal'], 'Box' | 'Button' | 'Text' | 'Input' | 'Client'>
352
353function commentRowOf(ui: Ui, key: string, quote: string, comment: string, onRemove: () => void): RenderElement {
354 const { Box, Button, Text } = ui
355 return Box({
356 key,
357 flexDirection: 'row',
358 columnGap: 1,
359 children: [Button({ key: `${key}:remove`, label: 'x', onPress: onRemove }), Text({ dimColor: true, children: `> ${quote}` }), Text({ children: comment })],
360 })
361}
362
363// The rows drawn right under segment `k`: one per committed span anchored on its last line, in
364// ascending `start` order, then the pending selection's own quote and `Input` when it is
365// anchored there too. A span keeps its index into `feedback.spans` for its key and its `onRemove`
366// closure, even sorted here into a different display order, so removing one still removes the
367// right one.
368function segmentRowsOf(ui: Ui, key: string, draft: Draft, lines: readonly string[], segment: Segment, feedback: Feedback, state: State, host: Host, identity: string): RenderElement[] {
369 const { Box, Text, Input } = ui
370 const rows: RenderElement[] = feedback.spans
371 .map((span, spanIndex) => ({ span, spanIndex }))
372 .filter(({ span }) => anchorLineOf(lines, span) === segment.lastLine)
373 .sort((a, b) => a.span.start - b.span.start)
374 .map(({ span, spanIndex }) =>
375 commentRowOf(ui, `${key}:c${spanIndex}`, shortQuoteOf(draft.body, span), span.comment, () => onSpanRemove(state, host, identity, spanIndex)),
376 )
377
378 const selection = feedback.selection
379 if (selection !== null && anchorLineOf(lines, selection) === segment.lastLine) {
380 rows.push(
381 Box({
382 key: `${key}:span-input-row`,
383 flexDirection: 'column',
384 children: [
385 Text({ dimColor: true, children: `> ${shortQuoteOf(draft.body, selection)}` }),
386 Input({
387 key: `${key}:span-input`,
388 label: 'comment',
389 placeholder: 'Enter adds it; empty Enter drops the span',
390 value: feedback.spanText,
391 autoFocus: true,
392 onInput: (value) => onSpanTextInput(state, host, identity, value),
393 onSubmit: (value) => onSpanTextSubmit(state, host, identity, value),
394 }),
395 ],
396 }),
397 )
398 }
399 return rows
400}
401
402function draftBoxOf(ui: Ui, index: number, openDraft: OpenDraft, state: State, host: Host): RenderElement {
403 const { Box, Button, Text, Input, Client } = ui
404 const { draft, isDuplicate } = openDraft
405 const identity = identityOf(draft)
406 const feedback = feedbackOf(state, identity)
407 const key = `d${index}`
408
409 const children: RenderElement[] = [Text({ bold: true, children: `D${draft.number} ${draft.title}` })]
410 if (isDuplicate) children.push(Text({ color: 'yellow', children: 'duplicate number' }))
411
412 if (draft.file !== null) {
413 children.push(Text({ dimColor: true, children: `file: ${draft.file}` }))
414 const readError = state.readErrors.get(identity)
415 if (readError !== undefined) {
416 children.push(Text({ color: 'red', children: `cannot read the file: ${readError}` }))
417 return Box({ key, flexDirection: 'column', rowGap: 1, children })
418 }
419 if (draft.body === '') {
420 children.push(Text({ children: 'the file is empty' }))
421 return Box({ key, flexDirection: 'column', rowGap: 1, children })
422 }
423 }
424
425 const lines = draft.body.split('\n')
426 const segments = segmentsForDraft(draft, feedback)
427 const selection = feedback.selection
428
429 segments.forEach((segment, k) => {
430 const armedRange = selection === null ? null : toLocal(segment, selection)
431 children.push(
432 Client({
433 key: `${key}:seg${k}`,
434 // A string literal, not a constant: the validator reads a Client's `module` statically
435 // and refuses any indirection, so the path is spelled out here rather than named once.
436 module: './draft-selection.ts',
437 props: { lines: segment.lines, ...(armedRange === null ? {} : { armedRange }) },
438 width: '100%',
439 }),
440 )
441 children.push(
442 Box({
443 key: `${key}:seg${k}:rows`,
444 flexDirection: 'column',
445 children: segmentRowsOf(ui, key, draft, lines, segment, feedback, state, host, identity),
446 }),
447 )
448 })
449
450 if (feedback.whole !== null) {
451 const whole = feedback.whole
452 children.push(
453 Box({
454 key: `${key}:whole`,
455 flexDirection: 'row',
456 columnGap: 1,
457 children: [
458 Button({ key: `${key}:whole:remove`, label: 'x', onPress: () => onWholeRemove(state, host, identity) }),
459 Text({ children: `(whole draft) ${whole}` }),
460 ],
461 }),
462 )
463 }
464
465 // Drawn only while `feedback.wholeOpen` is true, not from the first render: a real-terminal
466 // round measured that, with an `Input` on screen from the start, no mouse event reached Claude
467 // Code at all, in this pane or in the transcript, and that a pane holding only `Button` and
468 // `Text` (grilling-pane's) was unaffected. The `whole draft` button below opens the field; Enter
469 // closes it again, so at most one draft's `Input` is ever on screen.
470 if (feedback.wholeOpen) {
471 children.push(
472 Box({
473 key: `${key}:whole-input-row`,
474 children: [
475 Input({
476 key: `${key}:whole-input`,
477 label: 'whole draft',
478 placeholder: 'comment on the whole draft',
479 value: feedback.wholeText,
480 autoFocus: true,
481 onInput: (value) => onWholeTextInput(state, host, identity, value),
482 onSubmit: (value) => onWholeTextSubmit(state, host, identity, value),
483 }),
484 ],
485 }),
486 )
487 }
488
489 const n = commentCountOf(feedback)
490 children.push(
491 Box({
492 key: `${key}:actions`,
493 flexDirection: 'row',
494 columnGap: 1,
495 children: [
496 Button({
497 key: `${key}:whole-button`,
498 label: 'whole draft',
499 onPress: () => {
500 openWholeInput(state, host, identity, `${key}:whole-input`).catch((error: unknown) => host.log(`focus failed: ${messageOf(error)}`))
501 },
502 }),
503 Button({
504 key: `${key}:approve`,
505 label: 'Approve',
506 onPress: () => {
507 approve(state, host, draft).catch((error: unknown) => host.log(`submit failed: ${messageOf(error)}`))
508 },
509 }),
510 Button({
511 key: `${key}:submit`,
512 label: 'Submit',
513 onPress: () => {
514 submit(state, host, draft).catch((error: unknown) => host.log(`submit failed: ${messageOf(error)}`))
515 },
516 }),
517 Text({ dimColor: true, children: n === 1 ? '1 comment' : `${n} comments` }),
518 ],
519 }),
520 )
521
522 return Box({ key, flexDirection: 'column', rowGap: 1, children })
523}
524
525// Nothing here reads `state.host` beyond what the caller already resolved into `ui` and `host`:
526// the render hook itself is the only place allowed to touch `$` (through `$.ui.resolve`), and
527// this function draws from `state` alone, per the module's own rule against reading the
528// transcript from `ui.render`. `bodyColumns` and `bodyRows` are the render input's own body
529// size, passed down for the "no open drafts" line alone: centering it takes a Box as large as
530// the body to center inside, and the size to make one is only ever in the render input, never
531// in state.
532function paneOf(ui: Ui, state: State, host: Host, bodyColumns: number, bodyRows: number): RenderElement {
533 const { Box, Text } = ui
534 const visible = visibleOf(state)
535 if (visible.length === 0) {
536 // A first frame may report 0 for either before the surface has measured the pane; centering
537 // into a zero-sized Box would draw nothing, so this falls back to the plain line instead.
538 if (bodyColumns <= 0 || bodyRows <= 0) return Text({ children: 'no open drafts' })
539 return Box({
540 key: 'empty',
541 width: bodyColumns,
542 height: bodyRows,
543 justifyContent: 'center',
544 alignItems: 'center',
545 children: [Text({ dimColor: true, children: 'no open drafts' })],
546 })
547 }
548
549 return Box({
550 key: PANE_ID,
551 flexDirection: 'column',
552 rowGap: 1,
553 children: visible.map((openDraft, index) => draftBoxOf(ui, index, openDraft, state, host)),
554 })
555}
556
557export function register(on: On) {
558 const state: State = {
559 host: null,
560 isOpen: false,
561 wantsOpen: false,
562 open: [],
563 cwd: '',
564 feedback: new Map(),
565 sent: new Set(),
566 isSubmitting: false,
567 readErrors: new Map(),
568 }
569
570 on('session.start', async ($, e, next) => {
571 state.host = hostOf($)
572 state.cwd = e.cwd
573 await state.host.register().catch((error: unknown) => {
574 state.host?.log(`/${COMMAND} is not available: ${messageOf(error)}`)
575 })
576 const stored = await state.host.storeGet(STORE_KEY).catch(() => undefined)
577 state.wantsOpen = stored === true
578 // `reparse` runs first, so whether to open (there is a draft to show) is decided from this
579 // session's own transcript, not just the flag; `openIfWanted` inside it is what actually
580 // opens the pane, without focus, when that is warranted.
581 await reparse(state)
582 return next(e)
583 })
584
585 on('turn.complete', ($, e, next) => {
586 void reparse(state)
587 return next(e)
588 })
589
590 on('command.run', { command: COMMAND }, async ($, e, next) => {
591 const host = state.host
592 if (host === null) return next(e)
593
594 if (state.isOpen) {
595 await host.close()
596 state.isOpen = false
597 state.wantsOpen = false
598 await host.storeSet(STORE_KEY, false)
599 return { text: 'draft-pane hidden' }
600 }
601
602 // Opens even with zero drafts: the person asked outright, unlike the automatic open in
603 // `openIfWanted`, which only ever opens where there is something to show.
604 await host.open(true)
605 state.isOpen = true
606 state.wantsOpen = true
607 await host.storeSet(STORE_KEY, true)
608 await reparse(state)
609 return { text: 'draft-pane shown' }
610 })
611
612 on('ui.close', { id: PANE_ID }, async ($, e, next) => {
613 const result = await next(e)
614 const host = state.host
615 if (result.deny === undefined && host !== null) {
616 state.isOpen = false
617 state.wantsOpen = false
618 await host.storeSet(STORE_KEY, false)
619 updateStatus(state)
620 }
621 return result
622 })
623
624 // A draft-selection.ts Client posted this on a drag's release ('client' origin: code sent it,
625 // on nobody's behalf, so `data` is input to validate, never a fact). Its `element` key is
626 // `d${index}:seg${k}` (set in `draftBoxOf`): `index` addresses `visibleOf(state)` — the same
627 // order the pane drew it in — and `k` addresses that draft's own segments, recomputed here from
628 // its feedback with the same `segmentsForDraft` `draftBoxOf` drew from, so the two sides agree
629 // on what segment `k` means. A posted range is local to that segment's own text; `toAbsolute`
630 // is what turns it into the offsets `Feedback.selection` keeps.
631 on('ui.message', { requestId: PANE_ID }, async ($, e, next) => {
632 const host = state.host
633 if (host === null) return next(e)
634 const elementMatch = /^d(\d+):seg(\d+)$/.exec(e.element)
635 if (elementMatch === null) return next(e)
636 const openDraft = visibleOf(state)[Number(elementMatch[1])]
637 if (openDraft === undefined) return next(e)
638 const message = selectionMessageOf(e.data)
639 if (message === null) return next(e)
640
641 const identity = identityOf(openDraft.draft)
642 const feedback = feedbackOf(state, identity)
643 const segment = segmentsForDraft(openDraft.draft, feedback)[Number(elementMatch[2])]
644 if (segment === undefined) return next(e)
645 const absolute = message.type === 'selected' ? toAbsolute(segment, { start: message.start, end: message.end }) : null
646 const selection = absolute === null ? null : withoutTrailingNewlinesOf(openDraft.draft.body, absolute)
647 state.feedback.set(identity, withSelection(feedback, selection))
648 host.invalidate()
649
650 if (message.type === 'selected') {
651 // `autoFocus` only places the ring once a site already holds the keyboard; a drag does
652 // not give the pane the keyboard by itself. The open-with-focus request below is what
653 // does: a mouse drag over the pane is the person's own act, so asking for the keyboard
654 // right here, right after it, is the moment the surface grants the request (a pane opened
655 // with `focus` gets it while the composer is empty). The `host.focus` call after that is
656 // what then puts the ring on the comment `Input` itself.
657 try {
658 await host.open(true)
659 } catch (error) {
660 host.log(`focus: open failed: ${messageOf(error)}`)
661 }
662 await focusInput(host, `d${elementMatch[1]}:span-input`)
663 }
664 return next(e)
665 })
666
667 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
668 if (e.requestId !== PANE_ID || state.host === null) return next(e)
669 if (e.surface !== 'terminal') return next(e)
670 const { Box, Button, Text, Input, Client } = await $.ui.resolve(e)
671 return paneOf({ Box, Button, Text, Input, Client }, state, state.host, e.props.bodyColumns, e.props.scroll.bodyRows)
672 })
673}
674hooks/block.ts 220 lines1// Pure parsing and formatting for the ```draft block and the feedback lines a Submit press
2// sends. No `$`, no hooks, no Claude Code runtime beyond the `SessionMessage` type: mod.ts is
3// the only caller, and every export here is a plain function over strings and the transcript's
4// own messages. Must not know about the pane's drawing, a drag, or how a span was chosen —
5// only the block's text shape and the feedback line shape.
6
7import type { SessionMessage } from 'claude-code'
8
9// `file`, when not null, makes this a file draft: `body` is what mod.ts last read from that
10// path (empty until the first read), not text carried in the block itself.
11export type Draft = { number: number; title: string; body: string; file: string | null; revises: number | null }
12export type OpenDraft = { draft: Draft; isDuplicate: boolean }
13export type SpanComment = { start: number; end: number; comment: string }
14
15export const FEEDBACK_HEADER_PREFIX = 'Feedback (draft-pane) on D'
16export const APPROVED = '(approved)'
17export const WHOLE_DRAFT = '(whole draft)'
18
19// A block opens on three or more backticks followed by `draft`, and closes on a line of only
20// backticks (at least as many as the opener) — so a four-backtick block can carry a three-
21// backtick fence in its body without that fence line closing it early.
22const OPEN_RE = /^(`{3,})draft[ \t]*$/
23const CLOSE_RE = /^(`+)[ \t]*$/
24const HEADER_RE = /^D(\d+)(?:\s*\(revises D(\d+)\))?:\s*(.*)$/
25// The line right after the header, trimmed: when it matches, the draft names a file instead of
26// carrying its text.
27const FILE_RE = /^file:\s*(.+)$/
28// Same shape as `${FEEDBACK_HEADER_PREFIX}<number>:`, spelled out so the pattern is visible
29// in one place rather than built from the constant at runtime.
30const FEEDBACK_LINE_RE = /^Feedback \(draft-pane\) on D(\d+):$/
31
32// Leading and trailing blank lines (empty or whitespace only) dropped, an inner blank line
33// kept — the body is the exact string the pane draws and the formatter slices, so nothing
34// else here may change its length.
35function trimBlankLines(lines: readonly string[]): string[] {
36 const isBlank = (line: string): boolean => line.trim() === ''
37 let start = 0
38 let end = lines.length
39 while (start < end && isBlank(lines[start] ?? '')) start += 1
40 while (end > start && isBlank(lines[end - 1] ?? '')) end -= 1
41 return lines.slice(start, end)
42}
43
44// A body's leading and trailing blank lines removed, an inner blank line kept — the same trim
45// `draftOf` applies to a block's own body, exported so mod.ts applies it to a file's text too:
46// a file draft's body must be trimmed the same way, or its identity would depend on which of the
47// two places did the trimming.
48export function trimmedBodyOf(text: string): string {
49 return trimBlankLines(text.split('\n')).join('\n')
50}
51
52// `blockLines` is everything between the opener and the closer (or end of text), header line
53// first. A missing or malformed header drops the block rather than failing the whole parse: a
54// model's draft can contain a stray block. A plain draft whose body trims away to nothing is
55// dropped too, but a file draft is kept with an empty body — mod.ts fills it in on the first
56// read, and there is no text here yet to judge empty.
57function draftOf(blockLines: readonly string[]): Draft | null {
58 const headerLine = blockLines[0]
59 if (headerLine === undefined) return null
60 const headerMatch = HEADER_RE.exec(headerLine)
61 if (headerMatch === null) return null
62 const number = Number(headerMatch[1] ?? '')
63 const revisesGroup = headerMatch[2]
64 const revises = revisesGroup === undefined ? null : Number(revisesGroup)
65 const title = (headerMatch[3] ?? '').trim()
66
67 const fileLine = blockLines[1]
68 const fileMatch = fileLine === undefined ? null : FILE_RE.exec(fileLine.trim())
69 if (fileMatch !== null) {
70 const file = (fileMatch[1] ?? '').trim()
71 return { number, title, body: '', file, revises }
72 }
73
74 const body = trimmedBodyOf(blockLines.slice(1).join('\n'))
75 if (body === '') return null
76 return { number, title, body, file: null, revises }
77}
78
79// Every draft from every ```draft block in `text` (a message may carry more than one), in
80// order. Text outside a block is ignored; an unclosed block runs to the end of the text.
81export function draftsOf(text: string): Draft[] {
82 const drafts: Draft[] = []
83 const lines = text.split('\n')
84 let i = 0
85 while (i < lines.length) {
86 const openMatch = OPEN_RE.exec(lines[i] ?? '')
87 if (openMatch === null) {
88 i += 1
89 continue
90 }
91 const fenceLength = (openMatch[1] ?? '').length
92 i += 1
93 const blockLines: string[] = []
94 while (i < lines.length) {
95 const closeMatch = CLOSE_RE.exec(lines[i] ?? '')
96 if (closeMatch !== null && (closeMatch[1] ?? '').length >= fenceLength) {
97 i += 1
98 break
99 }
100 blockLines.push(lines[i] ?? '')
101 i += 1
102 }
103 const draft = draftOf(blockLines)
104 if (draft !== null) drafts.push(draft)
105 }
106 return drafts
107}
108
109export function identityOf(draft: Draft): string {
110 return `${draft.number}\n${draft.title}\n${draft.body}`
111}
112
113export function headerOf(draft: Draft): string {
114 return `${FEEDBACK_HEADER_PREFIX}${draft.number}:`
115}
116
117// Every `Feedback (draft-pane) on D<n>:` line in `text`, as numbers, in order.
118export function feedbackNumbersOf(text: string): number[] {
119 const numbers: number[] = []
120 for (const line of text.split('\n')) {
121 const match = FEEDBACK_LINE_RE.exec(line.trim())
122 if (match !== null) numbers.push(Number(match[1] ?? ''))
123 }
124 return numbers
125}
126
127// Every draft with an open number, deduplicated by identity (first occurrence kept), sorted by
128// number ascending.
129//
130// Only the last assistant message that holds a ```draft block supplies candidates. Without that
131// rule a draft the person never answered — an older topic, or one answered in plain words
132// instead of from the pane — would stay open forever, next to whatever the model just wrote. An
133// older draft is still readable in the transcript, and the model can post it again under a new
134// number.
135//
136// Feedback numbers are still read from every non-assistant message: a feedback prompt for the
137// newest draft can sit after the message that named it. `revised` is still built from every
138// draft in every assistant message, not only the newest one's, since the newest message may
139// itself carry both a draft and the revision that replaces it.
140export function openDraftsOf(messages: readonly SessionMessage[]): OpenDraft[] {
141 const allDrafts: Draft[] = []
142 const feedbackNumbers: number[] = []
143 let newestDrafts: Draft[] = []
144 for (const message of messages) {
145 if (message.role === 'assistant') {
146 const drafts = draftsOf(message.text)
147 allDrafts.push(...drafts)
148 if (drafts.length > 0) newestDrafts = drafts
149 } else {
150 feedbackNumbers.push(...feedbackNumbersOf(message.text))
151 }
152 }
153
154 const feedbackSet = new Set(feedbackNumbers)
155 const revised = new Set<number>()
156 for (const draft of allDrafts) {
157 if (draft.revises !== null) revised.add(draft.revises)
158 }
159
160 const isOpen = (draft: Draft): boolean => !feedbackSet.has(draft.number) && !revised.has(draft.number)
161
162 const seen = new Set<string>()
163 const open: Draft[] = []
164 for (const draft of newestDrafts) {
165 if (!isOpen(draft)) continue
166 const identity = identityOf(draft)
167 if (seen.has(identity)) continue
168 seen.add(identity)
169 open.push(draft)
170 }
171
172 const identitiesByNumber = new Map<number, Set<string>>()
173 for (const draft of open) {
174 const identities = identitiesByNumber.get(draft.number) ?? new Set<string>()
175 identities.add(identityOf(draft))
176 identitiesByNumber.set(draft.number, identities)
177 }
178
179 return open
180 .map((draft) => ({
181 draft,
182 isDuplicate: (identitiesByNumber.get(draft.number)?.size ?? 0) > 1,
183 }))
184 .sort((a, b) => a.draft.number - b.draft.number)
185}
186
187export function quoteOf(text: string): string {
188 return text
189 .split('\n')
190 .map((line) => '> ' + line)
191 .join('\n')
192}
193
194// A file draft's second line names the file the quotes came from, so the prompt is
195// self-contained without the model going back to the block.
196function fileLineOf(draft: Draft): string | null {
197 return draft.file === null ? null : `file: ${draft.file}`
198}
199
200// The prompt a Submit press sends: the header, then for a file draft the `file:` line, then for
201// each span (in ascending `start` order, stable, regardless of the order given) the quoted slice
202// of `draft.body` and the comment on its own line, then, when `whole` is given, one
203// `(whole draft) <comment>` line.
204export function feedbackTextOf(draft: Draft, spans: readonly SpanComment[], whole: string | null): string {
205 const ordered = [...spans].sort((a, b) => a.start - b.start)
206 const fileLine = fileLineOf(draft)
207 const lines: string[] = [headerOf(draft), ...(fileLine === null ? [] : [fileLine])]
208 for (const span of ordered) {
209 lines.push(quoteOf(draft.body.slice(span.start, span.end)))
210 lines.push(span.comment)
211 }
212 if (whole !== null) lines.push(`${WHOLE_DRAFT} ${whole}`)
213 return lines.join('\n')
214}
215
216export function approvalTextOf(draft: Draft): string {
217 const fileLine = fileLineOf(draft)
218 return [headerOf(draft), ...(fileLine === null ? [] : [fileLine]), APPROVED].join('\n')
219}
220hooks/feedback.ts 122 lines1// Pure state for one draft's pending feedback: the span just dragged and not yet commented on,
2// the committed span comments, and the one committed whole-draft comment. No `$`, no hooks —
3// mod.ts is the only caller, and every export here is a plain function over a `Feedback` value.
4// Must not know about how a drag is drawn (draft-selection.ts) or how a draft's body is parsed
5// or how a feedback prompt is written (block.ts) — only this pane's own pending state.
6
7import type { SpanComment } from './block'
8import type { SelectionMessage } from './draft-selection'
9
10export type Selection = { start: number; end: number }
11
12// `spanText` and `wholeText` mirror what their `Input` holds. Every redraw hands the `Input`
13// its `value` again (the engine has no state of its own for it), so without a mirror here a
14// redraw triggered by a finished turn or a fresh drag on another draft would hand the `Input`
15// back an empty string and silently erase what the person had already typed.
16export type Feedback = {
17 selection: Selection | null
18 spanText: string
19 spans: SpanComment[]
20 whole: string | null
21 wholeText: string
22 // True while the whole-draft `Input` is on screen: drawn on a `whole draft` button press, and
23 // closed again on Enter (empty or not). Kept false the rest of the time so the pane holds no
24 // `Input` until the person asks for one — see mod.ts's own note on why.
25 wholeOpen: boolean
26}
27
28export const EMPTY_FEEDBACK: Feedback = { selection: null, spanText: '', spans: [], whole: null, wholeText: '', wholeOpen: false }
29
30export function withSelection(feedback: Feedback, selection: Selection | null): Feedback {
31 return { ...feedback, selection, spanText: '' }
32}
33
34export function withSpanText(feedback: Feedback, text: string): Feedback {
35 return { ...feedback, spanText: text }
36}
37
38// No selection: nothing to attach the comment to, so the commit is a no-op. An empty Enter
39// (only whitespace) is how the person cancels a drag — it drops the selection and adds nothing,
40// rather than committing a blank comment.
41export function withSpanCommitted(feedback: Feedback, text: string): Feedback {
42 const selection = feedback.selection
43 if (selection === null) return feedback
44 const trimmed = text.trim()
45 if (trimmed === '') return { ...feedback, selection: null, spanText: '' }
46 return {
47 ...feedback,
48 selection: null,
49 spanText: '',
50 spans: [...feedback.spans, { start: selection.start, end: selection.end, comment: trimmed }],
51 }
52}
53
54export function withSpanRemoved(feedback: Feedback, index: number): Feedback {
55 if (index < 0 || index >= feedback.spans.length) return feedback
56 return { ...feedback, spans: [...feedback.spans.slice(0, index), ...feedback.spans.slice(index + 1)] }
57}
58
59export function withWholeText(feedback: Feedback, text: string): Feedback {
60 return { ...feedback, wholeText: text }
61}
62
63export function withWholeOpen(feedback: Feedback, open: boolean): Feedback {
64 return { ...feedback, wholeOpen: open }
65}
66
67// `whole` holds at most one comment: a second Enter replaces the first rather than adding a
68// second. An empty Enter removes it. Enter always closes the field, sent or not, so the `Input`
69// never lingers once the person has said what they meant to say.
70export function withWholeCommitted(feedback: Feedback, text: string): Feedback {
71 const trimmed = text.trim()
72 return { ...feedback, whole: trimmed === '' ? null : trimmed, wholeText: '', wholeOpen: false }
73}
74
75export function withWholeRemoved(feedback: Feedback): Feedback {
76 return { ...feedback, whole: null }
77}
78
79export function commentCountOf(feedback: Feedback): number {
80 return feedback.spans.length + (feedback.whole === null ? 0 : 1)
81}
82
83// True while an Input holds text Enter has not yet turned into a span or whole-draft comment —
84// the case a Submit or Approve press must refuse, or that text would be lost with no word said.
85export function hasUnsentTextOf(feedback: Feedback): boolean {
86 return feedback.spanText.trim() !== '' || feedback.wholeText.trim() !== ''
87}
88
89// `data` came from a draft-selection.ts Client's post — code sent it, not the engine — so this
90// is the one place that message is checked before mod.ts trusts its shape.
91export function selectionMessageOf(data: unknown): SelectionMessage | null {
92 if (typeof data !== 'object' || data === null) return null
93 const type = Reflect.get(data, 'type')
94 if (type === 'cleared') return { type: 'cleared' }
95 if (type !== 'selected') return null
96 const start = Reflect.get(data, 'start')
97 const end = Reflect.get(data, 'end')
98 if (typeof start !== 'number' || typeof end !== 'number' || start < 0 || end < start) return null
99 return { type: 'selected', start, end }
100}
101
102// A release at column 0 of the next line posts an `end` right after the `\n` it dragged past —
103// the person meant the line above, not the break itself. Left as is, that `\n` would become the
104// span's own anchor line (segments.ts's `anchorLineOf` reads `end - 1`) and the quote's last
105// line, so the highlight and the quote both want the words only, never the break that follows
106// them. Stops at one character so a selection that is itself a single `\n` is left alone, rather
107// than shrunk to an empty range nothing can anchor to.
108export function withoutTrailingNewlinesOf(body: string, range: Selection): Selection {
109 let end = range.end
110 while (end > range.start + 1 && body[end - 1] === '\n') end -= 1
111 return end === range.end ? range : { ...range, end }
112}
113
114// The quote line drawn above a span's Input and the first column of a comment row: the slice
115// collapsed to one line (a dragged span can cross a newline) and cut short so a long quote does
116// not crowd out the comment beside it.
117export function shortQuoteOf(body: string, selection: Selection, maxLength = 40): string {
118 const collapsed = body.slice(selection.start, selection.end).replace(/\s+/g, ' ').trim()
119 if (collapsed.length <= maxLength) return collapsed
120 return `${collapsed.slice(0, maxLength)}…`
121}
122hooks/segments.ts 66 lines1// Pure geometry for splitting a draft's body into segments — one per line that already carries a
2// comment (its anchor line), plus the rest after the last one — so mod.ts can draw one `Client`
3// per segment and place that segment's comments right under it. No `$`, no drawing, no
4// `Feedback` value: this file knows only lines, character offsets and anchor line numbers, never
5// a comment's text or how a segment ends up on screen.
6
7import { absoluteOffsetOf, posOf } from './draft-selection'
8
9export type Segment = { index: number; firstLine: number; lastLine: number; baseOffset: number; lines: string[] }
10
11// The line a comment sits on: the line holding the last character of its span, `end - 1`, never
12// below `start` (an empty span still anchors to the line it starts on).
13export function anchorLineOf(lines: readonly string[], span: { start: number; end: number }): number {
14 return posOf(lines, Math.max(span.start, span.end - 1)).line
15}
16
17// `lines` split into segments at each anchor: segment `k` holds the lines after the previous
18// anchor up to and including anchor `k`, in ascending order with duplicates dropped; a final
19// segment holds whatever is left after the last anchor, omitted when that is nothing (the last
20// line was itself an anchor).
21export function segmentsOf(lines: readonly string[], anchorLines: readonly number[]): Segment[] {
22 const maxLine = Math.max(lines.length - 1, 0)
23 const clamped = anchorLines.map((line) => Math.min(Math.max(line, 0), maxLine))
24 const sortedAnchors = [...new Set(clamped)].sort((a, b) => a - b)
25
26 const segments: Segment[] = []
27 let firstLine = 0
28 for (const anchor of sortedAnchors) {
29 segments.push({
30 index: segments.length,
31 firstLine,
32 lastLine: anchor,
33 baseOffset: absoluteOffsetOf(lines, { line: firstLine, col: 0 }),
34 lines: lines.slice(firstLine, anchor + 1),
35 })
36 firstLine = anchor + 1
37 }
38 if (firstLine <= maxLine) {
39 segments.push({
40 index: segments.length,
41 firstLine,
42 lastLine: maxLine,
43 baseOffset: absoluteOffsetOf(lines, { line: firstLine, col: 0 }),
44 lines: lines.slice(firstLine, maxLine + 1),
45 })
46 }
47 return segments
48}
49
50// `range`, given as offsets local to `segment`'s own text, converted to offsets into the whole
51// body — the shape `SpanComment` and `Feedback.selection` keep.
52export function toAbsolute(segment: Segment, range: { start: number; end: number }): { start: number; end: number } {
53 return { start: segment.baseOffset + range.start, end: segment.baseOffset + range.end }
54}
55
56// The inverse of `toAbsolute`, or `null` when `range` (offsets into the whole body) does not lie
57// inside `segment`'s own text — the case a `Client` outside the one the person dragged in must
58// draw no highlight at all.
59export function toLocal(segment: Segment, range: { start: number; end: number }): { start: number; end: number } | null {
60 const start = range.start - segment.baseOffset
61 const end = range.end - segment.baseOffset
62 const textLength = segment.lines.join('\n').length
63 if (start < 0 || end > textLength) return null
64 return { start, end }
65}
66hooks/draft-selection.ts 325 lines1// A `Client` surface module (loaded by the engine from `mod.ts`'s `Client({ module:
2// './draft-selection.ts' })`, never handed `$`): draws one draft's body (the hooks module
3// passes `lines`) and turns a mouse drag over it into a character range, posted to the hooks
4// module on release.
5//
6// This module draws the text itself, rather than overlaying a plain `Text` it stands in for, so
7// it can highlight inside it — where a drag covers it, or where `armedRange` says the hooks
8// module already armed. The highlighted `armedRange` is the span the person selected and has
9// not yet commented on.
10//
11// Must NOT know about: what a draft is, or what the posted range is used for (arming a quote is
12// the hooks module's job, driven by what this file posts through `surface.post`).
13
14import type { ClientElements, ClientModule, ClientSurface, RenderElement } from 'claude-code'
15
16export type DraftSelectionProps = {
17 lines: readonly string[]
18 // What the hooks module already armed from a past drag over this same text, as absolute
19 // offsets (the same shape a 'selected' message posts) — undefined when nothing is armed here.
20 // Drawn as a persistent highlight while no new drag is in progress, and a click landing
21 // inside it, with no movement, is how the person drops it (see onPointerOf's 'up' handling).
22 armedRange?: { start: number; end: number }
23}
24
25export type Pos = { line: number; col: number }
26export type OrderedRange = { start: Pos; end: Pos }
27export type SelectionMessage = { type: 'selected'; start: number; end: number } | { type: 'cleared' }
28
29// One screen row: which logical line it comes from, where in that line it starts, and the
30// characters it carries. A logical line the surface would otherwise soft-wrap is split into
31// several of these by `visualRowsOf`, so the module (not the surface) decides where each screen
32// row breaks — and a pointer's `y` can then index this list directly instead of `lines` itself.
33export type VisualRow = { line: number; startCol: number; text: string }
34
35type State = { anchor: Pos; current: Pos } | null
36
37// A position from a pointer event, or from the drag's own memory, clamped onto real text: a
38// line within `lines` (0 when there are none) and a column within that line (its length at
39// most, so a click past the end of a short line still lands somewhere real).
40export function clampPos(lines: readonly string[], pos: Pos): Pos {
41 const line = Math.min(Math.max(pos.line, 0), Math.max(lines.length - 1, 0))
42 const col = Math.min(Math.max(pos.col, 0), lines[line]?.length ?? 0)
43 return { line, col }
44}
45
46function isBefore(a: Pos, b: Pos): boolean {
47 return a.line < b.line || (a.line === b.line && a.col < b.col)
48}
49
50// The drag's two ends, clamped and put in reading order — a drag that moved up or left of
51// where it started is still `start <= end`, so every other function here never sees a
52// backwards range.
53export function orderedRangeOf(lines: readonly string[], a: Pos, b: Pos): OrderedRange {
54 const clampedA = clampPos(lines, a)
55 const clampedB = clampPos(lines, b)
56 return isBefore(clampedB, clampedA) ? { start: clampedB, end: clampedA } : { start: clampedA, end: clampedB }
57}
58
59// A range whose two ends landed on the same cell: a click, not a drag. Posted as `cleared`
60// rather than an empty `selected`, so the hooks module never arms a zero-length quote.
61export function isEmptyRange(range: OrderedRange): boolean {
62 return range.start.line === range.end.line && range.start.col === range.end.col
63}
64
65// `pos` as a character offset into the text's lines joined by `\n` — the same shape `draft.body`
66// already has, so the hooks module can slice it directly with no line math of its own.
67export function absoluteOffsetOf(lines: readonly string[], pos: Pos): number {
68 let offset = 0
69 for (let i = 0; i < pos.line; i += 1) offset += (lines[i]?.length ?? 0) + 1
70 return offset + pos.col
71}
72
73// The inverse of absoluteOffsetOf: an absolute character offset (as `armedRange` carries) back
74// to a line and column, so a range the hooks module already armed can be drawn the same way a
75// live drag is.
76export function posOf(lines: readonly string[], offset: number): Pos {
77 let remaining = offset
78 for (let line = 0; line < lines.length; line += 1) {
79 const length = lines[line]?.length ?? 0
80 if (remaining <= length) return { line, col: remaining }
81 remaining -= length + 1
82 }
83 return clampPos(lines, { line: Math.max(lines.length - 1, 0), col: remaining })
84}
85
86// The columns of one line a range covers, or null where the range does not reach that line:
87// the whole line for one strictly between the range's ends, `[0, range.end.col)` or
88// `[range.start.col, length)` for the line the range starts or ends on, both bounds on a
89// single-line range.
90export function selectedColumnsOf(range: OrderedRange, lineLength: number, lineIndex: number): { start: number; end: number } | null {
91 if (lineIndex < range.start.line || lineIndex > range.end.line) return null
92 const start = lineIndex === range.start.line ? range.start.col : 0
93 const end = lineIndex === range.end.line ? range.end.col : lineLength
94 return { start, end }
95}
96
97// The terminal cells one character occupies: 2 for the common CJK / fullwidth ranges (hiragana,
98// katakana, kanji, Hangul, fullwidth Latin and punctuation), 1 otherwise. Not a complete Unicode
99// East Asian Width table (an astral-plane character, a surrogate pair, still counts as two
100// column-1 units here, same as every other index in this file already treats one) — this covers
101// what broke: every column-counting function below treated one character as one cell, so a drag
102// over Japanese text (each character 2 cells wide) landed on the wrong character entirely
103// (measured, real-terminal feedback).
104function cellWidthOf(code: number): number {
105 const isWide =
106 (code >= 0x1100 && code <= 0x115f) ||
107 (code >= 0x2e80 && code <= 0xa4cf) ||
108 (code >= 0xac00 && code <= 0xd7a3) ||
109 (code >= 0xf900 && code <= 0xfaff) ||
110 (code >= 0xff00 && code <= 0xff60) ||
111 (code >= 0xffe0 && code <= 0xffe6)
112 return isWide ? 2 : 1
113}
114
115function displayWidthOf(text: string): number {
116 let width = 0
117 for (let i = 0; i < text.length; i += 1) width += cellWidthOf(text.charCodeAt(i))
118 return width
119}
120
121// The character index at or after `start` where `text`'s display width first reaches or would
122// exceed `maxWidth` cells from `start` — the point a row of at most `maxWidth` columns has to
123// end. Always advances past at least one character past `start`, so a single character wider
124// than `maxWidth` on its own (a 2-cell character at the last column of a 1-column pane) still
125// makes progress instead of looping forever; that one row ends up one cell over budget, the same
126// trade the ASCII hard-break below already makes for a word wider than the whole pane.
127function indexAtWidth(text: string, start: number, maxWidth: number): number {
128 let width = 0
129 for (let i = start; i < text.length; i += 1) {
130 const w = cellWidthOf(text.charCodeAt(i))
131 if (width + w > maxWidth && i > start) return i
132 width += w
133 }
134 return text.length
135}
136
137// The character index in `text` whose cell span covers column `x` (0-based, clamped past the
138// last character to `text.length`) — walks by display width, not character count, so a pointer
139// past a 2-cell character lands after it, not one character short.
140function charIndexAtColumn(text: string, x: number): number {
141 let col = 0
142 for (let i = 0; i < text.length; i += 1) {
143 const w = cellWidthOf(text.charCodeAt(i))
144 if (x < col + w) return i
145 col += w
146 }
147 return text.length
148}
149
150// One logical line, greedily word-wrapped to `columns` cells (measured by display width, not
151// character count): a break lands on the last space at or before the limit, and a single word
152// wider than `columns` hard-breaks by character (the only way to keep every row within the
153// width at all). `startCol` is the real index into the logical line — not reconstructed later by
154// re-joining words — so a wrapped word's own text still slices correctly out of the original
155// line.
156function wrapLineOf(text: string, columns: number): { startCol: number; text: string }[] {
157 if (!Number.isFinite(columns) || columns <= 0 || displayWidthOf(text) <= columns) return [{ startCol: 0, text }]
158 const rows: { startCol: number; text: string }[] = []
159 let rowStart = 0
160 while (rowStart < text.length) {
161 const limit = indexAtWidth(text, rowStart, columns)
162 if (limit >= text.length) {
163 rows.push({ startCol: rowStart, text: text.slice(rowStart, limit) })
164 break
165 }
166 let breakAt = -1
167 for (let i = limit; i > rowStart; i -= 1) {
168 if (text[i] === ' ') {
169 breakAt = i
170 break
171 }
172 }
173 if (breakAt === -1) {
174 rows.push({ startCol: rowStart, text: text.slice(rowStart, limit) })
175 rowStart = limit
176 } else {
177 rows.push({ startCol: rowStart, text: text.slice(rowStart, breakAt) })
178 rowStart = breakAt + 1
179 }
180 }
181 return rows
182}
183
184// Every logical line wrapped to `columns` cells, in order. `columns` is the surface's own
185// `columns` (0 before its first layout); passed on as `Infinity` for that one frame, which
186// wraps nothing and lets the surface soft-wrap on its own, same as before this file drew its
187// own rows — the real width arrives on the next call and this takes over from there.
188export function visualRowsOf(lines: readonly string[], columns: number): VisualRow[] {
189 const rows: VisualRow[] = []
190 lines.forEach((line, index) => {
191 for (const row of wrapLineOf(line, columns)) rows.push({ line: index, startCol: row.startCol, text: row.text })
192 })
193 return rows
194}
195
196// A pointer event's cell, as a position in the logical text: `event.y` indexes `visualRows`
197// directly (each is exactly one screen row, by construction), and `event.x` lands within that
198// row's own slice of its logical line, offset by where the row starts. `charIndexAtColumn` (not
199// a plain clamp of `x` itself) is what makes this correct for a row containing any 2-cell
200// character: `x` is a terminal column, not a character index, and the two only coincide when
201// every character on the row is 1 cell wide.
202function screenPosOf(visualRows: readonly VisualRow[], event: { x: number; y: number }): Pos {
203 const rowIndex = Math.min(Math.max(event.y, 0), Math.max(visualRows.length - 1, 0))
204 const row = visualRows[rowIndex]
205 if (row === undefined) return { line: 0, col: 0 }
206 const col = charIndexAtColumn(row.text, Math.max(event.x, 0))
207 return { line: row.line, col: row.startCol + col }
208}
209
210// The range a drag covers on one screen row, or null where the row is not covered at all: the
211// same logical-line span `selectedColumnsOf` reports, intersected with the row's own
212// `[startCol, startCol + text.length)` slice and rebased to that row's local columns.
213function rowSelectionOf(range: OrderedRange, row: VisualRow, lineLength: number): { start: number; end: number } | null {
214 const cols = selectedColumnsOf(range, lineLength, row.line)
215 if (cols === null) return null
216 const start = Math.max(cols.start, row.startCol)
217 const end = Math.min(cols.end, row.startCol + row.text.length)
218 if (start > end) return null
219 return { start: start - row.startCol, end: end - row.startCol }
220}
221
222// One row: plain text, or split into an unhighlighted prefix, an inverse-video run for the
223// covered part, and an unhighlighted suffix. A zero-width `columns` on a non-empty row (the
224// cell right after 'down', before any 'move') draws as plain text — inserting a one-space
225// highlighted run there, as a real (non-empty) selection does, turned the clicked character
226// into a false blank (measured: real-terminal feedback). A zero-width `columns` on a genuinely
227// empty row (one a multi-line drag covers in full) still draws as one highlighted space, so a
228// blank line inside a real selection still shows as covered. No `wrap` prop: every row already
229// fits `columns` by construction (visualRowsOf), so there is nothing left to cut or wrap.
230function lineRowOf(elements: ClientElements, key: string, text: string, columns: { start: number; end: number } | null): RenderElement {
231 const { Box, Text } = elements
232 const isFalseBlank = columns !== null && columns.start === columns.end && text !== ''
233 if (columns === null || isFalseBlank) {
234 return Box({ key, children: [Text({ children: text === '' ? ' ' : text })] })
235 }
236 const before = text.slice(0, columns.start)
237 const selected = text.slice(columns.start, columns.end)
238 const after = text.slice(columns.end)
239 const children: RenderElement[] = []
240 if (before !== '') children.push(Text({ children: before }))
241 children.push(Text({ inverse: true, children: selected === '' ? ' ' : selected }))
242 if (after !== '') children.push(Text({ children: after }))
243 return Box({ key, flexDirection: 'row', children })
244}
245
246// The pointer handler: 'down' starts a drag at the cell under the pointer, 'move' extends it
247// (ignored before a 'down' started one — a hover with no button held), 'up' posts what the drag
248// covered and ends it. A drag that never moved (a plain click) posts `cleared` whenever
249// something is armed here, anywhere in this text — not only a click landing inside the
250// highlight — since a click outside it dropping nothing read as unnatural (real-terminal
251// feedback: clicking away from a selection is how clearing one usually works). Registered fresh
252// on every call, which is how each render's `state` reaches the closure without a stale one
253// from an earlier call.
254function onPointerOf(
255 lines: readonly string[],
256 visualRows: readonly VisualRow[],
257 state: State,
258 armedRange: { start: number; end: number } | undefined,
259 setState: (next: State) => void,
260 post: (data: SelectionMessage) => void,
261) {
262 return (event: { type: string; x: number; y: number }) => {
263 if (event.type === 'down') {
264 const pos = screenPosOf(visualRows, event)
265 setState({ anchor: pos, current: pos })
266 return
267 }
268 if (event.type === 'move') {
269 if (state === null) return
270 const pos = screenPosOf(visualRows, event)
271 setState({ anchor: state.anchor, current: pos })
272 return
273 }
274 if (event.type === 'up') {
275 if (state === null) return
276 // The 'up' event carries its own cell, same as 'down' and 'move' do — read it directly
277 // rather than trusting a 'move' to have landed there first. A drag with no 'move' in
278 // between (a fast release, or a terminal that only sends 'move' on real movement) would
279 // otherwise end up comparing the anchor to itself and reporting an empty range.
280 const pos = screenPosOf(visualRows, event)
281 const range = orderedRangeOf(lines, state.anchor, pos)
282 if (isEmptyRange(range)) {
283 if (armedRange !== undefined) post({ type: 'cleared' })
284 } else {
285 post({ type: 'selected', start: absoluteOffsetOf(lines, range.start), end: absoluteOffsetOf(lines, range.end) })
286 }
287 setState(null)
288 }
289 }
290}
291
292// `surface` is the real `ClientSurface` from the engine, or (in a test) any object shaped
293// like one — the module never reaches for anything else on it.
294export function drawDraftSelection(
295 props: DraftSelectionProps,
296 surface: Pick<ClientSurface<State>, 'elements' | 'state' | 'setState' | 'onPointer' | 'post' | 'columns'>,
297): RenderElement {
298 const { elements, state, setState, onPointer, post, columns } = surface
299 const lines = props.lines
300 const armedRange = props.armedRange
301 const visualRows = visualRowsOf(lines, columns > 0 ? columns : Number.POSITIVE_INFINITY)
302
303 onPointer(onPointerOf(lines, visualRows, state ?? null, armedRange, setState, (data) => post(data)))
304
305 // A live drag takes over the drawing; otherwise an already-armed range (from a past drag)
306 // stays highlighted, so the person can see what is about to ride their next prompt without
307 // holding the mouse down.
308 const dragRange = state == null ? null : orderedRangeOf(lines, state.anchor, state.current)
309 const armedAsRange = armedRange === undefined ? null : { start: posOf(lines, armedRange.start), end: posOf(lines, armedRange.end) }
310 const range = dragRange ?? armedAsRange
311
312 const { Box } = elements
313 return Box({
314 key: 'root',
315 flexDirection: 'column',
316 children: visualRows.map((row, index) =>
317 lineRowOf(elements, `row:${index}`, row.text, range === null ? null : rowSelectionOf(range, row, lines[row.line]?.length ?? 0)),
318 ),
319 })
320}
321
322const DraftSelection: ClientModule<DraftSelectionProps, State> = (props, surface) => drawDraftSelection(props, surface)
323
324export default DraftSelection
325