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…


A Claude Code plugin (a Claude Mod) that shows the GitHub pull requests related to the current session in a pane beside the transcript.
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.
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1gh logged in to GitHubclaude 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
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.
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.
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.
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.
Early access. The function-hooks API can change between Claude Code releases without notice.
hooks/mod.ts 1107 lines1// 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}
1107hooks/review.ts 130 lines1// 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}
130hooks/description-selection.ts 331 lines1// 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