SLOPSHOPPER

gh-autocomplete

Typeahead for GitHub PRs and issues when you type # in a git repo

newpromptprocess
A shopper browsing a rack in a slop shop
README

gh-autocomplete

A Claude Code mod that autocompletes GitHub pull requests and issues in the prompt box. Type # inside a git repo and pick one from the typeahead; keep typing to filter by number (#63) or title (#beta).

  • #pr or #issue shows only that kind.
  • #pr:<query> or #issue:<query> filters within it: #issue:login, #pr:63.

Install

At the prompt of a Claude Code terminal session:

/plugin install gh-autocomplete --marketplace gugahoi/claude-mods

Answer y to add the marketplace, then pick a scope.

Needs the GitHub CLI installed and signed in (gh auth login).

How it works

  • Outside a git repo it does nothing.
  • Inside one it runs gh pr list --state all --limit 100 and gh issue list --state open --limit 100 side by side and caches the result for 60 seconds per directory, so the first # takes about a second. If one fails (say, issues are turned off) the other still shows.
  • It suggests up to 10, newest number first, below Claude Code's own suggestions. Picking one writes #<number>.

Develop

claude plugin validate .
claude plugin test .
claude --plugin-dir .
Source 1 files
hooks/register.ts 56 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3export type Item = { kind: 'pr' | 'issue'; number: number; title: string; state: string }
4
5const TTL_MS = 60_000
6const cache = new Map<string, { at: number; items: Promise<Item[]> }>()
7
8async function list($: EngineInterface, cwd: string, kind: Item['kind'], state: string): Promise<Item[]> {
9  const gh = await $.process.run(
10    ['gh', kind, 'list', '--state', state, '--limit', '100', '--json', 'number,title,state'],
11    { cwd },
12  )
13  // non-zero on its own, e.g. a repo with issues turned off: the other kind still shows
14  return gh.exitCode === 0 ? JSON.parse(gh.stdout).map((i: Omit<Item, 'kind'>) => ({ ...i, kind })) : []
15}
16
17export async function load($: EngineInterface, cwd: string): Promise<Item[]> {
18  const git = await $.process.run(['git', 'rev-parse', '--is-inside-work-tree'], { cwd })
19  if (git.exitCode !== 0) return []
20  const [prs, issues] = await Promise.all([list($, cwd, 'pr', 'all'), list($, cwd, 'issue', 'open')])
21  return [...prs, ...issues].sort((a, b) => b.number - a.number)
22}
23
24async function items($: EngineInterface): Promise<Item[]> {
25  const cwd = await $.session.cwd()
26  const hit = cache.get(cwd)
27  if (hit && Date.now() - hit.at < TTL_MS) return hit.items
28  const fresh = load($, cwd).catch(() => {
29    cache.delete(cwd) // gh missing or the call was cut short: retry on the next keystroke
30    return []
31  })
32  cache.set(cwd, { at: Date.now(), items: fresh })
33  return fresh
34}
35
36// `pr` or `issue` alone, or followed by `:` and a query, narrows to that kind
37export const match = (list: readonly Item[], query: string) => {
38  const [, kind, rest = query] = /^(pr|issue)(?::|$)(.*)/i.exec(query) ?? []
39  const q = rest.toLowerCase()
40  return list
41    .filter(i => !kind || i.kind === kind.toLowerCase())
42    .filter(i => String(i.number).startsWith(q) || i.title.toLowerCase().includes(q))
43    .slice(0, 10)
44}
45
46export const register: Register = on => {
47  on('prompt.autocomplete', { token: /^#/ }, async ($, e, next) => {
48    const [list, below] = await Promise.all([items($), next(e)])
49    const mine = match(list, e.token.slice(1)).map(i => ({
50      text: `#${i.number}`,
51      description: `${i.kind === 'pr' ? `PR ${i.state.toLowerCase()}` : 'issue'} · ${i.title}`,
52    }))
53    return { suggestions: [...below.suggestions, ...mine] }
54  })
55}
56