A pane for a GitHub repo's issues: up to three columns, one per issue in hand (one per parallel lane), each with the issues suggested to follow it.

A Claude Code mod that keeps a small pane open beside your session, showing where you are in a GitHub repo's issues. It holds up to three columns side by side, one per issue in hand, so a session running parallel lanes shows each lane:
Claude keeps it current. The mod gives Claude a set_focus tool and one line in its system prompt asking it to call that tool when an issue in hand changes, a lane starts or ends, or the two of you agree what comes next. Claude picks each column's next issues the way it would suggest parallel pairings: ones that could follow that column's issue without colliding with what the other columns have in hand.
| Slot | Source |
|---|---|
| The columns | what Claude last set, up to three; with none set, one column built from the two rows below |
| In hand, with no columns set | the branch name (123-slug or feat/123-slug) |
| Next, with one column and no next of its own | the issue numbers in the plan section of your plan file |
Each column leaves out any issue another column has in hand, and any issue that is no longer open. The plan file is optional. By default the dial reads RESUME-HERE.md at the root of the main checkout and takes every #123 under the ## Next steps heading, skipping PR #123. A missing file just means nothing is queued until Claude sets it.
Programs. The mod runs four commands, all through one helper (run() in hooks/register.tsx), in the session's folder, each with a 20 second timeout:
| Command | Why |
|---|---|
git remote get-url origin | at session start, to learn the repo's owner/name and decide whether the dial turns on |
git branch --show-current | to take the issue in hand from a branch like 123-slug |
git worktree list --porcelain | to find the main checkout, where the plan file lives |
gh api graphql -f query=<query> | one read-only query for the number, title and state of each issue on the dial: { repository(owner: "<owner>", name: "<name>") { i42: issueOrPullRequest(number: 42) { ... on Issue { number title state } } } }, one iN entry per issue |
<owner>, <name> and the issue numbers are the only parts filled in at run time. It runs nothing else.
Where data goes. The only destination is api.github.com, through your own signed-in gh, and only in a repo where the dial is on: a title and state lookup for the issues on the dial at session start, every five minutes and on Refresh, plus one after one of Claude's turns when the dial shows an issue it has not looked up yet. The requests carry the repo's owner/name and the issue numbers on the dial, which come from the branch name, the plan section of the plan file, or Claude's set_focus calls. Nothing else read locally is sent: not the rest of the plan file, not other files, not your conversation.
Conversation. The mod hooks turn.complete only to refresh the dial when one of Claude's own turns ends (not a subagent's). It reads no conversation text. It adds one sentence to Claude's system prompt naming the repo as owner/name, and its tool's replies list issue numbers. Issue titles stay in the pane.
The one tool it answers. The mod registers its own tool, set_focus (Claude sees it as mcp__issue-dial__set_focus), and serves it from a tool.call hook matched to that tool's name alone. That is how a mod implements a tool it registers; no other tool's calls reach the hook.
The privacy policy is PRIVACY.md.
git, and the GitHub CLI signed in (gh auth login)origin is on github.com (https or ssh)claude plugin marketplace add ahl-gram/IssueDial
claude plugin install issue-dial@issue-dial
The pane opens by itself in a terminal at least 144 columns wide. In a narrower one, type /issue-dial to open it.
Set them with claude plugin configure issue-dial@issue-dial, or in a session under /plugin, Installed, Configure options.
| Option | Default | What it does |
|---|---|---|
repos | empty | The repos the dial turns on in, each as owner/name (case does not matter). Empty turns it on in every GitHub repo. |
planFile | RESUME-HERE.md | The file, at the main checkout's root, whose plan section lists the issues to suggest next. |
planHeading | Next steps | The text a ## heading starts with to begin the plan section. |
Outside a listed repo, or outside GitHub, the mod adds nothing: no tool, no prompt line, no pane.
claude plugin test . # unit and pane tests
claude plugin validate .claude-plugin/plugin.json # the plugin manifest and hooks
claude plugin validate . # the marketplace manifest
To type-check, load the mod once (claude --plugin-dir .) so Claude Code writes .claude-plugin/types/, then run tsc -p . --noEmit. The eval suite and the command that runs it are in evals/README.md.
MIT, see LICENSE.
hooks/register.tsx 310 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Column, ColumnFocus, Dial, Focus, IssueRow } from '../types'
5import {
6 MAX_COLUMNS,
7 buildWheel,
8 columnsOf,
9 drumIndent,
10 fadeOf,
11 fadedColor,
12 heldColumns,
13 issueFromBranch,
14 mainWorktree,
15 isAllowed,
16 nextStepsNumbers,
17 repoFromRemote,
18 repoList,
19 upcoming,
20 usesPlan,
21} from './logic'
22import type { Repo } from './logic'
23
24const PANE = 'issue-dial'
25const TITLE = 'Issue dial'
26const TOOL = 'set_focus'
27const GITHUB_EVERY_MS = 5 * 60_000
28const PANE_ROWS = 8
29const COLUMN_GAP = 2
30const OPEN_GREEN = '#2da44e'
31const NO_COLUMN: Column = { current: null, next: [] }
32
33const EMPTY: Dial = {
34 columns: [],
35 currentFrom: null,
36 nextFrom: null,
37 error: null,
38 refreshedAt: null,
39}
40
41const dial = atom({ plugin: 'issue-dial', key: 'dial' } as const, EMPTY)
42const focus = atom({ plugin: 'issue-dial', key: 'focus' } as const, { columns: [] } as Focus)
43
44type Known = { title: string; isOpen: boolean }
45
46type Setup = { repo: Repo; planFile: string; planHeading: string }
47
48let setup: Setup | null = null
49const known = new Map<number, Known>()
50
51async function run($: EngineInterface, argv: string[], cwd?: string): Promise<string> {
52 const { exitCode, stdout, stderr } = await $.process.run(argv, { cwd, timeoutMs: 20_000 })
53
54 if (exitCode !== 0) {
55 throw new Error(`${argv.slice(0, 2).join(' ')} exited ${exitCode}: ${stderr.trim().slice(0, 120)}`)
56 }
57
58 return stdout
59}
60
61async function readOr($: EngineInterface, path: string, fallback: string): Promise<string> {
62 try {
63 return await $.fs.read(path)
64 } catch {
65 return fallback
66 }
67}
68
69async function learn($: EngineInterface, repo: Repo, numbers: readonly number[], isFresh: boolean): Promise<void> {
70 const wanted = [...new Set(numbers)].filter(number => isFresh || !known.has(number))
71
72 if (wanted.length === 0) {
73 return
74 }
75
76 const fields = wanted
77 .map(number => `i${number}: issueOrPullRequest(number: ${number}) { ... on Issue { number title state } }`)
78 .join(' ')
79 const query = `{ repository(owner: "${repo.owner}", name: "${repo.name}") { ${fields} } }`
80 const reply = JSON.parse(await run($, ['gh', 'api', 'graphql', '-f', `query=${query}`])) as {
81 data: { repository: Record<string, { number?: number; title?: string; state?: string } | null> }
82 }
83
84 for (const found of Object.values(reply.data.repository)) {
85 if (found?.number !== undefined && found.title !== undefined) {
86 known.set(found.number, { title: found.title, isOpen: found.state === 'OPEN' })
87 }
88 }
89}
90
91function rowOf(number: number): IssueRow {
92 return { number, title: known.get(number)?.title ?? '(not an issue in this repo)' }
93}
94
95async function refresh($: EngineInterface, isFresh: boolean): Promise<void> {
96 if (setup === null) {
97 return
98 }
99
100 const { repo, planFile, planHeading } = setup
101
102 try {
103 const cwd = await $.session.cwd()
104 const branch = await run($, ['git', 'branch', '--show-current'], cwd)
105 const root = mainWorktree(await run($, ['git', 'worktree', 'list', '--porcelain'], cwd)) ?? cwd
106 const plan = await readOr($, `${root}/${planFile}`, '')
107 const held = heldColumns(await read($, focus))
108 const fromBranch = issueFromBranch(branch)
109 const planned = nextStepsNumbers(plan, planHeading)
110 const columns = columnsOf(held, fromBranch, planned)
111 const inHand = columns.flatMap(column => (column.current === null ? [] : [column.current]))
112 const nexts = columns.map(column => upcoming(column.next, inHand))
113
114 await learn($, repo, [...inHand, ...nexts.flat()], isFresh)
115
116 const refreshedAt = await $.clock.now()
117 const fromPlan = usesPlan(held)
118
119 await update($, dial, (): Dial => ({
120 columns: columns.map((column, index) => ({
121 current: column.current === null ? null : rowOf(column.current),
122 next: (nexts[index] ?? []).filter(number => known.get(number)?.isOpen === true).map(rowOf),
123 })),
124 currentFrom: held.length > 0 ? 'session' : fromBranch !== null ? 'branch' : null,
125 nextFrom: !fromPlan ? 'session' : planned.length > 0 ? 'plan' : null,
126 error: null,
127 refreshedAt,
128 }))
129 } catch (error) {
130 const message = error instanceof Error ? error.message : String(error)
131 await update($, dial, (previous): Dial => ({ ...previous, error: message }))
132 }
133}
134
135function soon($: EngineInterface, isFresh: boolean): void {
136 $.clock.after(0, () => void refresh($, isFresh))
137}
138
139async function githubRepo($: EngineInterface): Promise<Repo | null> {
140 try {
141 return repoFromRemote(await run($, ['git', 'remote', 'get-url', 'origin'], await $.session.cwd()))
142 } catch {
143 return null
144 }
145}
146
147export const register: Register = (on, options) => {
148 const repos = repoList(options.repos)
149 const planFile = typeof options.planFile === 'string' ? options.planFile : ''
150 const planHeading = typeof options.planHeading === 'string' ? options.planHeading : ''
151
152 on('session.start', async ($, e, next) => {
153 const repo = await githubRepo($)
154 setup = repo !== null && isAllowed(repo, repos) ? { repo, planFile, planHeading } : null
155
156 if (setup !== null) {
157 await $.command.register({ name: 'issue-dial', description: 'Show the issue dial: each issue in hand and what could follow it' })
158 await $.tool.register({
159 name: TOOL,
160 description:
161 `Updates the user's issue-dial pane: one column per issue being worked on in this repo at the same time (up to ${MAX_COLUMNS}, e.g. one per parallel lane), each with the issue in hand and the issues suggested to follow it. Choose each column's \`next\` the way you would suggest parallel pairings: open issues that could follow that column's issue without colliding with what the other columns have in hand, most likely first. Call it when an issue in hand changes, a lane starts or ends, or what comes next is agreed. \`columns\` empty hands the dial back to the branch name and ${planFile}.`,
162 inputSchema: {
163 type: 'object',
164 properties: {
165 columns: {
166 type: 'array',
167 maxItems: MAX_COLUMNS,
168 description: 'One entry per issue in hand, in the order to show them left to right.',
169 items: {
170 type: 'object',
171 properties: {
172 current: { type: ['integer', 'null'], description: 'The issue number this column has in hand, or null.' },
173 next: { type: 'array', items: { type: 'integer' }, maxItems: 5, description: 'Issue numbers that could follow it, most likely first.' },
174 },
175 required: ['current', 'next'],
176 },
177 },
178 },
179 required: ['columns'],
180 },
181 })
182 void $.ui.open({ id: PANE, title: TITLE, rows: PANE_ROWS })
183 soon($, true)
184 $.clock.every(GITHUB_EVERY_MS, () => void refresh($, true))
185 }
186
187 return next(e)
188 })
189
190 on('command.run', { command: 'issue-dial' }, async $ => {
191 await $.ui.open({ id: PANE, title: TITLE, rows: PANE_ROWS })
192 soon($, true)
193
194 return { text: 'Issue dial opened.' }
195 })
196
197 on('turn.complete', async ($, e, next) => {
198 const done = await next(e)
199
200 if (setup !== null && e.agentId === undefined) {
201 soon($, false)
202 }
203
204 return done
205 })
206
207 on('tool.call', { tool: 'mcp__issue-dial__set_focus' }, async ($, e) => {
208 const input = e as unknown as { columns?: unknown }
209 const columns = (Array.isArray(input.columns) ? input.columns : []).slice(0, MAX_COLUMNS).map((column: unknown): ColumnFocus => {
210 const { current, next } = (column ?? {}) as { current?: unknown; next?: unknown }
211
212 return {
213 current: Number.isInteger(current) ? (current as number) : null,
214 next: Array.isArray(next) ? next.filter((n): n is number => Number.isInteger(n)).slice(0, 5) : [],
215 }
216 })
217
218 await update($, focus, () => ({ columns }))
219 await refresh($, false)
220
221 const shown = await read($, dial)
222 const named = (row: IssueRow | null) => (row === null ? 'none' : `Issue #${row.number}`)
223
224 const said = (shown.columns.length > 0 ? shown.columns : [NO_COLUMN])
225 .map(column => `now ${named(column.current)} (from ${shown.currentFrom ?? 'nothing'}), next ${column.next.map(named).join(', ') || 'none'}`)
226 .join('; ')
227
228 return {
229 result: `Issue dial: ${said}.${shown.error ? ` Refresh failed: ${shown.error}` : ''}`,
230 }
231 })
232
233 on('prompt.compose', async ($, e, next) => {
234 const composed = await next(e)
235
236 if (setup === null) {
237 return composed
238 }
239
240 return {
241 sections: [
242 ...composed.sections,
243 {
244 id: 'issue-dial:focus',
245 scope: 'session',
246 text: `The user keeps an issue-dial pane open for ${setup.repo.owner}/${setup.repo.name}: up to ${MAX_COLUMNS} columns, one per issue in hand (one per parallel lane), each with the issues that could follow it. When an issue in hand changes, a lane starts or ends, or you and the user settle what comes next, call mcp__issue-dial__${TOOL} so it stays true, choosing each column's next issues the way you would suggest parallel pairings. With no columns set, the branch name sets the issue in hand.`,
247 },
248 ],
249 }
250 })
251
252 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
253 const { Box, Button, Text } = $.ui.resolve(e)
254 const state = await read($, dial)
255 const shown = state.columns.length > 0 ? state.columns : [NO_COLUMN]
256 const width = Math.max(10, e.props.bodyColumns)
257 const columnWidth = Math.max(4, Math.floor((width - COLUMN_GAP * (shown.length - 1)) / shown.length))
258 const rule = '─'.repeat(columnWidth)
259 const label = (row: IssueRow) => (shown.length === 1 ? `Issue #${row.number} ${row.title}` : `#${row.number} ${row.title}`)
260
261 const columns = shown.map((column, index) => (
262 <Box key={`column-${index}`} flexDirection="column" width={columnWidth}>
263 {buildWheel(column.current, column.next).map(slot =>
264 fadeOf(slot.distance) === 'center' ? (
265 <Box key="now" flexDirection="column">
266 <Text dimColor>{rule}</Text>
267 <Text bold color={slot.row === null ? undefined : OPEN_GREEN} wrap="truncate-end">
268 {slot.row === null ? 'Nothing in hand' : label(slot.row)}
269 </Text>
270 <Text dimColor>{rule}</Text>
271 </Box>
272 ) : (
273 <Text key={`next-${slot.distance}`} color={fadedColor(OPEN_GREEN, slot.distance)} wrap="truncate-end">
274 {drumIndent(slot.distance)}
275 {slot.row === null ? '' : label(slot.row)}
276 </Text>
277 ),
278 )}
279 {column.next.length === 0 && state.refreshedAt !== null && (
280 <Text dimColor wrap="truncate-end">
281 Nothing queued next.
282 </Text>
283 )}
284 </Box>
285 ))
286
287 const sources = `now: ${state.currentFrom ?? 'none'} · next: ${state.nextFrom === 'plan' ? planFile : (state.nextFrom ?? 'none')}`
288
289 return (
290 <Box flexDirection="column">
291 {state.refreshedAt === null && state.error === null && <Text dimColor>Reading the issues…</Text>}
292 <Box flexDirection="row" columnGap={COLUMN_GAP}>
293 {columns}
294 </Box>
295 <Box>
296 <Text dimColor wrap="truncate-end">
297 {sources}{' '}
298 </Text>
299 <Button key="refresh" label="Refresh" hotkey="r" onPress={() => refresh($, true)} />
300 </Box>
301 {state.error !== null && (
302 <Text color="red" wrap="truncate-end">
303 {state.error}
304 </Text>
305 )}
306 </Box>
307 )
308 })
309}
310hooks/logic.ts 121 lines1import type { ColumnFocus, IssueRow } from '../types'
2
3export type Fade = 'center' | 'near' | 'far'
4
5export type WheelRow = {
6 kind: 'current' | 'next'
7 distance: number
8 row: IssueRow | null
9}
10
11export const REACH = 3
12
13export const MAX_COLUMNS = 3
14
15export type Repo = { owner: string; name: string }
16
17const GITHUB_REMOTE =
18 /^(?:https:\/\/(?:[^@/]+@)?github\.com\/|git@github\.com:|ssh:\/\/git@github\.com(?::\d+)?\/)([\w.-]+)\/([\w.-]+?)(?:\.git)?\/?$/
19
20export function repoFromRemote(url: string): Repo | null {
21 const [, owner, name] = GITHUB_REMOTE.exec(url.trim()) ?? []
22
23 return owner && name ? { owner, name } : null
24}
25
26export function repoList(value: unknown): string[] {
27 if (typeof value === 'string') {
28 return value.split(/[\s,]+/)
29 }
30
31 return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : []
32}
33
34export function isAllowed(repo: Repo, repos: readonly string[]): boolean {
35 const listed = repos.map(entry => entry.trim().toLowerCase()).filter(entry => entry !== '')
36
37 return listed.length === 0 || listed.includes(`${repo.owner}/${repo.name}`.toLowerCase())
38}
39
40export function issueFromBranch(branch: string): number | null {
41 const match = /^(?:[\w.-]+\/)?(\d+)(?:-|$)/.exec(branch.trim())
42
43 return match ? Number(match[1]) : null
44}
45
46export function nextStepsNumbers(plan: string, heading: string): number[] {
47 const wanted = heading.trim().toLowerCase()
48 const lines = plan.split('\n')
49 const start = lines.findIndex(line => /^##\s/.test(line) && line.replace(/^##\s+/, '').toLowerCase().startsWith(wanted))
50
51 if (start === -1) {
52 return []
53 }
54
55 const rest = lines.slice(start + 1)
56 const end = rest.findIndex(line => /^##\s/.test(line))
57 const section = (end === -1 ? rest : rest.slice(0, end)).join('\n')
58 const numbers = [...section.matchAll(/(?<!PR )#(\d+)/g)].map(match => Number(match[1]))
59
60 return [...new Set(numbers)]
61}
62
63export function mainWorktree(porcelain: string): string | null {
64 const first = porcelain.split('\n').find(line => line.startsWith('worktree '))
65
66 return first ? first.slice('worktree '.length) : null
67}
68
69export function upcoming(numbers: readonly number[], exclude: readonly number[]): number[] {
70 return numbers.filter(number => !exclude.includes(number))
71}
72
73export function heldColumns(focus: unknown): ColumnFocus[] {
74 const columns = (focus as { columns?: unknown } | undefined)?.columns
75
76 return Array.isArray(columns) ? (columns as ColumnFocus[]) : []
77}
78
79export function usesPlan(held: readonly ColumnFocus[]): boolean {
80 return held.length === 0 || (held.length === 1 && held[0]?.next.length === 0)
81}
82
83export function columnsOf(held: readonly ColumnFocus[], fromBranch: number | null, planned: readonly number[]): ColumnFocus[] {
84 if (held.length === 0) {
85 return [{ current: fromBranch, next: [...planned] }]
86 }
87
88 return held.slice(0, MAX_COLUMNS).map(column => (usesPlan(held) ? { ...column, next: [...planned] } : column))
89}
90
91export function buildWheel(current: IssueRow | null, next: readonly IssueRow[]): WheelRow[] {
92 const below = next.slice(0, REACH).map((row, index): WheelRow => ({ kind: 'next', distance: index + 1, row }))
93
94 return [{ kind: 'current', distance: 0, row: current }, ...below]
95}
96
97export function fadeOf(distance: number): Fade {
98 if (distance === 0) {
99 return 'center'
100 }
101
102 return distance === 1 ? 'near' : 'far'
103}
104
105const MIDDLE_GRAY = 128
106const FADE_BY_DISTANCE = [0, 0, 0.45, 0.7]
107
108export function fadedColor(hex: string, distance: number): string {
109 const amount = FADE_BY_DISTANCE[Math.min(distance, FADE_BY_DISTANCE.length - 1)] ?? 0
110 const channels = [1, 3, 5].map(at => parseInt(hex.slice(at, at + 2), 16))
111
112 return `#${channels
113 .map(channel => Math.round(channel + (MIDDLE_GRAY - channel) * amount).toString(16).padStart(2, '0'))
114 .join('')}`
115}
116
117export function drumIndent(distance: number): string {
118 return ' '.repeat(Math.max(0, distance - 1))
119}
120
121types/index.d.ts 26 lines1export type IssueRow = { number: number; title: string }
2
3export type CurrentSource = 'branch' | 'session' | null
4
5export type NextSource = 'session' | 'plan' | null
6
7export type Column = { current: IssueRow | null; next: IssueRow[] }
8
9export type Dial = {
10 columns: Column[]
11 currentFrom: CurrentSource
12 nextFrom: NextSource
13 error: string | null
14 refreshedAt: number | null
15}
16
17export type ColumnFocus = { current: number | null; next: number[] }
18
19export type Focus = { columns: ColumnFocus[] }
20
21declare module 'claude-code' {
22 interface PluginState {
23 'issue-dial': { dial: Dial; focus: Focus }
24 }
25}
26