SLOPSHOPPER

draft-pane

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.

newpanecommandstatus
v0.4.0MITupdated 2026-09-19meganemura/draft-pane/plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · draft-pane
│ ┃ draft-pane ✕ › fix the failing auth test and add an audit log call │ ┃ no open drafts │ ┃ ⏺ Read(src/auth.ts) │ ┃ ⎿ 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 │ ┃ │ ┃ › /draft-pane │ ┃ ⎿ draft-pane: draft-pane shown │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · draft-pane
no open drafts
README

draft-pane

test

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.

What it looks like

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

Requirements

  • Claude Code 2.1.273 or later, with CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1

Install

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

Use

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 draft block

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.

The feedback prompt

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.

What Approve means

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.

The transcript is the source of truth

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.

Development

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.

License

MIT. See LICENSE.

Source 5 files
hooks/mod.ts 674 lines
1// 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}
674
hooks/block.ts 220 lines
1// 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}
220
hooks/feedback.ts 122 lines
1// 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}
122
hooks/segments.ts 66 lines
1// 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}
66
hooks/draft-selection.ts 325 lines
1// 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