SLOPSHOPPER

pull-request-pane

A pane beside the transcript with the GitHub pull requests and issues related to the current session: drag a title or description to leave a comment on it, add…

newpaneguardcommandstatusprocess
★ 1v0.3.0MITupdated 2026-09-18meganemura/pull-request-pane/plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pull-request-pane
│ ┃ pull-request-pane ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ [ ↻ refreshed 10:33:02 PM ] ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ no related pull request or issue ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ status 10:33:02 PM ⏺ 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 │ │ › /pull-request-pane │ ⎿ pull-request-pane: pull-request-pane shown │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · pull-request-pane
[ ↻ refreshed 10:33:02 PM ] no related pull request or issue status 10:33:02 PM
README

pull-request-pane

test

The pane beside the transcript, showing a merged pull request with its checks passing

A Claude Code plugin (a Claude Mod) that shows the GitHub pull requests related to the current session in a pane beside the transcript.

  • Drag over an entry's title or description to leave a comment on that span. Add as many as you like, across either field, plus one comment on the entry as a whole, then press Submit to send them all as one prompt.
  • While the pane is open, each pull request's checks, review decision and mergeability refresh on a timer.

Issues that a pull request closes, and issues the transcript mentions, appear as entries too, with a line between a run of pull requests and a run of issues so the two do not read as one list of the same kind of thing.

Requirements

  • Claude Code 2.1.273 or later with CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1
  • gh logged in to GitHub

Install

claude plugin marketplace add meganemura/pull-request-pane
claude plugin install pull-request-pane@pull-request-pane

Set CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 for every session, instead of prefixing each claude invocation, by adding it to settings.json's env:

{
  "env": {
    "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1"
  }
}

Type /pull-request-pane to show or hide the pane.

To develop against a checkout instead, run the plugin straight from its working tree:

CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir /path/to/pull-request-pane/plugin

Refresh

A ↻ refreshed <time> button sits above the entries (↻ reading… before the first one lands). Press it to refetch every entry's title, description and checks right now, instead of waiting for the next automatic refresh or the 60-second poll — the poll's own schedule restarts from the press. An entry with review activity in progress (see below) is protected from this, the same way it is protected from the automatic refresh a turn or a gh command triggers.

Leave review comments

Each entry shows its identifier (#<n> PR <state> for a pull request, #<n> Issue <state> for an issue) as a link to it on GitHub — hover it and it highlights — then a blank line, its bold title, another blank line, its checks (a pull request only, its status word bold too), another blank line, then its full description. Drag over the title or the description to open a comment box for that span: the covered text highlights as you drag, and releasing opens a comment field under the entry with the span quoted above it. Type a comment and press Enter to add it — the field is dropped, and the comment appears in a list under the entry, with an x button to remove it. Press Enter with an empty field to drop the span with no comment. Repeat as many times as you like, over either field; an always-present overall field takes one comment on the entry as a whole, the same way.

Press Submit to send every comment for that entry as one prompt: a header naming the entry, then each comment with the text it was about quoted above it, the overall comment last. Submit refuses — the status line says why — while a comment field still holds text Enter has not added, or with nothing to send at all.

An entry's title and description stop refreshing while it has any review activity (a pending selection, a committed comment, or unsent text in a comment field), so nothing a comment quotes changes out from under it before you press Submit. Checks, review decision and mergeability keep updating live on their own 60-second poll regardless — they have nothing to do with the text.

Checks, review and mergeability

Each pull request's status is fetched once as soon as the pane opens, and every 60 seconds after that while it stays open. A pull request with no status yet shows fetching checks…; the footer below the entries reads status updating… while a fetch is in flight, status <time> once it lands. Once fetched, it draws as a ▶ checks toggle and one coloured word — failing (red), running (yellow), passing (green), or no checks — so you can tell at a glance whether to look further. Press the toggle for the detail: a summary line (✓<pass> ✗<fail> …<pending> · <review decision> · <mergeable>), then each check by name, coloured by its own outcome and linked to its run (GitHub Actions, CircleCI, whatever produced it) where one exists — hover a linked check and it highlights, so it reads as clickable. Issues have no status.

Each entry's description is drawn in full below its title and status. A line wider than the pane wraps onto as many screen rows as it needs, and a drag's position still matches the right character across the wrap.

Persistence

Opening the pane, or the pane's own data changing, is saved to this plugin's own store, so a reload of this file (developing against it under --plugin-dir, or any other reload the engine does on its own) shows the last known entries right away instead of reading… — a background refresh catches up from there once you next interact with the session.

Status

Early access. The function-hooks API can change between Claude Code releases without notice.

Source 3 files
hooks/mod.ts 1107 lines
1// The plugin's one function-hooks module (the validator admits one per plugin). `/pull-
2// request-pane` opens a pane beside the transcript with the pull requests and issues related
3// to this session: the checked-out branch's pull request first, then the issues its body
4// closes, then anything the transcript names. A drag over an entry's title or description opens
5// a comment box for that span; Enter adds it to the entry's own list, any number of times,
6// across either field. A second, always-present box takes one comment on the entry as a whole.
7// Pressing Submit sends every comment for that entry as one prompt that quotes each span, in
8// place of the person's own next prompt — this is a review pane, not a quoting one, so the
9// comments are the instruction (see docs/decisions/0014, superseding 0004's "ride the next
10// prompt as context").
11//
12// While the pane is open, a 60-second timer refetches each pull request's checks, review
13// decision and mergeability and draws them beside the entry; the timer starts when the pane
14// opens and stops when it closes.
15//
16// Must NOT know about: how a comment gets acted on (that is the model's job, driven by the
17// prompt Submit sends, never this file's); GitHub authentication (`gh auth status` failing is
18// shown as a line in the pane, not handled); paragraph- or line-level selection (a later
19// milestone).
20//
21// It loads only where Claude Code has function hooks enabled. The engine's validator reads
22// this file statically, so every call on `$` is spelled `$.noun.event(...)` and `$` is handed
23// only to the function declarations at the top of the file; the rest of the module holds a
24// `Host`, a bundle of closures built once at `session.start`.
25
26import type { Elements, On, RenderElement, SessionMessage, Timer } from 'claude-code'
27import {
28  EMPTY_REVIEW,
29  WHOLE_LABEL,
30  commentCountOf,
31  feedbackTextOf,
32  hasUnsentTextOf,
33  selectionMessageOf,
34  shortQuoteOf,
35  withSelection,
36  withSpanCommitted,
37  withSpanRemoved,
38  withSpanText,
39  withWholeCommitted,
40  withWholeRemoved,
41  withWholeText,
42} from './review'
43import type { Field, Review } from './review'
44
45const PANE_ID = 'pull-request-pane'
46const PANE_TITLE = 'pull-request-pane'
47const COMMAND = 'pull-request-pane'
48
49// `gh` reaches the network; this bounds a hung call, not a slow one.
50const GH_TIMEOUT_MS = 15_000
51
52const POLL_MS = 60_000
53
54const NOT_IN_REPOSITORY_TEXT = 'not in a GitHub repository'
55const NO_RELATED_TEXT = 'no related pull request or issue'
56
57// The pane's own right padding, named once: everything inside it (rows, entries, the Clients at
58// `width: '100%'`) draws into `bodyColumns` less this, so the kind divider is sized the same way
59// or it ends up one cell wider than everything around it and wraps onto a second row.
60const PANE_PADDING_RIGHT = 1
61
62// The closing-keyword set the spec names, one `#<n>` per match, case-insensitive.
63const CLOSE_KEYWORD_RE = /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\b\s*#(\d+)/gi
64
65// A CheckRun's `conclusion` values the spec sorts into fail and skipped; anything else
66// completed reads as pass, and an incomplete or absent conclusion reads as pending.
67const FAIL_CONCLUSIONS = new Set(['FAILURE', 'CANCELLED', 'TIMED_OUT', 'ACTION_REQUIRED', 'STARTUP_FAILURE'])
68const SKIP_CONCLUSIONS = new Set(['SKIPPED', 'NEUTRAL'])
69
70type Entry = {
71  kind: 'pr' | 'issue'
72  number: number
73  title: string
74  body: string
75  url: string
76  state: string
77  status?: PrStatus
78}
79
80// Filled by the status poll; undefined until the first one lands after the pane opens.
81type PrStatus = {
82  isDraft: boolean
83  mergeable: string
84  reviewDecision: string
85  checks: { pass: number; fail: number; pending: number; skipped: number }
86  checkItems: CheckItem[]
87  fetchedAt: string
88}
89
90type CheckOutcome = 'pass' | 'fail' | 'pending' | 'skipped'
91
92// One check by name, for the expanded list — gh's rollup has no stable id to key on, so the
93// name (falling back to its position) is what a re-render matches against.
94type CheckItem = { name: string; outcome: CheckOutcome; url?: string }
95
96type GhRecord = { number: number; title: string; body: string; url: string; state: string }
97
98// One `statusCheckRollup` element: a CheckRun (`name`, `status`, `conclusion`, `detailsUrl`) or
99// a StatusContext (`context`, `state`, `targetUrl`) — gh's two shapes for one check, told apart
100// by which fields are present.
101type CheckRollupItem = { name?: string; context?: string; status?: string; conclusion?: string | null; state?: string; detailsUrl?: string; targetUrl?: string }
102
103type Host = {
104  cwd: () => Promise<string>
105  messages: () => Promise<readonly SessionMessage[]>
106  run: (argv: readonly string[], cwd: string) => Promise<{ exitCode: number; stdout: string; stderr: string }>
107  submit: (text: string) => Promise<{ drop?: string }>
108  status: (text: string | undefined) => void
109  every: (ms: number, fn: () => void) => Timer
110  open: () => Promise<void>
111  close: () => Promise<void>
112  invalidate: () => void
113  log: (text: string) => void
114  register: () => Promise<unknown>
115  storeGet: (key: string) => Promise<unknown>
116  storeSet: (key: string, value: unknown) => Promise<void>
117  focus: (key: string) => Promise<unknown>
118}
119
120type State = {
121  host: Host | null
122  isOpen: boolean
123  repo: string | null
124  entries: Entry[]
125  error: string | null
126  refreshedAt: string | null
127  isRefreshing: boolean
128  isQueued: boolean
129  // One entry's pending review comments, by `entryKeyOf`. An entry with nothing in it (yet) is
130  // not a key here — see `reviewOf`, which hands back `EMPTY_REVIEW` for one that is missing.
131  review: Map<string, Review>
132  isSubmitting: boolean
133  expandedStatus: Set<string>
134  pollTimer: Timer | null
135  isPolling: boolean
136  statusAt: string | null
137}
138
139// The host is a bundle of closures over `$`, built once at `session.start`, so the rest of
140// this file never holds `$` itself. That is the validator's rule and also the seam a test
141// fakes: every world a test builds stubs these same calls with `on(...)`.
142function hostOf($: any): Host {
143  return {
144    cwd: () => $.session.cwd(),
145    messages: () => $.session.messages(),
146    run: (argv, cwd) => $.process.run(argv, { cwd, timeoutMs: GH_TIMEOUT_MS }),
147    submit: (text) => $.prompt.submit({ text }),
148    status: (text) => $.ui.status(text),
149    every: (ms, fn) => $.clock.every(ms, fn),
150    open: () => $.ui.open({ id: PANE_ID, title: PANE_TITLE }),
151    close: () => $.ui.close({ id: PANE_ID }),
152    invalidate: () => $.ui.invalidate('ui.render'),
153    log: (text) => $.ui.log(text),
154    register: () => $.command.register({ name: COMMAND, description: 'Show or hide the pull-request-pane' }),
155    storeGet: (key) => $.store.get(key),
156    storeSet: (key, value) => $.store.set(key, value),
157    focus: (key) => $.ui.focus({ requestId: PANE_ID, key }),
158  }
159}
160
161function messageOf(error: unknown): string {
162  return error instanceof Error ? error.message : String(error)
163}
164
165function firstLineOf(text: string): string {
166  const line = text.split('\n').find((candidate) => candidate.trim() !== '')
167  return (line ?? text).trim()
168}
169
170function entryKeyOf(entry: Pick<Entry, 'kind' | 'number'>): string {
171  return `${entry.kind}:${entry.number}`
172}
173
174// `$.store` survives a hot reload of this module (real-terminal feedback: while iterating on
175// this file, or after Claude Code otherwise reloads it, the pane would drop back to `reading…`
176// even though it had already shown real entries a moment before). Persisted after every
177// successful `refresh`, read back once at `session.start`, so a redraw that lands before this
178// session's own first `refresh` completes still shows the last known state instead of nothing.
179const STORE_KEY = 'snapshot'
180
181type Snapshot = { repo: string | null; entries: Entry[]; refreshedAt: string | null }
182
183function isEntry(value: unknown): value is Entry {
184  if (typeof value !== 'object' || value === null) return false
185  const kind = Reflect.get(value, 'kind')
186  return (
187    (kind === 'pr' || kind === 'issue') &&
188    typeof Reflect.get(value, 'number') === 'number' &&
189    typeof Reflect.get(value, 'title') === 'string' &&
190    typeof Reflect.get(value, 'body') === 'string' &&
191    typeof Reflect.get(value, 'url') === 'string' &&
192    typeof Reflect.get(value, 'state') === 'string'
193  )
194}
195
196// What came out of the store is code's own past write, not the engine's word — validated the
197// same way `selectionMessageOf` treats a Client's post, since a version this file has since
198// changed the shape of could still be sitting there.
199function snapshotFromStore(value: unknown): Snapshot | null {
200  if (typeof value !== 'object' || value === null) return null
201  const repo = Reflect.get(value, 'repo')
202  const entries = Reflect.get(value, 'entries')
203  const refreshedAt = Reflect.get(value, 'refreshedAt')
204  if (repo !== null && typeof repo !== 'string') return null
205  if (!Array.isArray(entries) || !entries.every(isEntry)) return null
206  if (refreshedAt !== null && typeof refreshedAt !== 'string') return null
207  return { repo, entries, refreshedAt }
208}
209
210function reviewOf(state: Pick<State, 'review'>, key: string): Review {
211  return state.review.get(key) ?? EMPTY_REVIEW
212}
213
214// True while an entry's review has anything a refresh must not disturb: a pending selection, a
215// committed span (its offsets point into the frozen text), an overall comment, or unsent text
216// still sitting in an Input. `commentCountOf` alone would miss the first and the last of those.
217function hasReviewActivityOf(review: Review): boolean {
218  return review.selection !== null || review.spans.length > 0 || review.whole !== null || hasUnsentTextOf(review)
219}
220
221// While an entry has review activity, its title or body must not change under the person: a
222// span's offsets are computed against one version of that text, and refreshing it mid-review — a
223// real edit landing on GitHub, or just a re-fetch of the same content under a new object — would
224// leave an offset pointing at the wrong thing, or a quote in a Submit's prompt reading as
225// something the person never actually selected.
226export function withReviewPreserved(state: Pick<State, 'review' | 'entries'>, freshEntries: Entry[]): Entry[] {
227  const activeKeys = new Set([...state.review].filter(([, review]) => hasReviewActivityOf(review)).map(([key]) => key))
228  if (activeKeys.size === 0) return freshEntries
229  return freshEntries.map((entry) => {
230    const key = entryKeyOf(entry)
231    if (!activeKeys.has(key)) return entry
232    return state.entries.find((candidate) => entryKeyOf(candidate) === key) ?? entry
233  })
234}
235
236// A key with review activity whose entry is missing from `priorEntries` fell out of
237// `state.entries` since it was last set — usually a transient `collectEntries` error that
238// blanked the list — so `withReviewPreserved` could not freeze its text and handed back the
239// fresh entry instead. Keeping that review would risk a committed span's offsets slicing the
240// wrong characters out of text they were never actually drawn from, and a Submit quoting it
241// silently. Dropping it is the same trade 0014 accepted for a manual refresh, just reached a
242// different way.
243export function withOrphanedReviewDropped(review: ReadonlyMap<string, Review>, priorEntries: readonly Entry[]): Map<string, Review> {
244  const priorKeys = new Set(priorEntries.map(entryKeyOf))
245  const next = new Map(review)
246  for (const [key, entryReview] of review) {
247    if (hasReviewActivityOf(entryReview) && !priorKeys.has(key)) next.delete(key)
248  }
249  return next
250}
251
252
253// Step 2 of the collection order, and also the cheap gate `command.run` uses to decide
254// whether there is anything to open a pane over: a failing `git rev-parse` here is read the
255// same way whether the cause is "not a repository" or a detached, ref-less checkout.
256async function branchOf(host: Host, cwd: string): Promise<string | null> {
257  try {
258    const { exitCode, stdout } = await host.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], cwd)
259    return exitCode === 0 ? stdout.trim() : null
260  } catch {
261    return null
262  }
263}
264
265type RepoResult = { kind: 'ok'; repo: string } | { kind: 'error'; message: string }
266
267// Step 1. `gh repo view` fails the same way for "no git remote" and for "no GitHub remote";
268// its stderr is the only signal this file has to tell that apart from "gh is missing or
269// unauthenticated", so a stderr naming a remote reads as the friendlier, generic text and
270// anything else is shown as `gh` left it.
271async function repoOf(host: Host, cwd: string): Promise<RepoResult> {
272  try {
273    const { exitCode, stdout, stderr } = await host.run(
274      ['gh', 'repo', 'view', '--json', 'nameWithOwner', '-q', '.nameWithOwner'],
275      cwd,
276    )
277    if (exitCode !== 0) {
278      const line = firstLineOf(stderr || stdout)
279      return { kind: 'error', message: /remote/i.test(line) ? NOT_IN_REPOSITORY_TEXT : line }
280    }
281    return { kind: 'ok', repo: stdout.trim() }
282  } catch (error) {
283    return { kind: 'error', message: firstLineOf(messageOf(error)) }
284  }
285}
286
287// Step 3: the branch's own pull requests, at most 5, oldest decision-relevant fields only.
288async function branchPrsOf(host: Host, cwd: string, branch: string): Promise<Entry[]> {
289  try {
290    const { exitCode, stdout } = await host.run(
291      ['gh', 'pr', 'list', '--head', branch, '--state', 'all', '--json', 'number,title,body,url,state', '--limit', '5'],
292      cwd,
293    )
294    if (exitCode !== 0) return []
295    const parsed = JSON.parse(stdout) as GhRecord[]
296    return parsed.map((pr) => ({ kind: 'pr' as const, number: pr.number, title: pr.title, body: pr.body, url: pr.url, state: pr.state }))
297  } catch {
298    return []
299  }
300}
301
302// Step 6: an issue first, a pull request on its failure. A number that answers neither is
303// dropped rather than failing the whole collection — one stale reference should not blank
304// the pane for every other entry.
305async function fetchEntryOf(host: Host, cwd: string, number: number): Promise<Entry | null> {
306  const issue = await ghViewOf(host, cwd, 'issue', number)
307  if (issue) return issue
308  return ghViewOf(host, cwd, 'pr', number)
309}
310
311async function ghViewOf(host: Host, cwd: string, kind: 'issue' | 'pr', number: number): Promise<Entry | null> {
312  try {
313    const { exitCode, stdout } = await host.run([...['gh', kind, 'view', String(number)], '--json', 'number,title,body,url,state'], cwd)
314    if (exitCode !== 0) return null
315    const parsed = JSON.parse(stdout) as GhRecord
316    return { kind, number: parsed.number, title: parsed.title, body: parsed.body, url: parsed.url, state: parsed.state }
317  } catch {
318    return null
319  }
320}
321
322// Step 4: closing-keyword numbers out of a PR body, and the digits in the branch name.
323function closingNumbersOf(body: string): number[] {
324  return [...body.matchAll(CLOSE_KEYWORD_RE)].map((match) => Number(match[1]))
325}
326
327function branchNumbersOf(branch: string): number[] {
328  return [...branch.matchAll(/\d+/g)].map((match) => Number(match[0]))
329}
330
331function escapeRegExp(text: string): string {
332  return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
333}
334
335// Step 5: `#n` (assumed this repository) and full GitHub URLs naming this repository, out of
336// the transcript's text.
337function transcriptNumbersOf(messages: readonly SessionMessage[], repo: string): number[] {
338  const numbers: number[] = []
339  const urlRe = new RegExp(`github\\.com/${escapeRegExp(repo)}/(?:pull|issues)/(\\d+)`, 'gi')
340  for (const message of messages) {
341    for (const match of message.text.matchAll(/#(\d+)/g)) numbers.push(Number(match[1]))
342    for (const match of message.text.matchAll(urlRe)) numbers.push(Number(match[1]))
343  }
344  return numbers
345}
346
347type CollectResult = { kind: 'ok'; repo: string; entries: Entry[] } | { kind: 'error'; message: string }
348
349// Steps 1-7: gather, dedupe (branch PR, the issues it closes, then the transcript's, first
350// occurrence wins), and fetch. Every failure short of "not a repository" (already refused by
351// `command.run` before this runs) resolves here rather than throwing, so a bad `gh` call
352// leaves the pane with one line instead of leaving the hook to fail open silently.
353async function collectEntries(host: Host, cwd: string, branch: string): Promise<CollectResult> {
354  const repoResult = await repoOf(host, cwd)
355  if (repoResult.kind === 'error') return repoResult
356  const repo = repoResult.repo
357
358  const seen = new Set<number>()
359  const entries: Entry[] = []
360
361  const branchPrs = await branchPrsOf(host, cwd, branch)
362  for (const entry of branchPrs) {
363    if (seen.has(entry.number)) continue
364    seen.add(entry.number)
365    entries.push(entry)
366  }
367
368  const candidateNumbers = [...branchPrs.flatMap((pr) => closingNumbersOf(pr.body)), ...branchNumbersOf(branch)]
369  for (const number of candidateNumbers) {
370    if (seen.has(number)) continue
371    seen.add(number)
372    const entry = await fetchEntryOf(host, cwd, number)
373    if (entry) entries.push(entry)
374  }
375
376  const messages = await host.messages()
377  for (const number of transcriptNumbersOf(messages, repo)) {
378    if (seen.has(number)) continue
379    seen.add(number)
380    const entry = await fetchEntryOf(host, cwd, number)
381    if (entry) entries.push(entry)
382  }
383
384  if (entries.length === 0) return { kind: 'error', message: NO_RELATED_TEXT }
385  return { kind: 'ok', repo, entries }
386}
387
388// The header line for one entry's Submit prompt: `Feedback (pull-request-pane) on PR #42:` or
389// `... on Issue #7:`. GitHub's PR/Issue wording is this file's own job — review.ts's
390// `feedbackTextOf` only orders, quotes and joins what this hands it.
391export const FEEDBACK_HEADER_PREFIX = 'Feedback (pull-request-pane) on '
392
393export function reviewHeaderOf(entry: Pick<Entry, 'kind' | 'number'>): string {
394  const kindWord = entry.kind === 'pr' ? 'PR' : 'Issue'
395  return `${FEEDBACK_HEADER_PREFIX}${kindWord} #${entry.number}:`
396}
397
398function sourceOf(entry: Pick<Entry, 'title' | 'body'>, field: Field): string {
399  return field === 'title' ? entry.title : entry.body
400}
401
402// The spec's aggregation, item by item: a CheckRun (has `conclusion`) is read by its
403// conclusion, a StatusContext (has `state` and no `conclusion`) by its state; a CheckRun still
404// running (no conclusion yet) and a StatusContext still pending both fall into `pending`.
405function outcomeOf(item: CheckRollupItem): CheckOutcome {
406  const isCheckRun = item.conclusion !== undefined || item.status !== undefined
407  if (isCheckRun) {
408    const conclusion = item.conclusion ?? null
409    if (conclusion === 'SUCCESS') return 'pass'
410    if (conclusion !== null && FAIL_CONCLUSIONS.has(conclusion)) return 'fail'
411    if (conclusion !== null && SKIP_CONCLUSIONS.has(conclusion)) return 'skipped'
412    return 'pending'
413  }
414  if (item.state === 'SUCCESS') return 'pass'
415  if (item.state === 'FAILURE' || item.state === 'ERROR') return 'fail'
416  return 'pending'
417}
418
419function checksOf(items: readonly CheckRollupItem[]): PrStatus['checks'] {
420  const checks = { pass: 0, fail: 0, pending: 0, skipped: 0 }
421  for (const item of items) checks[outcomeOf(item)] += 1
422  return checks
423}
424
425// A CheckRun names itself `name`; a StatusContext, the older commit-status shape, names itself
426// `context`. Neither is guaranteed present (gh's schema marks both nullable), so a position
427// falls back for the rare rollup entry with no name of its own.
428function checkItemsOf(items: readonly CheckRollupItem[]): CheckItem[] {
429  return items.map((item, index) => {
430    const url = item.detailsUrl ?? item.targetUrl
431    return { name: item.name ?? item.context ?? `check ${index + 1}`, outcome: outcomeOf(item), ...(url === undefined ? {} : { url }) }
432  })
433}
434
435type GhPrStatusRecord = { isDraft: boolean; mergeable: string; reviewDecision: string; statusCheckRollup: CheckRollupItem[] }
436
437async function fetchStatusOf(host: Host, cwd: string, number: number): Promise<PrStatus | null> {
438  try {
439    const { exitCode, stdout } = await host.run(
440      ['gh', 'pr', 'view', String(number), '--json', 'isDraft,mergeable,reviewDecision,statusCheckRollup'],
441      cwd,
442    )
443    if (exitCode !== 0) return null
444    const parsed = JSON.parse(stdout) as GhPrStatusRecord
445    const items = parsed.statusCheckRollup ?? []
446    return {
447      isDraft: parsed.isDraft,
448      mergeable: parsed.mergeable,
449      reviewDecision: parsed.reviewDecision,
450      checks: checksOf(items),
451      checkItems: checkItemsOf(items),
452      fetchedAt: new Date().toLocaleTimeString(),
453    }
454  } catch {
455    return null
456  }
457}
458
459// Not the entry re-collection `refresh` does: only the status of the pull requests already on
460// screen. A PR whose fetch fails keeps its last known status rather than losing it, so one bad
461// `gh pr view` does not blank a status the previous poll drew.
462async function pollStatuses(state: State): Promise<void> {
463  const host = state.host
464  if (host === null || state.isPolling) return
465  state.isPolling = true
466  host.invalidate()
467  try {
468    const cwd = await host.cwd()
469    // Not skipped for an entry with review activity, unlike withReviewPreserved: this only ever
470    // replaces `status`, never `title` or `body`, so it cannot move the text an offset points
471    // into — there is nothing here for a review to protect against (the lead's own correction,
472    // having first paused this too).
473    const numbers = state.entries.filter((entry) => entry.kind === 'pr').map((entry) => entry.number)
474    for (const number of numbers) {
475      const status = await fetchStatusOf(host, cwd, number)
476      if (status === null) continue
477      state.entries = state.entries.map((entry) => (entry.kind === 'pr' && entry.number === number ? { ...entry, status } : entry))
478    }
479    state.statusAt = new Date().toLocaleTimeString()
480  } finally {
481    state.isPolling = false
482    host.invalidate()
483  }
484}
485
486function startPoll(state: State, host: Host): void {
487  if (state.pollTimer !== null) return
488  state.pollTimer = host.every(POLL_MS, () => void pollStatuses(state).catch(() => undefined))
489}
490
491function stopPoll(state: State): void {
492  state.pollTimer?.cancel()
493  state.pollTimer = null
494}
495
496// Coalesced, headsign's shape exactly: a refresh asked for while one runs is run once more
497// after it, not in parallel, and there is no separate debounce timer.
498async function refresh(state: State): Promise<void> {
499  const host = state.host
500  if (host === null) return
501  if (state.isRefreshing) {
502    state.isQueued = true
503    return
504  }
505  state.isRefreshing = true
506  try {
507    do {
508      state.isQueued = false
509      const cwd = await host.cwd()
510      const branch = await branchOf(host, cwd)
511      const result = branch === null ? { kind: 'error' as const, message: NOT_IN_REPOSITORY_TEXT } : await collectEntries(host, cwd, branch)
512      state.refreshedAt = new Date().toLocaleTimeString()
513      if (result.kind === 'ok') {
514        state.repo = result.repo
515        const priorEntries = state.entries
516        state.entries = withReviewPreserved(state, result.entries)
517        state.review = withOrphanedReviewDropped(state.review, priorEntries)
518        state.error = null
519        const snapshot: Snapshot = { repo: state.repo, entries: state.entries, refreshedAt: state.refreshedAt }
520        void host.storeSet(STORE_KEY, snapshot).catch(() => undefined)
521      } else {
522        state.entries = []
523        state.error = result.message
524      }
525      host.invalidate()
526    } while (state.isQueued)
527  } finally {
528    state.isRefreshing = false
529  }
530}
531
532// The refresh button's own ask, distinct from the automatic refreshes `turn.complete` and the
533// `gh ` Bash hook already trigger: all three preserve an entry with review activity
534// (`withReviewPreserved`, inside `refresh`) so a drag or a half-written comment in progress does
535// not have its offsets invalidated, or its text lost, out from under it. Restarts the poll timer
536// too, so the person is not left waiting up to another `POLL_MS` for the checks fetch this same
537// press just asked for. `refresh` before `pollStatuses`, not together: `refresh` replaces
538// `state.entries` wholesale from `gh pr list`, whose records carry no `status` field, so
539// running the two concurrently let `refresh` finish after `pollStatuses` and overwrite the
540// status it had just written in (checked: run concurrently, the test below saw `fetching
541// checks…` where it expects `no checks`) — the opposite of what asking for both at once is for.
542async function manualRefresh(state: State, host: Host): Promise<void> {
543  stopPoll(state)
544  startPoll(state, host)
545  host.invalidate()
546  await refresh(state)
547  await pollStatuses(state)
548}
549
550// The real element types, so the typecheck refuses a prop the engine would refuse. `Text`
551// takes no `key`: giving it one drops the whole tree (measured, see the probe this file
552// replaced), so only `Box`, `Button`, `Input` and `Client` below ever carry one. `Link` takes no
553// `key` either (not in its props), so it is never a direct array child — always inside a keyed
554// `Box`.
555type Ui = Pick<Elements['terminal'], 'Box' | 'Button' | 'Text' | 'Link' | 'Input' | 'Client'>
556
557// The `element` key suffix for each field's Client — distinct and non-overlapping (neither is a
558// suffix of the other), so the `ui.message` hook can tell them apart by a plain `endsWith`
559// check with no ordering dependency.
560const TITLE_SELECT_SUFFIX = ':title-select'
561const BODY_SELECT_SUFFIX = ':body-select'
562
563// A drag over this draws its own coloured selection (description-selection.ts, reused for both
564// the title and the description); this surface has no absolute positioning (checked: no
565// `position`, `top`, `left` or `zIndex` in BoxProps), so the Client draws the text itself rather
566// than sitting over a separate `Text` rendering of it. Posts a `SelectionMessage` on release,
567// read by the `ui.message` hook in `register`. `pendingRange` is the entry's own pending
568// selection for this field, undefined otherwise, so a past drag's highlight survives a redraw,
569// not just the moment the mouse button is held.
570function textSelectionOf(
571  ui: Ui,
572  key: string,
573  suffix: string,
574  text: string,
575  pendingRange: { start: number; end: number } | undefined,
576  bold: boolean,
577): RenderElement {
578  const lines = text.split('\n')
579  return ui.Client({
580    key: `${key}${suffix}`,
581    module: './description-selection.ts',
582    props: { lines, ...(pendingRange === undefined ? {} : { armedRange: pendingRange }), ...(bold ? { bold: true } : {}) },
583    width: '100%',
584  })
585}
586
587// The entry's pending selection, for one field, or undefined when nothing is pending (or it is
588// pending on the other field).
589function pendingRangeFor(review: Review, field: Field): { start: number; end: number } | undefined {
590  const selection = review.selection
591  if (selection === null || selection.field !== field) return undefined
592  return { start: selection.start, end: selection.end }
593}
594
595// `⏭` (U+23ED) reads as an emoji glyph in some terminal fonts and rendered noticeably wider
596// than the one cell the surface allots it, overlapping the character that followed it
597// (real-terminal feedback). `~` is plain ASCII: no font can draw it wider than one cell.
598const SKIPPED_SYMBOL = '~'
599
600function checksSegmentOf(checks: PrStatus['checks']): string {
601  const parts: string[] = []
602  if (checks.pass > 0) parts.push(`✓${checks.pass}`)
603  if (checks.fail > 0) parts.push(`✗${checks.fail}`)
604  if (checks.pending > 0) parts.push(`…${checks.pending}`)
605  if (checks.skipped > 0) parts.push(`${SKIPPED_SYMBOL}${checks.skipped}`)
606  return parts.join(' ')
607}
608
609// Red beats yellow beats green: one failing check makes the line red even if the rest passed.
610function statusColorOf(checks: PrStatus['checks']): string | undefined {
611  if (checks.fail > 0) return 'red'
612  if (checks.pending > 0) return 'yellow'
613  if (checks.pass > 0) return 'green'
614  return undefined
615}
616
617// The summary line, once expanded: counts, review decision, mergeable state. Left uncoloured
618// (dim, like the description) — a single colour for the whole line said "everything here is
619// this one status", which was wrong the moment more than one check disagreed with the rest;
620// each check's own colour lives on its own row below instead.
621function summaryLineOf(ui: Ui, status: PrStatus): RenderElement {
622  const { Text } = ui
623  const segments = [checksSegmentOf(status.checks), status.reviewDecision, status.mergeable].filter((segment) => segment !== '')
624  return Text({ dimColor: true, children: segments.join(' · ') })
625}
626
627function statusWordOf(checks: PrStatus['checks']): string {
628  if (checks.fail > 0) return 'failing'
629  if (checks.pending > 0) return 'running'
630  if (checks.pass > 0) return 'passing'
631  return 'no checks'
632}
633
634function outcomeSymbolOf(outcome: CheckOutcome): string {
635  if (outcome === 'pass') return '✓'
636  if (outcome === 'fail') return '✗'
637  if (outcome === 'skipped') return SKIPPED_SYMBOL
638  return '…'
639}
640
641function outcomeColorOf(outcome: CheckOutcome): string | undefined {
642  if (outcome === 'pass') return 'green'
643  if (outcome === 'fail') return 'red'
644  if (outcome === 'pending') return 'yellow'
645  return undefined
646}
647
648// A cyan neither outcome colour uses, so hovering a link reads as "this is clickable" and not
649// as the check's state changing under the pointer.
650const LINK_HOVER_COLOR = 'cyan'
651
652// One row per check: the symbol carries the outcome's colour, the name stays the surface's
653// plain text colour so a hover's colour is the only colour change it ever shows — a name
654// already coloured red or green buried a hover highlight in a colour-on-colour change that was
655// hard to see (measured, real terminal). Wrapped in a `Link` to the check's own run when gh
656// gave one (a CheckRun's `detailsUrl`, a StatusContext's `targetUrl`); plain text otherwise.
657// The hover colour needs the enclosing `Box` to be keyed (the d.ts refuses it outside one),
658// which this row's `Box` already is.
659function checkItemLineOf(ui: Ui, key: string, item: CheckItem): RenderElement {
660  const { Box, Link, Text } = ui
661  const color = outcomeColorOf(item.outcome)
662  const symbol = Text({ ...(color === undefined ? {} : { color }), children: outcomeSymbolOf(item.outcome) })
663  const name = Text({ ...(item.url === undefined ? {} : { hover: { color: LINK_HOVER_COLOR } }), children: ` ${item.name}` })
664  const row = [symbol, name]
665  const content = item.url === undefined ? row : [Link({ href: item.url, children: row })]
666  return Box({ key, flexDirection: 'row', children: content })
667}
668
669// Collapsed by default: one word (coloured, so red/yellow/green reads before the word does)
670// answers "is anything failing, still running, or all clear" without reading numbers. Expanded,
671// each check draws its own name and outcome — pressing the toggle was the point of asking for
672// them, so the names are what expanding buys, not just the same summary spelled out.
673function checksSectionOf(ui: Ui, key: string, status: PrStatus, state: State, host: Host): RenderElement[] {
674  const { Box, Button, Text } = ui
675  const isExpanded = state.expandedStatus.has(key)
676  const color = statusColorOf(status.checks)
677  const toggleKey = `${key}:checks-toggle`
678
679  const toggleRow = Box({
680    key: toggleKey,
681    flexDirection: 'row',
682    columnGap: 1,
683    children: [
684      Button({
685        key: `${toggleKey}:button`,
686        label: `${isExpanded ? '▼' : '▶'} checks`,
687        onPress: () => {
688          if (isExpanded) state.expandedStatus.delete(key)
689          else state.expandedStatus.add(key)
690          host.invalidate()
691        },
692      }),
693      Text({ ...(color === undefined ? {} : { color }), bold: true, children: statusWordOf(status.checks) }),
694    ],
695  })
696
697  if (!isExpanded) return [toggleRow]
698
699  return [
700    toggleRow,
701    summaryLineOf(ui, status),
702    ...status.checkItems.map((item, index) => checkItemLineOf(ui, `${key}:check:${index}`, item)),
703  ]
704}
705
706// A PR whose status has not landed yet says so, rather than leaving a gap the same as an
707// issue's — the poll is already running (see `command.run`'s immediate `pollStatuses`), so
708// this is a "coming" state, not a "there is nothing here" one.
709function checksRowsOf(ui: Ui, key: string, entry: Entry, state: State, host: Host): RenderElement[] {
710  if (entry.kind !== 'pr') return []
711  if (entry.status) return checksSectionOf(ui, key, entry.status, state, host)
712  return [ui.Text({ dimColor: true, children: 'fetching checks…' })]
713}
714
715// The identifier line is a `Link` to the entry's own GitHub page — hovering it highlights (the
716// same cyan every other link in this pane uses), so it reads as clickable the same way a linked
717// check does. Wrapped in its own keyed Box: the hover colour is refused outside one.
718function identifierRowOf(ui: Ui, key: string, entry: Entry): RenderElement {
719  const { Box, Link, Text } = ui
720  const kindWord = entry.kind === 'pr' ? 'PR' : 'Issue'
721  const label = `#${entry.number} ${kindWord} ${entry.state}`
722  return Box({
723    key: `${key}:identifier`,
724    children: [Link({ href: entry.url, children: [Text({ hover: { color: LINK_HOVER_COLOR }, children: label })] })],
725  })
726}
727
728const REFRESH_BUTTON_KEY = 'refresh'
729
730// Moved to the top of the pane and turned into a button (asked for, in place of the plain
731// `refreshed <time>` line the footer used to end with): pressing it is an explicit ask for the
732// latest entries and checks right now, not just a status readout. See `manualRefresh` for what
733// a press actually does.
734function refreshButtonOf(ui: Ui, state: State, host: Host): RenderElement {
735  const { Box, Button } = ui
736  const label = state.refreshedAt === null ? '↻ reading…' : `↻ refreshed ${state.refreshedAt}`
737  return Box({
738    key: REFRESH_BUTTON_KEY,
739    marginBottom: 1,
740    children: [Button({ key: `${REFRESH_BUTTON_KEY}:button`, label, onPress: () => void manualRefresh(state, host).catch(() => undefined) })],
741  })
742}
743
744// Marks where the list crosses from a pull request to an issue or back, so the two do not read
745// as one undivided list of the same kind of thing (asked for: the branch's own closing issues
746// and the transcript's mentions mix pull requests and issues together with nothing between them).
747// Sized to `bodyColumns`, the render input's own cells-across-the-body figure — its d.ts names
748// this exact use ("size a table or a rule to it rather than to `viewport.columns`").
749function kindDividerOf(ui: Ui, bodyColumns: number): RenderElement {
750  return ui.Text({ dimColor: true, children: '─'.repeat(Math.max(bodyColumns - PANE_PADDING_RIGHT, 0)) })
751}
752
753// `isSubmitting` guards a press arriving while a previous submit is still in flight. Refusing
754// with unsent text, or with zero comments, guards the other press patterns: focus already on
755// Submit, one Enter that would otherwise spend a whole turn on an empty or half-written prompt.
756async function submitReview(state: State, host: Host, entry: Entry): Promise<void> {
757  if (state.isSubmitting) return
758  const key = entryKeyOf(entry)
759  const review = reviewOf(state, key)
760  if (hasUnsentTextOf(review)) {
761    host.status(`#${entry.number} has text in a comment box; press Enter to add it, or clear it`)
762    return
763  }
764  if (commentCountOf(review) === 0) {
765    host.status(`#${entry.number} has no comments to submit`)
766    return
767  }
768
769  state.isSubmitting = true
770  try {
771    const text = feedbackTextOf(reviewHeaderOf(entry), { title: entry.title, body: entry.body }, review.spans, review.whole)
772    const result = await host.submit(text)
773    if (result.drop === undefined) {
774      state.review.delete(key)
775      host.status(undefined)
776      host.invalidate()
777    }
778    // A `drop` leaves the entry's review exactly where it was, so the person can press Submit
779    // again without redoing anything.
780  } finally {
781    state.isSubmitting = false
782  }
783}
784
785// The transitions behind an `Input` or a `Button`, each one line so the closure drawn beside it
786// stays one line too (the test kit cannot type into an `Input`, so these are what
787// review.test.ts's plain functions cover instead). No `host.invalidate()` on an `onInput`: the
788// `Input` already shows what the person types, so redrawing on every keystroke is wasted work,
789// and it can move the cursor out from under the person's hands. The mirror kept in `state.review`
790// exists so a redraw triggered by something else (a `turn.complete`, another entry's own action)
791// hands the typed text back rather than losing it.
792function onSpanTextInput(state: State, key: string, text: string): void {
793  state.review.set(key, withSpanText(reviewOf(state, key), text))
794}
795
796function onSpanTextSubmit(state: State, host: Host, key: string, text: string): void {
797  state.review.set(key, withSpanCommitted(reviewOf(state, key), text))
798  host.invalidate()
799}
800
801function onSpanRemove(state: State, host: Host, key: string, index: number): void {
802  state.review.set(key, withSpanRemoved(reviewOf(state, key), index))
803  host.invalidate()
804}
805
806function onWholeTextInput(state: State, key: string, text: string): void {
807  state.review.set(key, withWholeText(reviewOf(state, key), text))
808}
809
810function onWholeTextSubmit(state: State, host: Host, key: string, text: string): void {
811  state.review.set(key, withWholeCommitted(reviewOf(state, key), text))
812  host.invalidate()
813}
814
815function onWholeRemove(state: State, host: Host, key: string): void {
816  state.review.set(key, withWholeRemoved(reviewOf(state, key)))
817  host.invalidate()
818}
819
820function commentRowOf(ui: Ui, key: string, quote: string, comment: string, onRemove: () => void): RenderElement {
821  const { Box, Button, Text } = ui
822  return Box({
823    key,
824    flexDirection: 'row',
825    columnGap: 1,
826    children: [Button({ key: `${key}:remove`, label: 'x', onPress: onRemove }), Text({ dimColor: true, children: `> ${quote}` }), Text({ children: comment })],
827  })
828}
829
830// The pending selection's own comment box: a quote line above an `Input`. Only one of an entry's
831// two textSelectionOf rows can have posted the selection this draws from — `pendingRangeFor`
832// decided which one draws the live highlight, and `selection.field` says which text to slice.
833function pendingCommentRowOf(ui: Ui, state: State, host: Host, entry: Entry, key: string, review: Review): RenderElement | null {
834  const selection = review.selection
835  if (selection === null) return null
836  const { Box, Input, Text } = ui
837  const text = sourceOf(entry, selection.field).slice(selection.start, selection.end)
838  return Box({
839    key: `${key}:span-input-row`,
840    flexDirection: 'column',
841    children: [
842      Text({ dimColor: true, children: `> ${shortQuoteOf(text)}` }),
843      Input({
844        key: `${key}:span-input`,
845        label: 'comment',
846        placeholder: 'Enter adds it; empty Enter drops the selection',
847        value: review.spanText,
848        autoFocus: true,
849        onInput: (value) => onSpanTextInput(state, key, value),
850        onSubmit: (value) => onSpanTextSubmit(state, host, key, value),
851      }),
852    ],
853  })
854}
855
856// Every span already committed, each with its quote and its `x` remove button, then the one
857// overall comment (if any), the same shape with its own remove button and no quote to draw.
858function committedRowsOf(ui: Ui, state: State, host: Host, entry: Entry, key: string, review: Review): RenderElement {
859  const { Box, Button, Text } = ui
860  const rows: RenderElement[] = review.spans.map((span, index) =>
861    commentRowOf(ui, `${key}:c${index}`, shortQuoteOf(sourceOf(entry, span.field).slice(span.start, span.end)), span.comment, () =>
862      onSpanRemove(state, host, key, index),
863    ),
864  )
865  if (review.whole !== null) {
866    const whole = review.whole
867    rows.push(
868      Box({
869        key: `${key}:whole`,
870        flexDirection: 'row',
871        columnGap: 1,
872        children: [Button({ key: `${key}:whole:remove`, label: 'x', onPress: () => onWholeRemove(state, host, key) }), Text({ children: `${WHOLE_LABEL} ${whole}` })],
873      }),
874    )
875  }
876  return Box({ key: `${key}:comments`, flexDirection: 'column', children: rows })
877}
878
879// Always drawn, whether or not anything else is pending: the one place to leave a comment that
880// is not about any particular span.
881function wholeInputRowOf(ui: Ui, state: State, host: Host, key: string, review: Review): RenderElement {
882  const { Box, Input } = ui
883  return Box({
884    key: `${key}:whole-input-row`,
885    children: [
886      Input({
887        key: `${key}:whole-input`,
888        label: 'overall',
889        placeholder: 'a comment on the whole entry',
890        value: review.wholeText,
891        onInput: (value) => onWholeTextInput(state, key, value),
892        onSubmit: (value) => onWholeTextSubmit(state, host, key, value),
893      }),
894    ],
895  })
896}
897
898function submitRowOf(ui: Ui, state: State, host: Host, entry: Entry, key: string, review: Review): RenderElement {
899  const { Box, Button, Text } = ui
900  const n = commentCountOf(review)
901  return Box({
902    key: `${key}:actions`,
903    flexDirection: 'row',
904    columnGap: 1,
905    children: [
906      Button({
907        key: `${key}:submit`,
908        label: 'Submit',
909        onPress: () => {
910          submitReview(state, host, entry).catch((error: unknown) => host.log(`pull-request-pane: submit failed: ${messageOf(error)}`))
911        },
912      }),
913      Text({ dimColor: true, children: n === 1 ? '1 comment' : `${n} comments` }),
914    ],
915  })
916}
917
918// No button to arm the whole entry: a drag already covers all of a field's text the same way
919// (docs/decisions/0007). The title and description are each their own textSelectionOf row, both
920// drag-selectable, then whatever the review has going: a pending selection's own comment box,
921// every comment already committed, the always-present overall-comment box, and Submit.
922// `rowGap` separates the identifier, the title, the checks (grouped into one child so the gap
923// lands around them, not between each check line) and the description from each other — asked
924// for, to make the entry easier to read at a glance.
925function entryBoxOf(ui: Ui, entry: Entry, state: State, host: Host): RenderElement {
926  const { Box } = ui
927  const key = entryKeyOf(entry)
928  const review = reviewOf(state, key)
929  const checksRows = checksRowsOf(ui, key, entry, state, host)
930  const pendingRow = pendingCommentRowOf(ui, state, host, entry, key, review)
931
932  return Box({
933    key,
934    flexDirection: 'column',
935    rowGap: 1,
936    children: [
937      identifierRowOf(ui, key, entry),
938      textSelectionOf(ui, key, TITLE_SELECT_SUFFIX, entry.title, pendingRangeFor(review, 'title'), true),
939      ...(checksRows.length === 0 ? [] : [Box({ key: `${key}:checks`, flexDirection: 'column', children: checksRows })]),
940      textSelectionOf(ui, key, BODY_SELECT_SUFFIX, entry.body, pendingRangeFor(review, 'description'), false),
941      ...(pendingRow === null ? [] : [pendingRow]),
942      committedRowsOf(ui, state, host, entry, key, review),
943      wholeInputRowOf(ui, state, host, key, review),
944      submitRowOf(ui, state, host, entry, key, review),
945    ],
946  })
947}
948
949function paneOf(ui: Ui, state: State, host: Host, bodyColumns: number): RenderElement {
950  const { Box, Text } = ui
951  const rows: RenderElement[] = []
952  if (state.error !== null) {
953    rows.push(Text({ color: 'red', children: state.error }))
954  } else {
955    state.entries.forEach((entry, index) => {
956      const previous = state.entries[index - 1]
957      if (previous !== undefined && previous.kind !== entry.kind) rows.push(kindDividerOf(ui, bodyColumns))
958      rows.push(entryBoxOf(ui, entry, state, host))
959    })
960  }
961
962  const statusLine = state.isPolling ? 'status updating…' : state.statusAt === null ? null : `status ${state.statusAt}`
963  const footerLines = statusLine === null ? [] : [Text({ dimColor: true, children: statusLine })]
964
965  // `rowGap` between entries (and a divider counts as one of the rows it separates), not
966  // before the first or after the last — a blank line between one entry and the next.
967  const children: RenderElement[] = [refreshButtonOf(ui, state, host), Box({ flexDirection: 'column', rowGap: 1, children: rows })]
968  if (footerLines.length > 0) children.push(Box({ flexDirection: 'column', marginTop: 1, children: footerLines }))
969
970  return Box({
971    key: 'pull-request-pane',
972    flexDirection: 'column',
973    paddingTop: 1,
974    paddingRight: PANE_PADDING_RIGHT,
975    children,
976  })
977}
978
979export function register(on: On) {
980  const state: State = {
981    host: null,
982    isOpen: false,
983    repo: null,
984    entries: [],
985    error: null,
986    refreshedAt: null,
987    isRefreshing: false,
988    isQueued: false,
989    review: new Map(),
990    isSubmitting: false,
991    expandedStatus: new Set(),
992    pollTimer: null,
993    isPolling: false,
994    statusAt: null,
995  }
996
997  on('session.start', async ($, e, next) => {
998    state.host = hostOf($)
999    await state.host.register().catch((error: unknown) => {
1000      state.host?.log(`pull-request-pane: /${COMMAND} is not available: ${messageOf(error)}`)
1001    })
1002    // Rehydrates from the last successful `refresh`, in case this session starts because the
1003    // module hot-reloaded while the pane was already open: without this, the pane would show
1004    // `reading…` again for however long the next `refresh` takes, even though it already had
1005    // real entries a moment ago.
1006    const snapshot = snapshotFromStore(await state.host.storeGet(STORE_KEY).catch(() => undefined))
1007    if (snapshot !== null) {
1008      state.repo = snapshot.repo
1009      state.entries = snapshot.entries
1010      state.refreshedAt = snapshot.refreshedAt
1011    }
1012    return next(e)
1013  })
1014
1015  on('command.run', { command: COMMAND }, async ($, e, next) => {
1016    const host = state.host
1017    if (host === null) return next(e)
1018
1019    if (state.isOpen) {
1020      await host.close()
1021      state.isOpen = false
1022      stopPoll(state)
1023      return { text: 'pull-request-pane hidden' }
1024    }
1025
1026    const cwd = await host.cwd()
1027    const branch = await branchOf(host, cwd)
1028    if (branch === null) return { text: NOT_IN_REPOSITORY_TEXT }
1029
1030    await host.open()
1031    state.isOpen = true
1032    startPoll(state, host)
1033    await refresh(state)
1034    // `host.every` only fires after its first full period; without this, the person who just
1035    // opened the pane would wait up to POLL_MS for the first status, not just for the poll
1036    // after that. Not awaited: entries are already drawn, and the checks section shows its own
1037    // "fetching checks…" line until this lands.
1038    void pollStatuses(state).catch(() => undefined)
1039    return { text: 'pull-request-pane shown' }
1040  })
1041
1042  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
1043    if (e.requestId !== PANE_ID || state.host === null) return next(e)
1044    // `Client` is a terminal-only element (not every surface's table has one — checked: this
1045    // is the only branch this plugin ever draws into, since it opens its pane with no surface
1046    // override, but the guard also narrows `$.ui.resolve`'s return type to `Elements['terminal']`.
1047    if (e.surface !== 'terminal') return next(e)
1048    const { Box, Button, Text, Link, Input, Client } = await $.ui.resolve(e)
1049    return paneOf({ Box, Button, Text, Link, Input, Client }, state, state.host, e.props.bodyColumns)
1050  })
1051
1052  on('ui.close', { id: PANE_ID }, async ($, e, next) => {
1053    const result = await next(e)
1054    if (result.deny === undefined) {
1055      state.isOpen = false
1056      stopPoll(state)
1057    }
1058    return result
1059  })
1060
1061  on('turn.complete', ($, e, next) => {
1062    if (state.isOpen) void refresh(state).catch(() => undefined)
1063    return next(e)
1064  })
1065
1066  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
1067    try {
1068      return await next(e)
1069    } finally {
1070      if (state.isOpen && typeof e.command === 'string' && e.command.includes('gh ')) void refresh(state).catch(() => undefined)
1071    }
1072  })
1073
1074  // A description-selection.ts Client posted this on a drag's release ('client' origin, per
1075  // the d.ts: code sent it, on nobody's behalf, so `data` is input to validate, never a fact).
1076  // Its `element` key is `${entryKey}${TITLE_SELECT_SUFFIX}` or `${entryKey}${BODY_SELECT_SUFFIX}`
1077  // (set in textSelectionOf) — the two suffixes are checked in full and neither is a suffix of
1078  // the other, so which one matched tells the field apart from the entry key in one step.
1079  on('ui.message', { requestId: PANE_ID }, async ($, e, next) => {
1080    const host = state.host
1081    if (host === null) return next(e)
1082    const field: Field | null = e.element.endsWith(TITLE_SELECT_SUFFIX) ? 'title' : e.element.endsWith(BODY_SELECT_SUFFIX) ? 'description' : null
1083    if (field === null) return next(e)
1084    const suffix = field === 'title' ? TITLE_SELECT_SUFFIX : BODY_SELECT_SUFFIX
1085    const key = e.element.slice(0, -suffix.length)
1086    const entry = state.entries.find((candidate) => entryKeyOf(candidate) === key)
1087    const message = selectionMessageOf(e.data)
1088    if (entry === undefined || message === null) return next(e)
1089
1090    const review = reviewOf(state, key)
1091    state.review.set(key, withSelection(review, message.type === 'selected' ? { field, start: message.start, end: message.end } : null))
1092    host.invalidate()
1093
1094    if (message.type === 'selected') {
1095      // The `Input` may not be drawn yet when this runs; its own `autoFocus` prop is the second
1096      // path to the same end, so a failure here is not the only way the person's keyboard lands
1097      // on the comment field.
1098      try {
1099        await host.focus(`${key}:span-input`)
1100      } catch (error) {
1101        host.log(`pull-request-pane: focus failed: ${messageOf(error)}`)
1102      }
1103    }
1104    return next(e)
1105  })
1106}
1107
hooks/review.ts 130 lines
1// Pure state for one entry's pending review comments: the span just dragged and not yet
2// commented on, the committed span comments (each into either the title or the description),
3// and the one committed comment on the entry as a whole. No `$`, no hooks — mod.ts is the only
4// caller, and builds the feedback prompt's header itself (GitHub's PR/Issue wording is its job,
5// not this file's). Must not know about `gh`, GitHub, or how a drag is drawn
6// (description-selection.ts).
7
8import type { SelectionMessage } from './description-selection'
9
10export type Field = 'title' | 'description'
11export type Selection = { field: Field; start: number; end: number }
12export type SpanComment = { field: Field; start: number; end: number; comment: string }
13export type ReviewSource = { title: string; body: string }
14
15// `spanText` and `wholeText` mirror what their `Input` holds. Every redraw hands the `Input` its
16// `value` again (the engine has no state of its own for it), so without a mirror here a redraw
17// triggered by a finished turn or a fresh drag on another entry would hand the `Input` back an
18// empty string and silently erase what the person had already typed.
19export type Review = {
20  selection: Selection | null
21  spanText: string
22  spans: SpanComment[]
23  whole: string | null
24  wholeText: string
25}
26
27export const EMPTY_REVIEW: Review = { selection: null, spanText: '', spans: [], whole: null, wholeText: '' }
28
29export function withSelection(review: Review, selection: Selection | null): Review {
30  return { ...review, selection, spanText: '' }
31}
32
33export function withSpanText(review: Review, text: string): Review {
34  return { ...review, spanText: text }
35}
36
37// No selection: nothing to attach the comment to, so the commit is a no-op. An empty Enter (only
38// whitespace) is how the person cancels a drag — it drops the selection and adds nothing, rather
39// than committing a blank comment.
40export function withSpanCommitted(review: Review, text: string): Review {
41  const selection = review.selection
42  if (selection === null) return review
43  const trimmed = text.trim()
44  if (trimmed === '') return { ...review, selection: null, spanText: '' }
45  return { ...review, selection: null, spanText: '', spans: [...review.spans, { ...selection, comment: trimmed }] }
46}
47
48export function withSpanRemoved(review: Review, index: number): Review {
49  if (index < 0 || index >= review.spans.length) return review
50  return { ...review, spans: [...review.spans.slice(0, index), ...review.spans.slice(index + 1)] }
51}
52
53export function withWholeText(review: Review, text: string): Review {
54  return { ...review, wholeText: text }
55}
56
57// `whole` holds at most one comment: a second Enter replaces the first rather than adding a
58// second. An empty Enter removes it.
59export function withWholeCommitted(review: Review, text: string): Review {
60  const trimmed = text.trim()
61  return { ...review, whole: trimmed === '' ? null : trimmed, wholeText: '' }
62}
63
64export function withWholeRemoved(review: Review): Review {
65  return { ...review, whole: null }
66}
67
68export function commentCountOf(review: Review): number {
69  return review.spans.length + (review.whole === null ? 0 : 1)
70}
71
72// True while an Input holds text Enter has not yet turned into a span or overall comment — the
73// case a Submit press must refuse, or that text would be lost with no word said.
74export function hasUnsentTextOf(review: Review): boolean {
75  return review.spanText.trim() !== '' || review.wholeText.trim() !== ''
76}
77
78// The quote line drawn beside a committed span's comment: the slice collapsed to one line (a
79// dragged span can cross a newline) and cut short so a long quote does not crowd out the comment
80// beside it.
81export function shortQuoteOf(text: string, maxLength = 40): string {
82  const collapsed = text.replace(/\s+/g, ' ').trim()
83  if (collapsed.length <= maxLength) return collapsed
84  return `${collapsed.slice(0, maxLength)}…`
85}
86
87function quoteOf(text: string): string {
88  return text
89    .split('\n')
90    .map((line) => '> ' + line)
91    .join('\n')
92}
93
94export const WHOLE_LABEL = '(overall)'
95
96// The order a Submit press's prompt lists spans in: title spans before description spans, each
97// group by ascending `start` — not the order the person committed them in, so a title comment
98// added after a description one still reads title-first.
99export function orderedSpansOf(spans: readonly SpanComment[]): SpanComment[] {
100  return [...spans].sort((a, b) => (a.field === b.field ? a.start - b.start : a.field === 'title' ? -1 : 1))
101}
102
103// The prompt one Submit press sends: `header`, then for each span (ordered by `orderedSpansOf`)
104// the quoted slice of `source`'s matching field and the comment on its own line, then, when
105// `whole` is given, one `(overall) <comment>` line. `header` is the caller's job (mod.ts, which
106// alone knows GitHub's PR/Issue wording) — this only slices, quotes, orders and joins.
107export function feedbackTextOf(header: string, source: ReviewSource, spans: readonly SpanComment[], whole: string | null): string {
108  const lines: string[] = [header]
109  for (const span of orderedSpansOf(spans)) {
110    const text = (span.field === 'title' ? source.title : source.body).slice(span.start, span.end)
111    lines.push(quoteOf(text))
112    lines.push(span.comment)
113  }
114  if (whole !== null) lines.push(`${WHOLE_LABEL} ${whole}`)
115  return lines.join('\n')
116}
117
118// `data` came from a description-selection.ts Client's post — code sent it, not the engine — so
119// this is the one place that message is checked before mod.ts trusts its shape.
120export function selectionMessageOf(data: unknown): SelectionMessage | null {
121  if (typeof data !== 'object' || data === null) return null
122  const type = Reflect.get(data, 'type')
123  if (type === 'cleared') return { type: 'cleared' }
124  if (type !== 'selected') return null
125  const start = Reflect.get(data, 'start')
126  const end = Reflect.get(data, 'end')
127  if (typeof start !== 'number' || typeof end !== 'number' || start < 0 || end < start) return null
128  return { type: 'selected', start, end }
129}
130
hooks/description-selection.ts 331 lines
1// A `Client` surface module (loaded by the engine from `mod.ts`'s `Client({ module:
2// './description-selection.ts' })`, never handed `$`): draws one entry's text (its description,
3// or its title — the hooks module decides which by the `element` key it posts under) and turns
4// a mouse drag over it into a character range, posted to the hooks module on release.
5//
6// No absolute positioning exists on this surface's `Box` (checked: no `position`, `top`,
7// `left` or `zIndex` in BoxProps), so this replaces the plain `Text` lines it stands in for
8// rather than overlaying them — it draws the text itself, highlighted where a drag covers it or
9// where `armedRange` says the hooks module already has a pending selection.
10//
11// Must NOT know about: GitHub, `gh`, or what the posted range is used for (what happens to a
12// selection is 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 DescriptionSelectionProps = {
17  lines: readonly string[]
18  // What the hooks module already has pending from a past drag over this same text, as absolute
19  // offsets (the same shape a 'selected' message posts) — undefined when nothing is pending 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  // The hooks module's own decoration (bold, for a title, to set it apart from the body it
24  // sits above) — this file draws it, but never decides it.
25  bold?: boolean
26}
27
28export type Pos = { line: number; col: number }
29export type OrderedRange = { start: Pos; end: Pos }
30export type SelectionMessage = { type: 'selected'; start: number; end: number } | { type: 'cleared' }
31
32// One screen row: which logical line it comes from, where in that line it starts, and the
33// characters it carries. A logical line the surface would otherwise soft-wrap is split into
34// several of these by `visualRowsOf`, so the module (not the surface) decides where each screen
35// row breaks — and a pointer's `y` can then index this list directly instead of `lines` itself.
36export type VisualRow = { line: number; startCol: number; text: string }
37
38type State = { anchor: Pos; current: Pos } | null
39
40// A position from a pointer event, or from the drag's own memory, clamped onto real text: a
41// line within `lines` (0 when there are none) and a column within that line (its length at
42// most, so a click past the end of a short line still lands somewhere real).
43export function clampPos(lines: readonly string[], pos: Pos): Pos {
44  const line = Math.min(Math.max(pos.line, 0), Math.max(lines.length - 1, 0))
45  const col = Math.min(Math.max(pos.col, 0), lines[line]?.length ?? 0)
46  return { line, col }
47}
48
49function isBefore(a: Pos, b: Pos): boolean {
50  return a.line < b.line || (a.line === b.line && a.col < b.col)
51}
52
53// The drag's two ends, clamped and put in reading order — a drag that moved up or left of
54// where it started is still `start <= end`, so every other function here never sees a
55// backwards range.
56export function orderedRangeOf(lines: readonly string[], a: Pos, b: Pos): OrderedRange {
57  const clampedA = clampPos(lines, a)
58  const clampedB = clampPos(lines, b)
59  return isBefore(clampedB, clampedA) ? { start: clampedB, end: clampedA } : { start: clampedA, end: clampedB }
60}
61
62// A range whose two ends landed on the same cell: a click, not a drag. Posted as `cleared`
63// rather than an empty `selected`, so the hooks module never arms a zero-length quote.
64export function isEmptyRange(range: OrderedRange): boolean {
65  return range.start.line === range.end.line && range.start.col === range.end.col
66}
67
68// `pos` as a character offset into the text's lines joined by `\n` — the same shape `entry.body`
69// (or `entry.title`) already has, so the hooks module can slice it directly with no line math
70// of its own.
71export function absoluteOffsetOf(lines: readonly string[], pos: Pos): number {
72  let offset = 0
73  for (let i = 0; i < pos.line; i += 1) offset += (lines[i]?.length ?? 0) + 1
74  return offset + pos.col
75}
76
77// The inverse of absoluteOffsetOf: an absolute character offset (as `armedRange` carries) back
78// to a line and column, so a range the hooks module already armed can be drawn the same way a
79// live drag is.
80export function posOf(lines: readonly string[], offset: number): Pos {
81  let remaining = offset
82  for (let line = 0; line < lines.length; line += 1) {
83    const length = lines[line]?.length ?? 0
84    if (remaining <= length) return { line, col: remaining }
85    remaining -= length + 1
86  }
87  return clampPos(lines, { line: Math.max(lines.length - 1, 0), col: remaining })
88}
89
90// The columns of one line a range covers, or null where the range does not reach that line:
91// the whole line for one strictly between the range's ends, `[0, range.end.col)` or
92// `[range.start.col, length)` for the line the range starts or ends on, both bounds on a
93// single-line range.
94export function selectedColumnsOf(range: OrderedRange, lineLength: number, lineIndex: number): { start: number; end: number } | null {
95  if (lineIndex < range.start.line || lineIndex > range.end.line) return null
96  const start = lineIndex === range.start.line ? range.start.col : 0
97  const end = lineIndex === range.end.line ? range.end.col : lineLength
98  return { start, end }
99}
100
101// The terminal cells one character occupies: 2 for the common CJK / fullwidth ranges (hiragana,
102// katakana, kanji, Hangul, fullwidth Latin and punctuation), 1 otherwise. Not a complete Unicode
103// East Asian Width table (an astral-plane character, a surrogate pair, still counts as two
104// column-1 units here, same as every other index in this file already treats one) — this covers
105// what broke: every column-counting function below treated one character as one cell, so a drag
106// over Japanese text (each character 2 cells wide) landed on the wrong character entirely
107// (measured, real-terminal feedback).
108function cellWidthOf(code: number): number {
109  const isWide =
110    (code >= 0x1100 && code <= 0x115f) ||
111    (code >= 0x2e80 && code <= 0xa4cf) ||
112    (code >= 0xac00 && code <= 0xd7a3) ||
113    (code >= 0xf900 && code <= 0xfaff) ||
114    (code >= 0xff00 && code <= 0xff60) ||
115    (code >= 0xffe0 && code <= 0xffe6)
116  return isWide ? 2 : 1
117}
118
119function displayWidthOf(text: string): number {
120  let width = 0
121  for (let i = 0; i < text.length; i += 1) width += cellWidthOf(text.charCodeAt(i))
122  return width
123}
124
125// The character index at or after `start` where `text`'s display width first reaches or would
126// exceed `maxWidth` cells from `start` — the point a row of at most `maxWidth` columns has to
127// end. Always advances past at least one character past `start`, so a single character wider
128// than `maxWidth` on its own (a 2-cell character at the last column of a 1-column pane) still
129// makes progress instead of looping forever; that one row ends up one cell over budget, the same
130// trade the ASCII hard-break below already makes for a word wider than the whole pane.
131function indexAtWidth(text: string, start: number, maxWidth: number): number {
132  let width = 0
133  for (let i = start; i < text.length; i += 1) {
134    const w = cellWidthOf(text.charCodeAt(i))
135    if (width + w > maxWidth && i > start) return i
136    width += w
137  }
138  return text.length
139}
140
141// The character index in `text` whose cell span covers column `x` (0-based, clamped past the
142// last character to `text.length`) — walks by display width, not character count, so a pointer
143// past a 2-cell character lands after it, not one character short.
144function charIndexAtColumn(text: string, x: number): number {
145  let col = 0
146  for (let i = 0; i < text.length; i += 1) {
147    const w = cellWidthOf(text.charCodeAt(i))
148    if (x < col + w) return i
149    col += w
150  }
151  return text.length
152}
153
154// One logical line, greedily word-wrapped to `columns` cells (measured by display width, not
155// character count): a break lands on the last space at or before the limit, and a single word
156// wider than `columns` hard-breaks by character (the only way to keep every row within the
157// width at all). `startCol` is the real index into the logical line — not reconstructed later by
158// re-joining words — so a wrapped word's own text still slices correctly out of the original
159// line.
160function wrapLineOf(text: string, columns: number): { startCol: number; text: string }[] {
161  if (!Number.isFinite(columns) || columns <= 0 || displayWidthOf(text) <= columns) return [{ startCol: 0, text }]
162  const rows: { startCol: number; text: string }[] = []
163  let rowStart = 0
164  while (rowStart < text.length) {
165    const limit = indexAtWidth(text, rowStart, columns)
166    if (limit >= text.length) {
167      rows.push({ startCol: rowStart, text: text.slice(rowStart, limit) })
168      break
169    }
170    let breakAt = -1
171    for (let i = limit; i > rowStart; i -= 1) {
172      if (text[i] === ' ') {
173        breakAt = i
174        break
175      }
176    }
177    if (breakAt === -1) {
178      rows.push({ startCol: rowStart, text: text.slice(rowStart, limit) })
179      rowStart = limit
180    } else {
181      rows.push({ startCol: rowStart, text: text.slice(rowStart, breakAt) })
182      rowStart = breakAt + 1
183    }
184  }
185  return rows
186}
187
188// Every logical line wrapped to `columns` cells, in order. `columns` is the surface's own
189// `columns` (0 before its first layout); passed on as `Infinity` for that one frame, which
190// wraps nothing and lets the surface soft-wrap on its own, same as before this file drew its
191// own rows — the real width arrives on the next call and this takes over from there.
192export function visualRowsOf(lines: readonly string[], columns: number): VisualRow[] {
193  const rows: VisualRow[] = []
194  lines.forEach((line, index) => {
195    for (const row of wrapLineOf(line, columns)) rows.push({ line: index, startCol: row.startCol, text: row.text })
196  })
197  return rows
198}
199
200// A pointer event's cell, as a position in the logical text: `event.y` indexes `visualRows`
201// directly (each is exactly one screen row, by construction), and `event.x` lands within that
202// row's own slice of its logical line, offset by where the row starts. `charIndexAtColumn` (not
203// a plain clamp of `x` itself) is what makes this correct for a row containing any 2-cell
204// character: `x` is a terminal column, not a character index, and the two only coincide when
205// every character on the row is 1 cell wide.
206function screenPosOf(visualRows: readonly VisualRow[], event: { x: number; y: number }): Pos {
207  const rowIndex = Math.min(Math.max(event.y, 0), Math.max(visualRows.length - 1, 0))
208  const row = visualRows[rowIndex]
209  if (row === undefined) return { line: 0, col: 0 }
210  const col = charIndexAtColumn(row.text, Math.max(event.x, 0))
211  return { line: row.line, col: row.startCol + col }
212}
213
214// The range a drag covers on one screen row, or null where the row is not covered at all: the
215// same logical-line span `selectedColumnsOf` reports, intersected with the row's own
216// `[startCol, startCol + text.length)` slice and rebased to that row's local columns.
217function rowSelectionOf(range: OrderedRange, row: VisualRow, lineLength: number): { start: number; end: number } | null {
218  const cols = selectedColumnsOf(range, lineLength, row.line)
219  if (cols === null) return null
220  const start = Math.max(cols.start, row.startCol)
221  const end = Math.min(cols.end, row.startCol + row.text.length)
222  if (start > end) return null
223  return { start: start - row.startCol, end: end - row.startCol }
224}
225
226// One row: plain text, or split into an unhighlighted prefix, an inverse-video run for the
227// covered part, and an unhighlighted suffix. A zero-width `columns` on a non-empty row (the
228// cell right after 'down', before any 'move') draws as plain text — inserting a one-space
229// highlighted run there, as a real (non-empty) selection does, turned the clicked character
230// into a false blank (measured: real-terminal feedback). A zero-width `columns` on a genuinely
231// empty row (one a multi-line drag covers in full) still draws as one highlighted space, so a
232// blank line inside a real selection still shows as covered. No `wrap` prop: every row already
233// fits `columns` by construction (visualRowsOf), so there is nothing left to cut or wrap.
234function lineRowOf(elements: ClientElements, key: string, text: string, columns: { start: number; end: number } | null, bold: boolean): RenderElement {
235  const { Box, Text } = elements
236  const boldProp = bold ? { bold: true } : {}
237  const isFalseBlank = columns !== null && columns.start === columns.end && text !== ''
238  if (columns === null || isFalseBlank) {
239    return Box({ key, children: [Text({ ...boldProp, children: text === '' ? ' ' : text })] })
240  }
241  const before = text.slice(0, columns.start)
242  const selected = text.slice(columns.start, columns.end)
243  const after = text.slice(columns.end)
244  const children: RenderElement[] = []
245  if (before !== '') children.push(Text({ ...boldProp, children: before }))
246  children.push(Text({ ...boldProp, inverse: true, children: selected === '' ? ' ' : selected }))
247  if (after !== '') children.push(Text({ ...boldProp, children: after }))
248  return Box({ key, flexDirection: 'row', children })
249}
250
251// The pointer handler: 'down' starts a drag at the cell under the pointer, 'move' extends it
252// (ignored before a 'down' started one — a hover with no button held), 'up' posts what the drag
253// covered and ends it. A drag that never moved (a plain click) posts `cleared` whenever
254// something is armed here, anywhere in this text — not only a click landing inside the
255// highlight — since a click outside it dropping nothing read as unnatural (real-terminal
256// feedback: clicking away from a selection is how clearing one usually works). Registered fresh
257// on every call, which is how each render's `state` reaches the closure without a stale one
258// from an earlier call.
259function onPointerOf(
260  lines: readonly string[],
261  visualRows: readonly VisualRow[],
262  state: State,
263  armedRange: { start: number; end: number } | undefined,
264  setState: (next: State) => void,
265  post: (data: SelectionMessage) => void,
266) {
267  return (event: { type: string; x: number; y: number }) => {
268    if (event.type === 'down') {
269      const pos = screenPosOf(visualRows, event)
270      setState({ anchor: pos, current: pos })
271      return
272    }
273    if (event.type === 'move') {
274      if (state === null) return
275      const pos = screenPosOf(visualRows, event)
276      setState({ anchor: state.anchor, current: pos })
277      return
278    }
279    if (event.type === 'up') {
280      if (state === null) return
281      // The 'up' event carries its own cell, same as 'down' and 'move' do — read it directly
282      // rather than trusting a 'move' to have landed there first. A drag with no 'move' in
283      // between (a fast release, or a terminal that only sends 'move' on real movement) would
284      // otherwise end up comparing the anchor to itself and reporting an empty range.
285      const pos = screenPosOf(visualRows, event)
286      const range = orderedRangeOf(lines, state.anchor, pos)
287      if (isEmptyRange(range)) {
288        if (armedRange !== undefined) post({ type: 'cleared' })
289      } else {
290        post({ type: 'selected', start: absoluteOffsetOf(lines, range.start), end: absoluteOffsetOf(lines, range.end) })
291      }
292      setState(null)
293    }
294  }
295}
296
297// `surface` is the real `ClientSurface` from the engine, or (in a test) any object shaped
298// like one — the module never reaches for anything else on it.
299export function drawDescriptionSelection(
300  props: DescriptionSelectionProps,
301  surface: Pick<ClientSurface<State>, 'elements' | 'state' | 'setState' | 'onPointer' | 'post' | 'columns'>,
302): RenderElement {
303  const { elements, state, setState, onPointer, post, columns } = surface
304  const lines = props.lines
305  const armedRange = props.armedRange
306  const bold = props.bold ?? false
307  const visualRows = visualRowsOf(lines, columns > 0 ? columns : Number.POSITIVE_INFINITY)
308
309  onPointer(onPointerOf(lines, visualRows, state ?? null, armedRange, setState, (data) => post(data)))
310
311  // A live drag takes over the drawing; otherwise an already-armed range (from a past drag)
312  // stays highlighted, so the person can see what is about to ride their next prompt without
313  // holding the mouse down.
314  const dragRange = state == null ? null : orderedRangeOf(lines, state.anchor, state.current)
315  const armedAsRange = armedRange === undefined ? null : { start: posOf(lines, armedRange.start), end: posOf(lines, armedRange.end) }
316  const range = dragRange ?? armedAsRange
317
318  const { Box } = elements
319  return Box({
320    key: 'root',
321    flexDirection: 'column',
322    children: visualRows.map((row, index) =>
323      lineRowOf(elements, `row:${index}`, row.text, range === null ? null : rowSelectionOf(range, row, lines[row.line]?.length ?? 0), bold),
324    ),
325  })
326}
327
328const DescriptionSelection: ClientModule<DescriptionSelectionProps, State> = (props, surface) => drawDescriptionSelection(props, surface)
329
330export default DescriptionSelection
331