SLOPSHOPPER

issues

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…

newbandtoastpromptprocesstimer
v0.1.0MITupdated 2026-10-06thefuga/claude-x/mods/issues
A shopper browsing a rack in a slop shop
README

issues

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.

Install

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.

Works with

  • vim: its command line's field is in the same band above the prompt. While the list is up, the focus chord (its Ctrl+X : too) moves the keys into the list instead; at any other time it opens the command line as before.
  • attachments: each @#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.
  • statusline and syntax: nothing is shared. A pick leaves the draft uncolored until the next typed key, as :e does.

None of them is needed.

Finding an issue

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:

  • digits match an issue's number from its start, the exact one first: @#16 lists #16, then #161;
  • anything else matches titles: those that start with it, then those with a word that does, then those that hold it, then those that have its letters in order.

A space, or the cursor moving off the reference, takes the list down. Typing @#161 in full needs no list at all.

Picking one

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:

  • Click an issue to pick it, in the fullscreen renderer (/tui fullscreen), the one that reports the mouse.
  • Ctrl+X Tab, Claude Code's own chord for the area above the prompt (or any key bound to abovePrompt:focus, such as the vim mod's Ctrl+X :), moves the keys into the list:
KeyIn the list
Down, TabThe next issue, round the end
Up, Shift+TabThe one before
Letters, digits, BackspaceNarrow the list further; the draft is left as it is
EnterMakes the reference @#N and gives the keys back to the prompt
EscapeGives 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.

As a chip

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.

What the model reads

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.

Limits

  • GitHub only, from the origin. The repository is the git origin of the folder Claude Code runs in, over HTTPS or SSH on github.com. There is no setting to name another; a folder without one says so in the list.
  • The hundred newest open issues. The list is fetched once and filtered as you type, so it holds at most a hundred; an @# typed five minutes later fetches it again. Older and closed issues are reached by their number.
  • The prompt waits for gh. Fetching the issues a prompt names takes a moment before it is sent, five seconds at most each.
  • Pull requests are not in the list. 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.
  • Prompts you type. A prompt a plugin, a scheduled task or another session sends is left as it is.
  • The terminal only. The desktop app's prompt is left as it is.
Source 7 files
hooks/register.tsx 603 lines
1import { 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}
603
hooks/draft.ts 67 lines
1// 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}
67
hooks/github.ts 186 lines
1// 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')
186
hooks/matches.ts 59 lines
1// 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}
59
hooks/view.tsx 115 lines
1import 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}
115
hooks/theme.ts 11 lines
1// 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}
11
types/index.d.ts 42 lines
1// 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