Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save andrea-dagostino/9002c807723cf74bca717acbbb6f7eb2 to your computer and use it in GitHub Desktop.

Select an option

Save andrea-dagostino/9002c807723cf74bca717acbbb6f7eb2 to your computer and use it in GitHub Desktop.
Github Issue Tracker Pi Agent Extension
/**
* 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