Created
August 7, 2026 08:02
-
-
Save andrea-dagostino/9002c807723cf74bca717acbbb6f7eb2 to your computer and use it in GitHub Desktop.
Github Issue Tracker Pi Agent Extension
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * GitHub Issues Panel | |
| * | |
| * Ambient issue strip above the editor (never overlaps chat) plus an | |
| * expandable right-side overlay with a status timeline of tracked issues | |
| * and their sub-issues, for the current repository (via the GitHub CLI `gh`). | |
| * | |
| * ctrl+shift+i or /issues — toggle the strip above the editor | |
| * /issues expand — toggle the full right-side panel | |
| * | |
| * Behavior: | |
| * - Hidden by default; fetches on open, then polls every 5 minutes while | |
| * either view is visible. | |
| * - Timeline shows parent issues with nested sub-issues and a status glyph: | |
| * ✓ completed (closed + completed) | |
| * ○ pending (open) | |
| * ✕ closed (closed, not completed — cancelled / not planned) | |
| * - Parent links come from GitHub native `parent` when present, else from a | |
| * body reference (`## Parent` / `issues/N` link). | |
| * - Strip guarantees at least a few visible nodes (adaptive layout). | |
| * - Expanded panel shows up to PANEL_MAX timeline rows, read-only, never steals focus. | |
| * - No-ops (with one notification) outside GitHub repos or when `gh` is missing. | |
| * | |
| * Requires: GitHub CLI (`gh`), authenticated. | |
| */ | |
| import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent"; | |
| import { | |
| truncateToWidth, | |
| visibleWidth, | |
| type Component, | |
| type OverlayHandle, | |
| type TUI, | |
| } from "@earendil-works/pi-tui"; | |
| const MAX_FETCH = 100; // hard cap of issues fetched from GitHub | |
| const PANEL_MAX = 50; // rows rendered in the expanded panel | |
| const POLL_MS = 5 * 60_000; // refresh interval while any view is visible | |
| const GH_TIMEOUT_MS = 15_000; | |
| const MIN_TERM_WIDTH = 100; // expanded panel hidden below this terminal width | |
| const WIDGET_KEY = "gh-issues-panel"; | |
| /** Issue lifecycle status shown in the timeline. */ | |
| type IssueStatus = "done" | "pending" | "closed"; | |
| interface RawIssue { | |
| number: number; | |
| title: string; | |
| state: "OPEN" | "CLOSED" | string; | |
| stateReason?: string | null; | |
| body?: string | null; | |
| parent?: { number: number } | null; | |
| } | |
| interface IssueNode { | |
| number: number; | |
| title: string; | |
| status: IssueStatus; | |
| parentNumber: number | null; | |
| children: IssueNode[]; | |
| } | |
| interface PanelState { | |
| repoName: string; | |
| status: "loading" | "ok" | "error"; | |
| /** Root issues (parents with children first, then standalone open). */ | |
| roots: IssueNode[]; | |
| /** Flattened pre-order walk used by compact strip chips. */ | |
| flat: IssueNode[]; | |
| /** completed / total among nodes that appear in the timeline. */ | |
| doneCount: number; | |
| totalCount: number; | |
| hasMore: boolean; // true when the fetch hit MAX_FETCH | |
| error?: string; | |
| updatedAt?: number; | |
| } | |
| function updatedClock(updatedAt: number): string { | |
| const d = new Date(updatedAt); | |
| return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`; | |
| } | |
| function issueStatus(state: string, stateReason?: string | null): IssueStatus { | |
| const s = (state || "").toUpperCase(); | |
| if (s === "OPEN") return "pending"; | |
| const reason = (stateReason || "COMPLETED").toUpperCase(); | |
| // CLOSED without reason is treated as completed (gh often omits it). | |
| if (reason === "COMPLETED" || reason === "") return "done"; | |
| return "closed"; // NOT_PLANNED, REOPENED edge cases, etc. | |
| } | |
| /** Pull a parent issue number from native parent or a body reference. */ | |
| function parseParentNumber(raw: RawIssue): number | null { | |
| if (raw.parent?.number && raw.parent.number !== raw.number) { | |
| return raw.parent.number; | |
| } | |
| const body = raw.body ?? ""; | |
| // Prefer an explicit Parent heading, then any issues/N link in the body. | |
| const heading = body.match( | |
| /(?:^|\n)\s{0,3}#{1,6}\s*Parent\b[^\n]*\n+[\s\S]*?issues\/(\d+)/i, | |
| ); | |
| if (heading?.[1]) { | |
| const n = Number(heading[1]); | |
| return Number.isFinite(n) && n !== raw.number ? n : null; | |
| } | |
| // Fall through: look for "Parent" near an issues/N URL within the first ~1.5k chars. | |
| // Bare #N mentions elsewhere in the body are too noisy to treat as parent links. | |
| const head = body.slice(0, 1500); | |
| const nearParent = head.match(/Parent[\s\S]{0,200}?issues\/(\d+)/i); | |
| if (nearParent?.[1]) { | |
| const n = Number(nearParent[1]); | |
| return Number.isFinite(n) && n !== raw.number ? n : null; | |
| } | |
| return null; | |
| } | |
| /** | |
| * Build a forest of issues. Children nest under parents; standalone open | |
| * issues (no parent, no kids) appear as their own roots. Standalone closed | |
| * issues with no children are dropped (noise). | |
| */ | |
| function buildForest(raws: RawIssue[]): { roots: IssueNode[]; flat: IssueNode[] } { | |
| const byNumber = new Map<number, IssueNode>(); | |
| for (const r of raws) { | |
| byNumber.set(r.number, { | |
| number: r.number, | |
| title: r.title, | |
| status: issueStatus(r.state, r.stateReason), | |
| parentNumber: parseParentNumber(r), | |
| children: [], | |
| }); | |
| } | |
| // Attach children. If the declared parent wasn't in the fetch, synthesize a stub. | |
| for (const node of byNumber.values()) { | |
| if (node.parentNumber == null) continue; | |
| let parent = byNumber.get(node.parentNumber); | |
| if (!parent) { | |
| parent = { | |
| number: node.parentNumber, | |
| title: `#${node.parentNumber}`, | |
| status: "pending", | |
| parentNumber: null, | |
| children: [], | |
| }; | |
| byNumber.set(parent.number, parent); | |
| } | |
| // Avoid cycles | |
| if (parent.number === node.number) continue; | |
| if (!parent.children.some((c) => c.number === node.number)) { | |
| parent.children.push(node); | |
| } | |
| } | |
| // Sort children by number (stable rollout order) | |
| for (const node of byNumber.values()) { | |
| node.children.sort((a, b) => a.number - b.number); | |
| } | |
| const childNumbers = new Set<number>(); | |
| for (const node of byNumber.values()) { | |
| for (const c of node.children) childNumbers.add(c.number); | |
| } | |
| const roots: IssueNode[] = []; | |
| for (const node of byNumber.values()) { | |
| if (childNumbers.has(node.number)) continue; // not a root | |
| const isStandaloneClosed = | |
| node.children.length === 0 && node.status !== "pending" && node.parentNumber == null; | |
| if (isStandaloneClosed) continue; | |
| roots.push(node); | |
| } | |
| // Parents with kids first (by number), then standalone open issues (by number) | |
| roots.sort((a, b) => { | |
| const aHas = a.children.length > 0 ? 0 : 1; | |
| const bHas = b.children.length > 0 ? 0 : 1; | |
| if (aHas !== bHas) return aHas - bHas; | |
| return a.number - b.number; | |
| }); | |
| const flat: IssueNode[] = []; | |
| const walk = (n: IssueNode) => { | |
| flat.push(n); | |
| for (const c of n.children) walk(c); | |
| }; | |
| for (const r of roots) walk(r); | |
| return { roots, flat }; | |
| } | |
| function countProgress(nodes: IssueNode[]): { done: number; total: number } { | |
| let done = 0; | |
| let total = 0; | |
| const visit = (n: IssueNode) => { | |
| // Progress is measured on leaf-ish work: children if present, else the node itself. | |
| if (n.children.length > 0) { | |
| for (const c of n.children) visit(c); | |
| } else { | |
| total += 1; | |
| if (n.status === "done") done += 1; | |
| } | |
| }; | |
| for (const n of nodes) visit(n); | |
| return { done, total }; | |
| } | |
| function statusGlyph(theme: Theme, status: IssueStatus): string { | |
| if (status === "done") return theme.fg("success", "✓"); | |
| if (status === "closed") return theme.fg("error", "✕"); | |
| return theme.fg("dim", "○"); | |
| } | |
| function plainGlyph(status: IssueStatus): string { | |
| if (status === "done") return "✓"; | |
| if (status === "closed") return "✕"; | |
| return "○"; | |
| } | |
| /** Multi-row strip rendered above the editor. Never overlaps chat content. */ | |
| class IssuesStripComponent implements Component { | |
| constructor( | |
| private readonly tui: TUI, | |
| private readonly theme: Theme, | |
| private readonly state: PanelState, | |
| ) {} | |
| refresh(): void { | |
| this.invalidate(); | |
| this.tui.requestRender(); | |
| } | |
| invalidate(): void { | |
| // No cached render state; render() derives everything from this.state. | |
| } | |
| private chip(node: IssueNode, chipW: number): string { | |
| const glyph = statusGlyph(this.theme, node.status); | |
| const num = `#${node.number}`; | |
| const plainPrefix = `${plainGlyph(node.status)} ${num} `; | |
| const titleBudget = Math.max(4, chipW - visibleWidth(plainPrefix)); | |
| const title = truncateToWidth(node.title, titleBudget, "…"); | |
| const plain = `${plainPrefix}${title}`; | |
| const styled = `${glyph} ${this.theme.fg("dim", num)} ${title}`; | |
| return styled + " ".repeat(Math.max(0, chipW - visibleWidth(plain))); | |
| } | |
| render(width: number): string[] { | |
| const th = this.theme; | |
| const s = this.state; | |
| const w = Math.max(20, width); | |
| const inner = w - 2; // border on both sides | |
| const lines: string[] = []; | |
| const border = (text: string) => th.fg("accent", text); | |
| const row = (content: string): string => | |
| border("│") + content + " ".repeat(Math.max(0, inner - visibleWidth(content))) + border("│"); | |
| lines.push(border(`╭${"─".repeat(inner)}╮`)); | |
| // Header: repo + progress left; expand hint + status right | |
| const progress = | |
| s.totalCount > 0 ? `${s.doneCount}/${s.totalCount}` : `${s.flat.length}${s.hasMore ? "+" : ""}`; | |
| const headLeft = ` ${s.repoName || "GitHub"} · ${progress}`; | |
| let headRight = ""; | |
| if (s.status === "error" && s.error) headRight = `! ${truncateToWidth(s.error, 30, "…")}`; | |
| else if (s.updatedAt) headRight = `updated ${updatedClock(s.updatedAt)}`; | |
| if (w >= 60) headRight = headRight ? `/issues expand · ${headRight} ` : "/issues expand "; | |
| const leftW = visibleWidth(headLeft); | |
| const rightW = visibleWidth(headRight); | |
| if (leftW + rightW + 1 <= inner) { | |
| const rightStyled = s.status === "error" ? th.fg("warning", headRight) : th.fg("dim", headRight); | |
| lines.push(row(th.bold(th.fg("accent", headLeft)) + " ".repeat(inner - leftW - rightW) + rightStyled)); | |
| } else { | |
| lines.push(row(th.bold(th.fg("accent", truncateToWidth(headLeft, inner, "…"))))); | |
| } | |
| // Body | |
| if (s.status === "loading" && s.flat.length === 0) { | |
| lines.push(row(th.fg("dim", " Loading issues…"))); | |
| lines.push(border(`╰${"─".repeat(inner)}╯`)); | |
| return lines.map((line) => truncateToWidth(line, w)); | |
| } | |
| if (s.flat.length === 0) { | |
| lines.push(row(th.fg("dim", " No issues"))); | |
| lines.push(border(`╰${"─".repeat(inner)}╯`)); | |
| return lines.map((line) => truncateToWidth(line, w)); | |
| } | |
| // Prefer one parent timeline when present: parent header + child chips. | |
| // Fallback: flat chip grid of the walk order. | |
| const primary = s.roots.find((r) => r.children.length > 0) ?? null; | |
| if (primary) { | |
| const pDone = primary.children.filter((c) => c.status === "done").length; | |
| const pTotal = primary.children.length; | |
| const pGlyph = statusGlyph(th, primary.status); | |
| const pHead = ` ${pGlyph} ${th.fg("dim", `#${primary.number}`)} ${truncateToWidth(primary.title, Math.max(8, inner - 18), "…")} ${th.fg("dim", `${pDone}/${pTotal}`)}`; | |
| lines.push(row(pHead)); | |
| const kids = primary.children; | |
| const cols = Math.max(1, Math.min(3, Math.floor(inner / 36))); | |
| const gap = 2; | |
| const chipW = Math.max(14, Math.floor((inner - 1 - gap * (cols - 1)) / cols)); | |
| const maxRows = 4; | |
| const capacity = cols * maxRows; | |
| let shown = Math.min(kids.length, capacity); | |
| let more = kids.length - shown; | |
| // Keep room for a "+N more" chip when overflowing, without dropping below 3 kids if possible. | |
| if (more > 0 && shown >= 4) { | |
| shown -= 1; | |
| more = kids.length - shown; | |
| } | |
| const chips: string[] = []; | |
| for (let i = 0; i < shown; i++) chips.push(this.chip(kids[i]!, chipW)); | |
| if (more > 0) { | |
| const label = `+${more} more`; | |
| chips.push(th.fg("dim", label) + " ".repeat(Math.max(0, chipW - visibleWidth(label)))); | |
| } | |
| const rows = Math.ceil(chips.length / cols); | |
| for (let r = 0; r < rows; r++) { | |
| const rowChips = chips.slice(r * cols, r * cols + cols); | |
| if (rowChips.length === 0) break; | |
| lines.push(row(" " + rowChips.join(" ".repeat(gap)))); | |
| } | |
| } else { | |
| // Flat adaptive grid | |
| const cols = Math.max(1, Math.min(4, Math.floor(inner / 50))); | |
| const gap = 2; | |
| const chipW = Math.max(16, Math.floor((inner - 1 - gap * (cols - 1)) / cols)); | |
| const n = s.flat.length; | |
| const rows = n >= 5 ? Math.min(5, Math.ceil(5 / cols)) : Math.ceil(n / cols); | |
| const capacity = cols * rows; | |
| const chips: string[] = []; | |
| let shown = Math.min(n, capacity); | |
| let more = n - shown; | |
| if (more > 0 && shown - 1 >= 5) { | |
| shown -= 1; | |
| more += 1; | |
| } | |
| for (let i = 0; i < shown; i++) chips.push(this.chip(s.flat[i]!, chipW)); | |
| if (more > 0 && chips.length < capacity) { | |
| const label = `+${more} more`; | |
| chips.push(th.fg("dim", label) + " ".repeat(Math.max(0, chipW - visibleWidth(label)))); | |
| } | |
| for (let r = 0; r < rows; r++) { | |
| const rowChips = chips.slice(r * cols, r * cols + cols); | |
| if (rowChips.length === 0) break; | |
| lines.push(row(" " + rowChips.join(" ".repeat(gap)))); | |
| } | |
| } | |
| lines.push(border(`╰${"─".repeat(inner)}╯`)); | |
| return lines.map((line) => truncateToWidth(line, w)); | |
| } | |
| } | |
| /** Bordered right-side overlay: hierarchical status timeline. */ | |
| class IssuesPanelComponent implements Component { | |
| constructor( | |
| private readonly tui: TUI, | |
| private readonly theme: Theme, | |
| private state: PanelState, | |
| ) {} | |
| refresh(): void { | |
| this.invalidate(); | |
| this.tui.requestRender(); | |
| } | |
| invalidate(): void { | |
| // No cached render state; render() derives everything from this.state. | |
| } | |
| private row(content: string, inner: number): string { | |
| const padded = content + " ".repeat(Math.max(0, inner - visibleWidth(content))); | |
| return this.theme.fg("border", "│") + padded + this.theme.fg("border", "│"); | |
| } | |
| /** | |
| * Emit timeline lines for a root and its children. | |
| * Uses box-drawing connectors so the list reads as a vertical timeline. | |
| */ | |
| private emitTree(root: IssueNode, inner: number, budget: { left: number }, out: string[]): void { | |
| const th = this.theme; | |
| const push = (content: string) => { | |
| if (budget.left <= 0) return; | |
| out.push(this.row(content, inner)); | |
| budget.left -= 1; | |
| }; | |
| const glyph = statusGlyph(th, root.status); | |
| const num = th.fg("dim", `#${root.number}`); | |
| let suffix = ""; | |
| if (root.children.length > 0) { | |
| const d = root.children.filter((c) => c.status === "done").length; | |
| suffix = th.fg("dim", ` ${d}/${root.children.length}`); | |
| } | |
| // " ✓ #N " + optional " D/T" — visibleWidth ignores ANSI in suffix | |
| const headBudget = inner - 2 - visibleWidth(` ✓ #${root.number} `) - visibleWidth(suffix); | |
| const title = truncateToWidth(root.title, Math.max(4, headBudget), "…"); | |
| push(` ${glyph} ${num} ${th.bold(title)}${suffix}`); | |
| const kids = root.children; | |
| for (let i = 0; i < kids.length; i++) { | |
| if (budget.left <= 0) return; | |
| const child = kids[i]!; | |
| const isLast = i === kids.length - 1; | |
| const branch = isLast ? "└─" : "├─"; | |
| const cGlyph = statusGlyph(th, child.status); | |
| const cNum = th.fg("dim", `#${child.number}`); | |
| // connector + glyph + #n + spaces ≈ fixed; rest is title | |
| const plainMeta = `${branch} ✓ #${child.number} `; | |
| const cTitle = truncateToWidth(child.title, Math.max(4, inner - 2 - visibleWidth(plainMeta)), "…"); | |
| const titleStyled = | |
| child.status === "done" || child.status === "closed" | |
| ? th.fg("dim", cTitle) | |
| : cTitle; | |
| push(` ${th.fg("dim", branch)} ${cGlyph} ${cNum} ${titleStyled}`); | |
| // Nested grandchildren (rare) — indent one more level | |
| const grand = child.children; | |
| for (let g = 0; g < grand.length; g++) { | |
| if (budget.left <= 0) return; | |
| const gc = grand[g]!; | |
| const gLast = g === grand.length - 1; | |
| const gBranch = `${isLast ? " " : "│ "} ${gLast ? "└─" : "├─"}`; | |
| const gGlyph = statusGlyph(th, gc.status); | |
| const gNum = th.fg("dim", `#${gc.number}`); | |
| const gPlain = `${isLast ? " " : "│ "} ${gLast ? "└─" : "├─"} ✓ #${gc.number} `; | |
| const gTitle = truncateToWidth(gc.title, Math.max(4, inner - 2 - visibleWidth(gPlain)), "…"); | |
| const gStyled = | |
| gc.status === "done" || gc.status === "closed" ? th.fg("dim", gTitle) : gTitle; | |
| push(` ${th.fg("dim", gBranch)} ${gGlyph} ${gNum} ${gStyled}`); | |
| } | |
| } | |
| } | |
| render(width: number): string[] { | |
| const th = this.theme; | |
| const inner = Math.max(8, width - 2); | |
| const bar = "─".repeat(inner); | |
| const s = this.state; | |
| const lines: string[] = []; | |
| lines.push(th.fg("border", `╭${bar}╮`)); | |
| const progress = | |
| s.totalCount > 0 ? `${s.doneCount}/${s.totalCount} done` : `${s.flat.length}${s.hasMore ? "+" : ""}`; | |
| const header = truncateToWidth(` ${s.repoName} · ${progress}`, inner, "…"); | |
| lines.push(this.row(th.bold(th.fg("accent", header)), inner)); | |
| // Legend | |
| const legend = ` ${statusGlyph(th, "done")} done ${statusGlyph(th, "pending")} pending ${statusGlyph(th, "closed")} closed`; | |
| lines.push(this.row(th.fg("dim", legend), inner)); | |
| lines.push(th.fg("border", `├${bar}┤`)); | |
| if (s.status === "loading" && s.flat.length === 0) { | |
| lines.push(this.row(th.fg("dim", " Loading…"), inner)); | |
| } else if (s.flat.length === 0) { | |
| lines.push(this.row(th.fg("dim", " No issues"), inner)); | |
| } else { | |
| const budget = { left: PANEL_MAX }; | |
| for (let i = 0; i < s.roots.length; i++) { | |
| if (budget.left <= 0) break; | |
| if (i > 0 && budget.left > 0) { | |
| // blank separator between root groups | |
| lines.push(this.row(" ", inner)); | |
| budget.left -= 1; | |
| } | |
| this.emitTree(s.roots[i]!, inner, budget, lines); | |
| } | |
| if (budget.left <= 0 && s.flat.length > PANEL_MAX) { | |
| // indicated in footer | |
| } | |
| } | |
| const parts: string[] = []; | |
| if (s.flat.length > PANEL_MAX) parts.push(`+${s.flat.length - PANEL_MAX} more`); | |
| if (s.status === "error" && s.error) parts.push(`! ${s.error}`); | |
| if (s.updatedAt) parts.push(`updated ${updatedClock(s.updatedAt)}`); | |
| parts.push("/issues expand to close"); | |
| const footer = truncateToWidth(` ${parts.join(" · ")}`, inner, "…"); | |
| lines.push(th.fg("border", `├${bar}┤`)); | |
| lines.push(this.row(th.fg(s.status === "error" ? "warning" : "dim", footer), inner)); | |
| lines.push(th.fg("border", `╰${bar}╯`)); | |
| return lines; | |
| } | |
| } | |
| export default function (pi: ExtensionAPI) { | |
| let stripVisible = false; | |
| let panelVisible = false; | |
| let creating = false; | |
| let fetching = false; | |
| let repoName: string | null = null; | |
| let strip: IssuesStripComponent | null = null; | |
| let panel: IssuesPanelComponent | null = null; | |
| let overlayHandle: OverlayHandle | null = null; | |
| let pollTimer: ReturnType<typeof setInterval> | null = null; | |
| let sessionCwd = process.cwd(); | |
| const state: PanelState = { | |
| repoName: "", | |
| status: "loading", | |
| roots: [], | |
| flat: [], | |
| doneCount: 0, | |
| totalCount: 0, | |
| hasMore: false, | |
| }; | |
| function syncPolling(): void { | |
| const anyVisible = stripVisible || panelVisible; | |
| if (anyVisible && !pollTimer) { | |
| pollTimer = setInterval(() => void fetchIssues(), POLL_MS); | |
| } else if (!anyVisible && pollTimer) { | |
| clearInterval(pollTimer); | |
| pollTimer = null; | |
| } | |
| } | |
| function applyPatch(patch: Partial<PanelState>): void { | |
| Object.assign(state, patch); | |
| strip?.refresh(); | |
| panel?.refresh(); | |
| } | |
| async function fetchIssues(): Promise<void> { | |
| if (fetching || (!strip && !panel)) return; | |
| fetching = true; | |
| try { | |
| // Open + closed so completed sub-issues appear on the timeline. | |
| const res = await pi.exec( | |
| "gh", | |
| [ | |
| "issue", "list", | |
| "--state", "all", | |
| "--limit", String(MAX_FETCH), | |
| "--json", "number,title,state,stateReason,body", | |
| ], | |
| { timeout: GH_TIMEOUT_MS, cwd: sessionCwd }, | |
| ); | |
| if (res.code !== 0) { | |
| const firstLine = (res.stderr || "gh issue list failed").trim().split("\n")[0]; | |
| throw new Error(firstLine ?? "gh issue list failed"); | |
| } | |
| const raws = JSON.parse(res.stdout) as RawIssue[]; | |
| const { roots, flat } = buildForest(raws); | |
| const { done, total } = countProgress(roots); | |
| applyPatch({ | |
| status: "ok", | |
| roots, | |
| flat, | |
| doneCount: done, | |
| totalCount: total, | |
| hasMore: raws.length >= MAX_FETCH, | |
| updatedAt: Date.now(), | |
| error: undefined, | |
| }); | |
| } catch (err) { | |
| const msg = err instanceof Error ? err.message : String(err); | |
| applyPatch({ status: "error", error: truncateToWidth(msg, 60, "…") }); | |
| } finally { | |
| fetching = false; | |
| } | |
| } | |
| async function ensureRepo(ctx: ExtensionContext): Promise<boolean> { | |
| if (repoName) return true; | |
| const res = await pi.exec( | |
| "gh", | |
| ["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"], | |
| { timeout: GH_TIMEOUT_MS, cwd: ctx.cwd }, | |
| ); | |
| if (res.code !== 0 || !res.stdout.trim()) { | |
| ctx.ui.notify("Issues panel: not a GitHub repo (or gh missing/unauthenticated)", "warning"); | |
| return false; | |
| } | |
| repoName = res.stdout.trim(); | |
| state.repoName = repoName; | |
| sessionCwd = ctx.cwd; | |
| return true; | |
| } | |
| // --- Strip (widget above editor) --- | |
| async function toggleStrip(ctx: ExtensionContext): Promise<void> { | |
| if (ctx.mode !== "tui") return; | |
| if (stripVisible) { | |
| stripVisible = false; | |
| strip = null; | |
| ctx.ui.setWidget(WIDGET_KEY, undefined); | |
| syncPolling(); | |
| return; | |
| } | |
| if (!(await ensureRepo(ctx))) return; | |
| stripVisible = true; | |
| ctx.ui.setWidget( | |
| WIDGET_KEY, | |
| (tui, theme) => { | |
| strip = new IssuesStripComponent(tui, theme, state); | |
| return strip; | |
| }, | |
| { placement: "aboveEditor" }, | |
| ); | |
| syncPolling(); | |
| void fetchIssues(); | |
| } | |
| // --- Expanded panel (right overlay, transient) --- | |
| async function ensurePanelOverlay(ctx: ExtensionContext): Promise<boolean> { | |
| if (overlayHandle || panel) return true; | |
| if (creating) return false; | |
| creating = true; | |
| try { | |
| void ctx.ui | |
| .custom<undefined>( | |
| (tui, theme, _keybindings, done) => { | |
| panel = new IssuesPanelComponent(tui, theme, state); | |
| void done; // transient panel: dismissed via /issues expand | |
| return panel; | |
| }, | |
| { | |
| overlay: true, | |
| overlayOptions: { | |
| width: "30%", | |
| minWidth: 28, | |
| maxHeight: "90%", | |
| anchor: "right-center", | |
| nonCapturing: true, | |
| visible: (termWidth) => termWidth >= MIN_TERM_WIDTH, | |
| }, | |
| onHandle: (handle) => { | |
| overlayHandle = handle; | |
| if (!panelVisible) handle.setHidden(true); | |
| }, | |
| }, | |
| ) | |
| .catch(() => {}) | |
| .finally(() => { | |
| overlayHandle = null; | |
| panel = null; | |
| panelVisible = false; | |
| syncPolling(); | |
| }); | |
| return true; | |
| } finally { | |
| creating = false; | |
| } | |
| } | |
| async function togglePanel(ctx: ExtensionContext): Promise<void> { | |
| if (ctx.mode !== "tui") return; | |
| if (panelVisible) { | |
| panelVisible = false; | |
| overlayHandle?.setHidden(true); | |
| syncPolling(); | |
| return; | |
| } | |
| if (!(await ensureRepo(ctx))) return; | |
| panelVisible = true; | |
| if (!(await ensurePanelOverlay(ctx))) { | |
| panelVisible = false; | |
| return; | |
| } | |
| overlayHandle?.setHidden(false); | |
| syncPolling(); | |
| void fetchIssues(); | |
| } | |
| pi.registerCommand("issues", { | |
| description: "Toggle the GitHub issues strip (/issues expand for the full timeline)", | |
| handler: async (args, ctx) => { | |
| const sub = args.trim().toLowerCase(); | |
| if (sub === "" || sub === "strip") { | |
| await toggleStrip(ctx); | |
| } else if (sub === "expand" || sub === "all") { | |
| await togglePanel(ctx); | |
| } else { | |
| ctx.ui.notify("Usage: /issues [expand]", "info"); | |
| } | |
| }, | |
| }); | |
| pi.registerShortcut("ctrl+shift+i", { | |
| description: "Toggle the GitHub issues strip", | |
| handler: async (ctx) => toggleStrip(ctx), | |
| }); | |
| pi.on("session_shutdown", (_event, ctx) => { | |
| stripVisible = false; | |
| panelVisible = false; | |
| if (pollTimer) { | |
| clearInterval(pollTimer); | |
| pollTimer = null; | |
| } | |
| strip = null; | |
| panel = null; | |
| overlayHandle?.hide(); | |
| overlayHandle = null; | |
| repoName = null; | |
| ctx.ui.setWidget(WIDGET_KEY, undefined); | |
| }); | |
| }% |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment