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

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.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).
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.#<number>.claude plugin validate .
claude plugin test .
claude --plugin-dir .hooks/register.ts 56 lines1import 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