Finds the repository's GitHub issues as @# is typed in the prompt, as opencode.vim does: a list above the prompt filtered by number or title, picked with a…

Finds the repository's GitHub issues as @# is typed in Claude Code's prompt, as opencode.vim's @# does. A list of the open issues comes up above the prompt and narrows as you type; a pick becomes @#N in the draft, and each @#N in a prompt that is sent goes with it: the model reads the issue beside the prompt.
The issues are those of the folder's git origin on github.com, listed and fetched by the GitHub CLI (gh), logged in as you. The list's colors are Claude Code's own theme colors, so they follow whatever /theme picks.
claude plugin marketplace add thefuga/claude-x
claude plugin install issues@claude-x
It needs gh, logged in once with gh auth login. It has no options: disable it (claude plugin disable issues@claude-x) to leave @# as plain text.
: too) moves the keys into the list instead; at any other time it opens the command line as before.@#N in the draft is a chip there once its issue is loaded, with GitHub's mark for an open, closed or merged issue or pull request, its title, and a × that takes it out. The list stands over the chips.:e does.None of them is needed.
Type @# at the start of the prompt or after a space. The list comes up over the prompt with the open issues, the most recently opened first, and follows what you type after it:
@#16 lists #16, then #161;A space, or the cursor moving off the reference, takes the list down. Typing @#161 in full needs no list at all.
Unlike Claude Code's own @ menu, the arrows and Enter do not pick from the list while you type in the prompt: Claude Code keeps those keys to itself (its arrows walk the prompt history) and never hands them to a mod, nor lets a mod add to its own menu. So the list says how to reach it, right after what you typed:
/tui fullscreen), the one that reports the mouse.abovePrompt:focus, such as the vim mod's Ctrl+X :), moves the keys into the list:| Key | In the list |
|---|---|
| Down, Tab | The next issue, round the end |
| Up, Shift+Tab | The one before |
| Letters, digits, Backspace | Narrow the list further; the draft is left as it is |
| Enter | Makes the reference @#N and gives the keys back to the prompt |
| Escape | Gives the keys back, the draft as it was; the list stays down until the reference changes |
Enter with digits that no open issue has takes them as they are, for a closed issue: @#7. Claude Code puts the cursor at the end of a draft a mod writes, so a reference picked in the middle of the draft leaves the cursor at its end.
With the attachments mod, each @#N in the draft is a chip above the prompt, as a pasted picture or a mentioned file is: GitHub's mark for an open, closed or merged issue or pull request, #N and its title, and a × that takes it out of the draft. An issue is loaded as soon as its @#N is typed out (the one still under the cursor waits), from the list where it has it and from gh issue view otherwise, and its chip comes up once it is.
When the prompt is sent, each @#N that stands alone (not me@#1, not @#1a) is fetched with gh issue view, open or closed, and goes with the prompt for the model to read: as opencode.vim's attachment, YAML front matter (repository, number, link, title, state, author, labels, assignees, dates) and the body as written, under a line that names it @#N. The comments are left out, as opencode.vim leaves them out. The prompt itself is sent and shown as typed.
An issue that goes with the prompt is not announced, as in opencode.vim. One gh cannot fetch goes as the list last had it, where it had it; otherwise it is left out, a toast says why, and the prompt is sent all the same.
@# typed five minutes later fetches it again. Older and closed issues are reached by their number.gh issue list leaves them out, but gh issue view answers for one, so @#N of a pull request goes with the prompt like an issue, its description as the body.hooks/register.tsx 603 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, ProcessRunResult, Register, Timer } from 'claude-code'
3
4import type { FieldState, List, Note, Reference } from '../types'
5import { referencesOf, typedAt, typedKey, withIssue } from './draft'
6import type { Typed } from './draft'
7import { GH_ENV, ORIGIN, SAID, TOP_LEVEL, contextOf, failureOf, issuesOf, listArgv, slugOf, unrunOf, viewArgv, viewedOf } from './github'
8import type { Issue } from './github'
9import { matchesOf } from './matches'
10import { IssueList, NEXT, PREVIOUS, fieldKey, isField } from './view'
11
12// How often the draft is read: a sent prompt, a history recall or a click moves it with no event.
13const POLL_MS = 250
14// How often the list's field asks whether it still has the keyboard, and how long it is left
15// undrawn to hand the keys back.
16const WATCH_MS = 100
17const FIELD_DOWN_MS = 80
18// How long a listing serves before the next reference lists again; how long git and gh may take,
19// fetching an issue as its prompt is sent included: the prompt waits for it.
20const LISTED_MS = 5 * 60_000
21const GIT_TIMEOUT_MS = 3000
22const LIST_TIMEOUT_MS = 15_000
23const VIEW_TIMEOUT_MS = 5000
24// How long before an issue the draft names that could not be loaded is asked for again.
25const RETRY_MS = 60_000
26// Whose prompts the issues they name are fetched for: the person's, at the terminal or remotely.
27const PERSON = new Set(['composer', 'bridge'])
28
29const FIRST_FIELD: FieldState = { drawn: 0, isDown: false, value: '' }
30const LISTING: Note = { text: 'listing the open issues…', isWarning: false }
31const NONE_OPEN: Note = { text: 'no open issues', isWarning: false }
32
33const list = atom({ plugin: 'issues', key: 'list' } as const, null)
34const field = atom({ plugin: 'issues', key: 'field' } as const, FIRST_FIELD)
35const references = atom({ plugin: 'issues', key: 'references' } as const, [])
36
37type Repository = { slug: string; root: string }
38
39// The session's folder as a GitHub repository, or why it is none; a reason that may not hold next
40// time (git missing, or slow) is asked again.
41type Found = { repository: Repository } | { note: Note; isFinal: boolean }
42
43let found: { root: string; answer: Promise<Found> } | undefined
44// The open issues as last listed, for which repository and when; a listing under way; and why the
45// last one failed.
46let listed: { slug: string; issues: Issue[]; at: number } | undefined
47let listing: Promise<void> | undefined
48let failure: Note | undefined
49// The reference typed at the cursor, what the list is filtered by (its words, or what is typed in
50// the list's field), the issues that answer and the one picked.
51let typed: Typed | null = null
52let filter = ''
53let matched: Issue[] = []
54let picked = 0
55// A reference Escape closed the list over: it stays closed until the reference changes.
56let dismissed: string | null = null
57// The field while it holds the keys: the timer that asks after them, and the times in a row it was
58// told no.
59let held: { watch: Timer; denied: number } | undefined
60let poll: Timer | undefined
61let seen: string | undefined
62// The issues the draft names, each as loaded and when (null where gh had none), for which
63// repository, and those being loaded; the numbers the draft names now, and what was last published
64// of them.
65let loaded = new Map<number, { issue: Issue | null; at: number }>()
66let loadedSlug: string | undefined
67const loading = new Set<number>()
68let named: number[] = []
69let published: string | undefined
70
71// Nothing here is worth failing a hook over.
72const quietly = async ($: EngineInterface, label: string, work: Promise<unknown>) => {
73 try {
74 await work
75 } catch (error) {
76 $.ui.log(`${label}: ${String(error)}`, { to: 'debug' })
77 }
78}
79
80const warning = (text: string): Note => ({ text, isWarning: true })
81
82// One command; why it could not run, where it could not, in place of what it printed.
83const ran = async ($: EngineInterface, argv: readonly string[], cwd: string, timeoutMs: number): Promise<ProcessRunResult | { error: unknown }> => {
84 try {
85 return await $.process.run(argv, { cwd, env: GH_ENV, timeoutMs })
86 } catch (error) {
87 return { error }
88 }
89}
90
91const firstLine = (stdout: string) => stdout.trim().split(/\r?\n/)[0] ?? ''
92
93// The repository is told by its git origin, as opencode.vim tells it: there is no other to name.
94const detect = async ($: EngineInterface, root: string): Promise<Found> => {
95 const top = await ran($, TOP_LEVEL, root, GIT_TIMEOUT_MS)
96
97 if ('error' in top) {
98 return { note: warning(SAID.noRepository), isFinal: false }
99 }
100
101 if (top.exitCode !== 0 || firstLine(top.stdout) === '') {
102 return { note: warning(SAID.noRepository), isFinal: true }
103 }
104
105 const topLevel = firstLine(top.stdout)
106 const origin = await ran($, ORIGIN, topLevel, GIT_TIMEOUT_MS)
107
108 if ('error' in origin) {
109 return { note: warning(SAID.noOrigin), isFinal: false }
110 }
111
112 const slug = origin.exitCode === 0 ? slugOf(firstLine(origin.stdout)) : null
113
114 if (slug === null) {
115 return { note: warning(origin.exitCode === 0 && firstLine(origin.stdout) !== '' ? SAID.notGitHub : SAID.noOrigin), isFinal: true }
116 }
117
118 return { repository: { slug, root: topLevel } }
119}
120
121// The repository of the session's folder, found once for each folder the session is in.
122const repositoryOf = async ($: EngineInterface) => {
123 const root = await $.session.root()
124 const asked = found?.root === root ? found : { root, answer: detect($, root) }
125 found = asked
126 const answer = await asked.answer
127
128 if ('isFinal' in answer && !answer.isFinal && found === asked) {
129 found = undefined
130 }
131
132 return answer
133}
134
135const listIssues = async ($: EngineInterface) => {
136 const answer = await repositoryOf($)
137
138 if (!('repository' in answer)) {
139 listed = undefined
140 failure = answer.note
141
142 return
143 }
144
145 const { slug, root } = answer.repository
146 const now = await $.clock.now()
147
148 if (listed?.slug === slug && now - listed.at < LISTED_MS) {
149 return
150 }
151
152 const result = await ran($, listArgv(slug), root, LIST_TIMEOUT_MS)
153 const issues = 'error' in result || result.exitCode !== 0 ? null : issuesOf(result.stdout)
154
155 if (issues !== null) {
156 listed = { slug, issues, at: now }
157 failure = undefined
158
159 return
160 }
161
162 // A listing that fails leaves the one before it on show, where there is one for this repository.
163 if (listed?.slug !== slug) {
164 listed = undefined
165 }
166
167 failure = warning('error' in result ? unrunOf(result.error) : result.exitCode !== 0 ? failureOf(result.stderr) : SAID.unreadable)
168}
169
170// The open issues are listed once for a while, and filtered as the person types: a reference typed
171// after that lists them again, as does one typed after a listing failed.
172const ensureListed = async ($: EngineInterface) => {
173 listing ??= listIssues($).finally(() => {
174 listing = undefined
175 })
176 await listing
177 await show($)
178}
179
180const noteOf = (): Note | null => {
181 if (listed === undefined) {
182 return listing === undefined && failure !== undefined ? failure : LISTING
183 }
184
185 if (listed.issues.length === 0) {
186 return NONE_OPEN
187 }
188
189 return matched.length === 0 ? { text: `no open issue answers ${filter}`, isWarning: false } : null
190}
191
192// What the list shows, for the drawing to read; nothing while no reference is typed.
193const show = async ($: EngineInterface) => {
194 if (typed === null) {
195 matched = []
196 await update($, list, () => null)
197
198 return
199 }
200
201 const issues = listed?.issues ?? []
202 matched = matchesOf(issues, filter)
203 picked = Math.max(0, Math.min(picked, matched.length - 1))
204 const shown: List = {
205 query: filter,
206 isFocused: held !== undefined,
207 rows: matched.map(({ number, title, labels }) => ({ number, title, labels })),
208 picked,
209 open: issues.length,
210 repository: listed?.slug ?? null,
211 note: noteOf(),
212 }
213
214 await update($, list, () => shown)
215}
216
217// The draft as it stands after an edit, or as read: the list follows the reference at the cursor.
218const follow = async ($: EngineInterface, text: string, cursor: number) => {
219 if (held !== undefined) {
220 return
221 }
222
223 const now = typedAt(text, cursor)
224
225 if (now === null || typedKey(now) === dismissed) {
226 dismissed = now === null ? null : dismissed
227
228 if (typed !== null) {
229 typed = null
230 await show($)
231 }
232
233 return
234 }
235
236 if (typed !== null && typedKey(typed) === typedKey(now) && typed.end === now.end) {
237 return
238 }
239
240 const isNew = typed === null || typed.start !== now.start
241 dismissed = null
242 typed = now
243 filter = now.query
244 picked = 0
245 // The field holds what is typed after `@#`, for the keys to carry on from where it is.
246 await update($, field, kept => (kept.value === now.query ? kept : { ...kept, value: now.query }))
247 await show($)
248
249 if (isNew) {
250 void quietly($, 'list', ensureListed($))
251 }
252}
253
254const referenceOf = ({ number, title, state, url }: Issue): Reference => ({ number, title, state, isPull: /\/pull\/\d+\/?$/.test(url) })
255
256// The issues the draft names, as far as they are loaded, for the attachments mod to draw.
257const publish = async ($: EngineInterface) => {
258 const shown = named.flatMap(number => {
259 const issue = loaded.get(number)?.issue
260
261 return issue === null || issue === undefined ? [] : [referenceOf(issue)]
262 })
263 const json = JSON.stringify(shown)
264
265 if (json !== published) {
266 published = json
267 await update($, references, () => shown)
268 }
269}
270
271// One issue the draft names: the copy listed for the list where it has one, else as gh has it, else
272// none.
273const loadOne = async ($: EngineInterface, { slug, root }: Repository, number: number, now: number) => {
274 const kept = listed?.slug === slug ? listed.issues.find(issue => issue.number === number) : undefined
275
276 if (kept !== undefined) {
277 loaded.set(number, { issue: kept, at: now })
278
279 return
280 }
281
282 const result = await ran($, viewArgv(slug, number), root, VIEW_TIMEOUT_MS)
283 loaded.set(number, { issue: 'error' in result || result.exitCode !== 0 ? null : viewedOf(result.stdout, number), at: now })
284}
285
286// Loads the issues the draft names that are not loaded, or not for a while; one that could not be
287// loaded is asked for again a minute on.
288const loadNamed = async ($: EngineInterface) => {
289 const answer = await repositoryOf($)
290
291 if (!('repository' in answer)) {
292 return
293 }
294
295 if (loadedSlug !== answer.repository.slug) {
296 loaded = new Map()
297 loadedSlug = answer.repository.slug
298 }
299
300 const now = await $.clock.now()
301 const due = named.filter(number => {
302 const kept = loaded.get(number)
303
304 return !loading.has(number) && (kept === undefined || now - kept.at >= (kept.issue === null ? RETRY_MS : LISTED_MS))
305 })
306
307 if (due.length === 0) {
308 return
309 }
310
311 due.forEach(number => loading.add(number))
312
313 try {
314 await Promise.all(due.map(number => loadOne($, answer.repository, number, now)))
315 } finally {
316 due.forEach(number => loading.delete(number))
317 }
318
319 await publish($)
320}
321
322// The issues the draft names, the one still being typed at the cursor left out until it is done.
323const track = async ($: EngineInterface, text: string, cursor: number) => {
324 const typing = typedAt(text, cursor)
325 named = referencesOf(typing === null ? text : `${text.slice(0, typing.start)} ${text.slice(typing.end)}`)
326 await publish($)
327 void quietly($, 'references', loadNamed($))
328}
329
330// The draft as it stands: the list follows the reference at the cursor, and the issues it names are
331// loaded.
332const observe = ($: EngineInterface, text: string, cursor: number) => Promise.all([follow($, text, cursor), track($, text, cursor)])
333
334const sync = async ($: EngineInterface) => {
335 const { text, cursor } = await $.prompt.read()
336 const draft = `${cursor}\n${text}`
337
338 if (draft !== seen) {
339 seen = draft
340 await observe($, text, cursor)
341 }
342}
343
344// The focus chord moved the keys into the list's field. One typed in a field nobody saw taking the
345// keys (the mod was loaded again under it) holds them as the first does.
346const holdKeys = async ($: EngineInterface, requestId: string, key: string) => {
347 if (held !== undefined || typed === null) {
348 return
349 }
350
351 held = {
352 denied: 0,
353 watch: $.clock.every(WATCH_MS, () => {
354 void quietly($, 'list', watchKeys($, requestId, key))
355 }),
356 }
357 await show($)
358}
359
360// Escape hands the keys back to the prompt and raises nothing. The engine refuses to move the ring
361// of a site that does not hold the keyboard, so asking it to keep the ring where it is tells; two
362// refusals in a row, since a ring on the move is refused too.
363const watchKeys = async ($: EngineInterface, requestId: string, key: string) => {
364 const answer = await $.ui.focus({ requestId, key }).catch((error: unknown) => ({ deny: String(error) }))
365
366 if (held === undefined) {
367 return
368 }
369
370 held.denied = answer.deny === undefined ? 0 : held.denied + 1
371
372 if (held.denied >= 2) {
373 await letKeysGo($, true)
374 }
375}
376
377// The field hands the keys back to the prompt: a field that is not drawn can hold no keyboard, so it
378// is left out of one drawing, and the one drawn after it is another field. After Escape the list
379// stays closed over the reference until it changes.
380const letKeysGo = async ($: EngineInterface, isDismissed: boolean) => {
381 held?.watch.cancel()
382 held = undefined
383
384 if (isDismissed && typed !== null) {
385 dismissed = typedKey(typed)
386 typed = null
387 }
388
389 filter = typed?.query ?? ''
390 picked = 0
391 await update($, field, ({ drawn }) => ({ drawn: drawn + 1, isDown: true, value: filter }))
392 $.clock.after(FIELD_DOWN_MS, () => {
393 void quietly($, 'list', update($, field, kept => ({ ...kept, isDown: false })))
394 })
395 await show($)
396}
397
398const typeFilter = async ($: EngineInterface, requestId: string, key: string, text: string) => {
399 await holdKeys($, requestId, key)
400 filter = text
401 picked = 0
402 await show($)
403}
404
405// Down or Tab, Up or Shift+Tab in the field: the pick moves, round the ends.
406const step = async ($: EngineInterface, by: number) => {
407 if (matched.length > 0) {
408 picked = (picked + by + matched.length) % matched.length
409 await show($)
410 }
411}
412
413// The reference at the cursor becomes `@#N`. The keys go back to the prompt first; Claude Code puts
414// the cursor at the end of a draft a mod fills in.
415const take = async ($: EngineInterface, number: number) => {
416 const reference = typed
417
418 if (reference === null) {
419 return
420 }
421
422 // A draft read before the new one lands opens no list over the reference.
423 dismissed = typedKey(reference)
424 typed = null
425 await letKeysGo($, false)
426 const { text, cursor } = await $.prompt.read()
427 const at = typedAt(text, cursor) ?? (text.startsWith(`@#${reference.query}`, reference.start) ? reference : null)
428
429 if (at !== null) {
430 await $.prompt.fill({ text: withIssue(text, at, number), mode: 'replace' })
431 }
432}
433
434// Enter in the field takes the picked issue; with none, digits typed there are taken as the number
435// of an issue the list does not have (a closed one), and anything else hands the keys back.
436const submitFilter = async ($: EngineInterface) => {
437 const number = matched[picked]?.number ?? (/^[1-9]\d*$/.test(filter) ? Number(filter) : null)
438
439 if (number === null) {
440 await letKeysGo($, false)
441 } else {
442 await take($, number)
443 }
444}
445
446// One issue a prompt names, as gh has it now; the copy listed for the list, or loaded for the
447// draft's chips, where gh cannot answer, and otherwise why not.
448const fetchIssue = async ($: EngineInterface, { slug, root }: Repository, number: number): Promise<Issue | Note> => {
449 const result = await ran($, viewArgv(slug, number), root, VIEW_TIMEOUT_MS)
450 const issue = 'error' in result || result.exitCode !== 0 ? null : viewedOf(result.stdout, number)
451 const kept = (listed?.slug === slug ? listed.issues.find(listedIssue => listedIssue.number === number) : undefined) ?? (loadedSlug === slug ? loaded.get(number)?.issue ?? undefined : undefined)
452
453 if (issue !== null || kept !== undefined) {
454 return issue ?? (kept as Issue)
455 }
456
457 return warning('error' in result ? unrunOf(result.error) : result.exitCode !== 0 ? failureOf(result.stderr) : SAID.unreadable)
458}
459
460const isIssue = (value: Issue | Note): value is Issue => 'number' in value
461
462// The issues a prompt names, each as the model reads it beside the prompt. As in opencode.vim, an
463// issue that goes with it is not announced; a toast says which could not.
464const contextFor = async ($: EngineInterface, numbers: number[]) => {
465 const answer = await repositoryOf($)
466
467 if (!('repository' in answer)) {
468 $.ui.toast(`${numbers.map(number => `@#${number}`).join(', ')} not attached: ${answer.note.text}`)
469
470 return []
471 }
472
473 const fetched = await Promise.all(numbers.map(async number => ({ number, got: await fetchIssue($, answer.repository, number) })))
474 const issues = fetched.flatMap(({ got }) => (isIssue(got) ? [got] : []))
475 const missed = fetched.flatMap(({ number, got }) => (isIssue(got) ? [] : [`could not attach @#${number}: ${got.text}`]))
476
477 if (missed.length > 0) {
478 $.ui.toast(missed.join(' · '))
479 }
480
481 return issues.map(issue => contextOf(issue, answer.repository.slug))
482}
483
484// The mod was loaded, or loaded again: a list or a field it left up before is taken down, and the
485// draft is followed from here.
486const boot = async ($: EngineInterface) => {
487 held?.watch.cancel()
488 held = undefined
489 typed = null
490 dismissed = null
491 seen = undefined
492 named = []
493 published = undefined
494 await update($, list, () => null)
495 await update($, references, () => [])
496 await update($, field, ({ drawn }) => ({ drawn: drawn + 1, isDown: false, value: '' }))
497 poll ??= $.clock.every(POLL_MS, () => {
498 void quietly($, 'draft', sync($))
499 })
500}
501
502export const register: Register = on => {
503 on('session.start', async ($, e, next) => {
504 const started = await next(e)
505 void quietly($, 'start', boot($))
506
507 return started
508 })
509
510 // Each key that types or moves the cursor: the list follows at once, without waiting for a read.
511 on('prompt.edit', async ($, e, next) => {
512 const edited = await next(e)
513 seen = `${edited.cursor}\n${edited.text}`
514 void quietly($, 'draft', observe($, edited.text, edited.cursor))
515
516 return edited
517 })
518
519 // A prompt that is sent takes the list with it, and the issues it names go to the model beside it.
520 on('prompt.submit', async ($, e, next) => {
521 typed = null
522 named = []
523 void quietly($, 'list', Promise.all([show($), publish($)]))
524 const numbers = PERSON.has(e.origin.kind) ? referencesOf(e.text) : []
525
526 if (numbers.length === 0) {
527 return next(e)
528 }
529
530 const blocks = await contextFor($, numbers).catch((error: unknown) => {
531 $.ui.log(`attach: ${String(error)}`, { to: 'debug' })
532
533 return []
534 })
535
536 return next(blocks.length === 0 ? e : { ...e, context: [...(e.context ?? []), ...blocks] })
537 })
538
539 // The list stands first in the band above the prompt, over whatever else is drawn there, and gives
540 // way to a survey.
541 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
542 const beneath = await next(e)
543
544 if (e.surface !== 'terminal' || e.props.hasSurvey) {
545 return beneath
546 }
547
548 const shown = await read($, list)
549
550 if (shown === null) {
551 return beneath
552 }
553
554 const { drawn, isDown, value } = await read($, field)
555 const table = $.ui.resolve(e)
556 const { Box } = table
557 const { requestId } = e
558 const key = fieldKey(drawn)
559 const keys = isDown
560 ? null
561 : {
562 key,
563 value,
564 onInput: (text: string) => {
565 void quietly($, 'list', typeFilter($, requestId, key, text))
566 },
567 onSubmit: () => {
568 void quietly($, 'list', submitFilter($))
569 },
570 }
571
572 const pick = (number: number) => {
573 void quietly($, 'list', take($, number))
574 }
575
576 return (
577 <Box flexDirection="column">
578 {IssueList(table, shown, keys, e.props.bodyColumns, e.props.maxRows, pick)}
579 {beneath}
580 </Box>
581 )
582 })
583
584 // The keys moving into the field is the list taking them. Tab, Shift+Tab and the arrows in the
585 // field move the ring onto an element beside it: the pick moves instead, and the ring is kept on
586 // the field by not passing the move on.
587 on('ui.focus', async ($, e, next) => {
588 if (e.component === 'AbovePrompt' && e.plugin === 'issues' && e.origin.kind === 'person' && (e.element === NEXT || e.element === PREVIOUS)) {
589 void quietly($, 'list', step($, e.element === NEXT ? 1 : -1))
590
591 return {}
592 }
593
594 const moved = await next(e)
595
596 if (e.component === 'AbovePrompt' && e.plugin === 'issues' && isField(e.element) && e.element !== undefined && moved.deny === undefined) {
597 void quietly($, 'list', holdKeys($, e.requestId, e.element))
598 }
599
600 return moved
601 })
602}
603hooks/draft.ts 67 lines1// The `@#` references of a draft, as opencode.vim reads them (its `autocomplete.ts` and
2// `github-issues.ts`): the one being typed at the cursor, which the list above the prompt follows
3// and a pick replaces, and the finished `@#N` ones that go with the prompt when it is sent. What
4// they name is the hooks' business; here they are only found, and written.
5
6// The reference being typed: where its `@` stands, where it ends (before a stop typed after it, a
7// comma, a closing bracket) and what follows the `#`.
8export type Typed = { start: number; end: number; query: string }
9
10const STOP = /[.,;:!?'"`)\]}]+$/u
11const QUERY = /^[\p{L}\p{N}_./-]*$/u
12const WORD = /[A-Za-z0-9_]/
13
14// The reference the cursor is at the end of: `@#` at the start of the draft or after a space, then
15// a number or words without spaces. An address (`me@#1`) is none, nor is a plain `@` mention.
16export const typedAt = (text: string, cursor: number): Typed | null => {
17 const upto = text.slice(0, Math.max(0, Math.min(cursor, text.length)))
18 const at = upto.lastIndexOf('@')
19
20 if (at < 0 || (at > 0 && !/\s/.test(upto[at - 1] ?? ''))) {
21 return null
22 }
23
24 const word = upto.slice(at + 1)
25
26 if (/\s/.test(word) || !word.startsWith('#')) {
27 return null
28 }
29
30 const raw = word.slice(1)
31 const stop = STOP.exec(raw)?.[0] ?? ''
32 const query = raw.slice(0, raw.length - stop.length)
33
34 return QUERY.test(query) ? { start: at, end: upto.length - stop.length, query } : null
35}
36
37// Which reference this is, as long as it stays where it is and reads the same.
38export const typedKey = ({ start, query }: Typed) => `${start}:${query}`
39
40// The draft with the reference being typed made `@#N`, and a space after it unless a space or a
41// stop follows already.
42export const withIssue = (text: string, typed: Typed, number: number) => {
43 const before = text.slice(0, typed.start)
44 const after = text.slice(typed.end)
45 const space = after !== '' && /^\s|^[.,;:!?'"`)\]}]/u.test(after) ? '' : ' '
46
47 return `${before}@#${number}${space}${after}`
48}
49
50// The issues a prompt names, each once and in the order it is first named: `@#N` standing alone,
51// so `me@#1` and `@#1a` name none, and `(@#2)` names one.
52export const referencesOf = (text: string): number[] => {
53 const numbers = new Set<number>()
54
55 for (const match of text.matchAll(/@#([1-9][0-9]*)/g)) {
56 const start = match.index
57 const end = start + match[0].length
58 const number = Number(match[1])
59
60 if (Number.isSafeInteger(number) && !WORD.test(text[start - 1] ?? '') && !WORD.test(text[end] ?? '')) {
61 numbers.add(number)
62 }
63 }
64
65 return [...numbers]
66}
67hooks/github.ts 186 lines1// The repository's GitHub issues as opencode.vim gets them (its `github-issues.ts`): the
2// repository from the git origin, the open issues from one `gh issue list`, one issue from
3// `gh issue view`, and an issue written out for the model as the Markdown file opencode.vim
4// attaches. What is run, and when, is the hooks' business; here the commands are written and what
5// they print is read.
6
7// One issue as gh prints it, the comments left out as opencode.vim leaves them out.
8export type Issue = {
9 number: number
10 title: string
11 state: string
12 body: string
13 labels: string[]
14 assignees: string[]
15 author: string | null
16 createdAt: string | null
17 updatedAt: string
18 url: string
19}
20
21// The most open issues listed: the list is fetched once and filtered as the person types.
22export const LIST_LIMIT = 100
23const FIELDS = ['number', 'title', 'state', 'body', 'labels', 'assignees', 'author', 'createdAt', 'updatedAt', 'url'].join(',')
24
25// What git and gh are run with: nothing asked of a terminal, no pager, no colors, no update notice.
26export const GH_ENV = {
27 GH_PROMPT_DISABLED: '1',
28 GH_NO_UPDATE_NOTIFIER: '1',
29 GIT_TERMINAL_PROMPT: '0',
30 GH_PAGER: 'cat',
31 PAGER: 'cat',
32 NO_COLOR: '1',
33}
34
35export const TOP_LEVEL = ['git', 'rev-parse', '--show-toplevel']
36export const ORIGIN = ['git', 'config', '--get', 'remote.origin.url']
37
38export const listArgv = (slug: string) => ['gh', 'issue', 'list', '--repo', slug, '--state', 'open', '--limit', String(LIST_LIMIT), '--json', FIELDS]
39
40export const viewArgv = (slug: string, number: number) => ['gh', 'issue', 'view', String(number), '--repo', slug, '--json', FIELDS]
41
42// Why there are no issues to list, in the words the list says it with.
43export const SAID = {
44 noRepository: 'not in a git repository',
45 noOrigin: 'this repository has no origin remote',
46 notGitHub: 'origin is not a github.com repository',
47 noGh: 'gh is not installed: cli.github.com',
48 loggedOut: 'gh is not logged in: run gh auth login',
49 slow: 'GitHub did not answer in time',
50 unreadable: 'gh printed something that is not a list of issues',
51}
52
53const NAME = /^[A-Za-z0-9_.-]+$/
54
55// `OWNER/REPO` of a github.com remote, over HTTPS or SSH, as opencode.vim takes them; null for any
56// other host.
57export const slugOf = (remote: string): string | null => {
58 const value = remote.trim()
59 const found =
60 /^https:\/\/github\.com\/([^/]+)\/([^/#]+?)\/?$/i.exec(value) ??
61 /^git@github\.com:([^/]+)\/([^/#]+?)\/?$/i.exec(value) ??
62 /^ssh:\/\/(?:git@)?github\.com\/([^/]+)\/([^/#]+?)\/?$/i.exec(value)
63 const owner = found?.[1]?.trim() ?? ''
64 const name = found?.[2]?.trim().replace(/\.git$/i, '') ?? ''
65
66 return NAME.test(owner) && NAME.test(name) ? `${owner}/${name}` : null
67}
68
69const record = (value: unknown): Record<string, unknown> =>
70 typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : {}
71
72const text = (value: unknown) => (typeof value === 'string' ? value.trim() : '')
73
74// A person as gh names one: by login, or by name where there is no login.
75const login = (value: unknown) => (typeof value === 'string' ? value.trim() : text(record(value).login) || text(record(value).name)) || null
76
77const names = (value: unknown, field: 'name' | 'login') =>
78 Array.isArray(value) ? value.map(item => (typeof item === 'string' ? item.trim() : text(record(item)[field]))).filter(name => name !== '') : []
79
80// One issue of gh's JSON; null for one missing what every issue has.
81export const issueOf = (value: unknown): Issue | null => {
82 const raw = record(value)
83 const number = Number(raw.number)
84 const title = text(raw.title)
85 const state = text(raw.state).toUpperCase()
86 const updatedAt = text(raw.updatedAt)
87 const url = text(raw.url)
88
89 if (!Number.isSafeInteger(number) || number <= 0 || title === '' || state === '' || updatedAt === '' || url === '') {
90 return null
91 }
92
93 return {
94 number,
95 title,
96 state,
97 body: typeof raw.body === 'string' ? raw.body : '',
98 labels: names(raw.labels, 'name'),
99 assignees: names(raw.assignees, 'login'),
100 author: login(raw.author),
101 createdAt: text(raw.createdAt) || null,
102 updatedAt,
103 url,
104 }
105}
106
107const parsed = (stdout: string): unknown => {
108 try {
109 return JSON.parse(stdout)
110 } catch {
111 return undefined
112 }
113}
114
115// What `gh issue list` printed, or null where it is not a list of issues.
116export const issuesOf = (stdout: string): Issue[] | null => {
117 const value = parsed(stdout)
118 const issues = Array.isArray(value) ? value.map(issueOf) : null
119
120 return issues === null || issues.some(issue => issue === null) ? null : (issues as Issue[])
121}
122
123// What `gh issue view` printed, or null where it is not that issue.
124export const viewedOf = (stdout: string, number: number): Issue | null => {
125 const issue = issueOf(parsed(stdout))
126
127 return issue?.number === number ? issue : null
128}
129
130// What gh said when it failed, as one short line with any credential in it blanked.
131export const failureOf = (stderr: string) => {
132 if (/auth|authenticate|login|token|credential/i.test(stderr)) {
133 return SAID.loggedOut
134 }
135
136 const line =
137 stderr
138 .replace(/\bgh[pousr]_[A-Za-z0-9_]+\b/g, '[redacted]')
139 .replace(/(authorization\s*[:=]\s*bearer\s+)[^\s,;]+/gi, '$1[redacted]')
140 .split(/\r?\n/)
141 .map(row => row.trim())
142 .find(row => row !== '') ?? ''
143
144 return line === '' ? 'gh failed' : `gh: ${line.slice(0, 120)}`
145}
146
147// Why gh could not be run at all: not there, or too slow.
148export const unrunOf = (error: unknown) => {
149 const message = error instanceof Error ? error.message : String(error)
150
151 return /time|killed/i.test(message) ? SAID.slow : /ENOENT|not found|no such file|spawn/i.test(message) ? SAID.noGh : `gh could not run: ${message.slice(0, 120)}`
152}
153
154// JSON's quoting is YAML's, but for the two line separators JSON leaves bare.
155const SEPARATORS = new RegExp(`[${String.fromCharCode(0x2028, 0x2029)}]`, 'g')
156
157const quoted = (value: string) => JSON.stringify(value).replace(SEPARATORS, char => `\\u${char.charCodeAt(0).toString(16)}`)
158
159const quotedOrNull = (value: string | null) => (value === null ? 'null' : quoted(value))
160
161const list = (values: string[]) => `[${values.map(quoted).join(', ')}]`
162
163// An issue for the model to read beside the prompt: what it is named in the prompt, then the file
164// opencode.vim attaches, YAML front matter and the body as written.
165export const contextOf = (issue: Issue, slug: string) =>
166 [
167 `@#${issue.number} in the prompt is this GitHub issue of ${slug}, its comments left out:`,
168 '',
169 '---',
170 `kind: ${quoted('github-issue')}`,
171 `github_reference: ${quoted(`${slug}#${issue.number}`)}`,
172 `repository: ${quoted(slug)}`,
173 `number: ${issue.number}`,
174 `canonical_url: ${quoted(`https://github.com/${slug}/issues/${issue.number}`)}`,
175 `title: ${quoted(issue.title)}`,
176 `state: ${quoted(issue.state)}`,
177 `author: ${quotedOrNull(issue.author)}`,
178 `labels: ${list(issue.labels)}`,
179 `assignees: ${list(issue.assignees)}`,
180 `created_at: ${quotedOrNull(issue.createdAt)}`,
181 `updated_at: ${quoted(issue.updatedAt)}`,
182 '---',
183 '',
184 issue.body.replace(/\r\n?/g, '\n') || '(no description)',
185 ].join('\n')
186hooks/matches.ts 59 lines1// Which open issues answer what is typed after `@#`, best first, as opencode.vim ranks them: digits
2// match an issue's number from its start, the exact one first; anything else matches its title.
3
4type Ranked = { number: number; title: string }
5
6// How well a title answers the words typed: from its start, from a word's start, anywhere in it, or
7// with their letters in order; undefined when it does not.
8const rankOf = (title: string, query: string) => {
9 const haystack = title.toLowerCase()
10
11 if (haystack.startsWith(query)) {
12 return 0
13 }
14
15 if (haystack.split(/[^\p{L}\p{N}]+/u).some(word => word.startsWith(query))) {
16 return 1
17 }
18
19 if (haystack.includes(query)) {
20 return 2
21 }
22
23 let from = 0
24
25 for (const char of query) {
26 const at = haystack.indexOf(char, from)
27
28 if (at < 0) {
29 return undefined
30 }
31
32 from = at + 1
33 }
34
35 return 3
36}
37
38// The issues that answer `query`, the list's own order kept among equals: gh lists the most
39// recently opened first.
40export const matchesOf = <T extends Ranked>(issues: readonly T[], query: string): T[] => {
41 const needle = query.trim().toLowerCase()
42
43 if (needle === '') {
44 return [...issues]
45 }
46
47 if (/^\d+$/.test(needle)) {
48 const numbered = issues.filter(({ number }) => String(number).startsWith(needle))
49
50 return [...numbered.filter(({ number }) => String(number) === needle), ...numbered.filter(({ number }) => String(number) !== needle)]
51 }
52
53 return issues
54 .map((issue, index) => ({ issue, index, rank: rankOf(issue.title, needle) }))
55 .filter((entry): entry is { issue: T; index: number; rank: number } => entry.rank !== undefined)
56 .sort((a, b) => a.rank - b.rank || a.index - b.index)
57 .map(({ issue }) => issue)
58}
59hooks/view.tsx 115 lines1import type { Elements } from 'claude-code'
2
3import type { List, Row } from '../types'
4import { theme } from './theme'
5
6type Parts = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button' | 'Input'>
7
8// What the list's field is drawn with: its address, the text it holds when drawn, and what typing
9// and Enter run.
10export type Field = {
11 key: string
12 value: string
13 onInput: (value: string) => void
14 onSubmit: (value: string) => void
15}
16
17// The field is drawn under a new key each time the list lets the keys go: the engine keeps what was
18// typed in a field by its key.
19export const fieldKey = (drawn: number) => `issues:${drawn}`
20
21export const isField = (key: string | undefined) => key !== undefined && /^issues:\d+$/.test(key)
22
23// The two elements drawn either side of the field. The engine moves the ring onto one for Tab or
24// Down and for Shift+Tab or Up; that move is a step through the list instead.
25export const NEXT = 'issues:next'
26export const PREVIOUS = 'issues:previous'
27
28// The most issues the list shows at once.
29const ROWS = 6
30
31// The keys, said right after what is typed: Claude Code keeps the arrows and Enter while the keys
32// are in the prompt, so the way into the list is spelled out while there is something to pick.
33const keysOf = ({ rows, isFocused }: List) =>
34 isFocused ? '↑↓ pick · enter insert · esc back' : rows.length > 0 ? 'ctrl+x tab or click to pick' : null
35
36// How many issues answer, once they are listed.
37const countOf = ({ rows, open, repository }: List) =>
38 repository === null ? null : rows.length === open ? `${open} open in ${repository}` : `${rows.length} of ${open} open`
39
40// A title cut to `room` cells, an ellipsis at its end where it was cut.
41const cut = (text: string, room: number) => {
42 const chars = [...text]
43
44 return chars.length <= room ? text : `${chars.slice(0, Math.max(0, room - 1)).join('')}…`
45}
46
47// One issue: its number in a column as wide as the widest shown and its title, which a click takes,
48// and its labels after them where the title leaves them room. The rows not picked are drawn dim,
49// and at full strength under the pointer.
50const issueRow = ({ Box, Text, Button }: Parts, row: Row, isPicked: boolean, numbers: number, width: number, onPick: (number: number) => void) => {
51 const number = `#${row.number}`.padEnd(numbers)
52 const labels = row.labels.join(', ')
53 const room = width - 2 - numbers - 2
54 const isLabeled = labels !== '' && room - labels.length - 2 >= 24
55 const title = cut(row.title, isLabeled ? room - labels.length - 2 : room)
56
57 return (
58 <Box key={`row:${row.number}`} flexDirection="row" width={width} paddingX={1} backgroundColor={isPicked ? theme.picked : theme.background}>
59 <Button key={`pick:${row.number}`} plain dimColor={!isPicked} onPress={() => onPick(row.number)}>
60 {`${number} ${title}`}
61 </Button>
62 {isLabeled && <Text color={theme.hint}>{` ${labels}`}</Text>}
63 </Box>
64 )
65}
66
67// The list, one row over the issues for what is typed and the keys, then as many issues as the band
68// has rows for, round the picked one, which is marked; or the note that stands for them. The field
69// the person's focus chord moves the keys into stands first, in a box of no height, so that its
70// `autoFocus` is the band's first.
71export const IssueList = (parts: Parts, list: List, field: Field | null, width: number, height: number, onPick: (number: number) => void) => {
72 const { Box, Text, Button, Input } = parts
73 const fit = Math.max(0, Math.min(ROWS, height - 1))
74 const first = Math.max(0, Math.min(list.picked - Math.floor(fit / 2), list.rows.length - fit))
75 const rows = list.rows.slice(first, first + fit)
76 const numbers = Math.max(0, ...rows.map(({ number }) => String(number).length + 1))
77 const keys = keysOf(list)
78 const count = countOf(list)
79
80 return (
81 <Box flexDirection="column">
82 {field !== null && (
83 <Box height={0} overflow="hidden">
84 <Button key={PREVIOUS} onPress={() => undefined}>
85 previous
86 </Button>
87 <Input key={field.key} value={field.value} autoFocus onInput={field.onInput} onSubmit={field.onSubmit} />
88 <Button key={NEXT} onPress={() => undefined}>
89 next
90 </Button>
91 </Box>
92 )}
93 <Box flexDirection="row" width={width} paddingX={1} justifyContent="space-between" backgroundColor={theme.background}>
94 <Box flexDirection="row">
95 <Text color={theme.text} bold>{`@#${list.query}`}</Text>
96 {list.isFocused && <Text inverse> </Text>}
97 {keys !== null && <Text color={theme.keys}>{` ${keys}`}</Text>}
98 </Box>
99 {count !== null && (
100 <Text color={theme.hint} wrap="truncate-end">
101 {count}
102 </Text>
103 )}
104 </Box>
105 {list.note !== null ? (
106 <Box flexDirection="row" width={width} paddingX={1} backgroundColor={theme.background}>
107 <Text color={list.note.isWarning ? theme.warning : theme.hint}>{list.note.text}</Text>
108 </Box>
109 ) : (
110 rows.map((row, index) => issueRow(parts, row, first + index === list.picked, numbers, width, onPick))
111 )}
112 </Box>
113 )
114}
115hooks/theme.ts 11 lines1// Every color is a key of Claude Code's theme, so the list follows whatever `/theme` picks, light or
2// dark. The list is drawn as the vim mod's completions are in the statusline mod's bar.
3export const theme = {
4 background: 'userMessageBackground',
5 picked: 'selectionBg',
6 text: 'text',
7 keys: 'suggestion',
8 hint: 'inactive',
9 warning: 'warning',
10}
11types/index.d.ts 42 lines1// One open issue as a row of the list shows it.
2export type Row = { number: number; title: string; labels: string[] }
3
4// What the list says in place of its rows: that the issues are on their way, that none answers, or
5// why there are none to list.
6export type Note = { text: string; isWarning: boolean }
7
8// The list above the prompt while an `@#` reference is typed: what it is filtered by, whether its
9// field holds the keys, the issues that answer, best first, and the one picked among them, how many
10// are open in which repository, and a note where there are no rows.
11export type List = {
12 query: string
13 isFocused: boolean
14 rows: Row[]
15 picked: number
16 open: number
17 repository: string | null
18 note: Note | null
19}
20
21// The list's field: how many were taken down before it, which is what the one drawn now is keyed
22// by, whether it is left undrawn for the moment, and the text it was last drawn holding.
23export type FieldState = { drawn: number; isDown: boolean; value: string }
24
25// An issue the draft names with `@#N`, once it is loaded: its title, its state as gh says it
26// (`OPEN`, `CLOSED`, `MERGED`), and whether it is a pull request, which `gh issue view` answers for
27// too. The attachments mod draws each as a chip.
28export type Reference = { number: number; title: string; state: string; isPull: boolean }
29
30// These outlive a reload of the mod, so a value whose shape changes takes a new key. The
31// attachments mod reads `references`.
32declare module 'claude-code' {
33 interface PluginState {
34 issues: {
35 list: List | null
36 field: FieldState
37 // The issues the draft names, in the order it first names them, as far as they are loaded.
38 references: Reference[]
39 }
40 }
41}
42