Project Info Panel v2 Implementation Plan โ
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Enhance the VSCode extension's Project Info panel from a flat list into 7 collapsible groups with rich project stats.
Architecture: Add 6 new fetch functions to githubData.js (issues, PRs, CI, reviews, branches, activity). Refactor ProjectInfoProvider.js to use a parent/child tree with InfoGroup (collapsible) and InfoItem (leaf) classes. All fetches run in parallel via Promise.all.
Tech Stack: VS Code TreeView API, GitHub CLI (gh), GitHub Search API, local git commands.
Task 1: Add InfoGroup class to ProjectInfoProvider โ
Files:
Modify:
tooling/vscode-extension/src/providers/ProjectInfoProvider.js:7-24[ ] Step 1: Add InfoGroup class above InfoItem
Add the collapsible parent class right after the existing imports:
class InfoGroup extends vscode.TreeItem {
constructor(label, { description, tooltip, icon, url, children }) {
super(label, vscode.TreeItemCollapsibleState.Collapsed)
this.description = description || ''
this.tooltip = tooltip || ''
this.iconPath = new vscode.ThemeIcon(icon)
this.contextValue = 'projectInfoGroup'
this.children = children || []
if (url) {
this.command = {
command: 'vscode.open',
title: 'Open in Browser',
arguments: [vscode.Uri.parse(url)],
}
}
}
}- [ ] Step 2: Update
getChildrento support parent/child
Replace the existing getChildren() method:
getChildren(element) {
if (element instanceof InfoGroup) {
return element.children
}
return this._buildTree()
}- [ ] Step 3: Extract current
getChildrenbody into_buildTree
Rename the current getChildren() body to _buildTree():
_buildTree() {
const branch = this._branch || getCurrentBranch(this._root)
const pkg = readPackageJson(this._root)
const version = pkg.version || 'unknown'
const items = []
// (existing items will be replaced in later tasks)
// ... keep existing code for now ...
return items
}- [ ] Step 4: Verify extension still loads
Open VS Code, check the Lantern Control sidebar loads without errors. The panel should look identical to before.
- [ ] Step 5: Commit
git add tooling/vscode-extension/src/providers/ProjectInfoProvider.js
git commit -m "refactor(vscode-ext): add InfoGroup class for collapsible tree items"Task 2: Add fetchIssueCounts and fetchPRCounts to githubData โ
Files:
Modify:
tooling/vscode-extension/src/lib/githubData.js[ ] Step 1: Add a helper for GitHub search count queries
Add this after the existing execAsync function:
/**
* Run a GitHub search query and return the total_count.
* @param {string} cwd โ workspace root
* @param {string} query โ search query (without repo: prefix, that's added automatically)
* @returns {Promise<number|null>}
*/
async function searchCount(cwd, query) {
const raw = await execAsync('gh', [
'api', `search/issues?q=repo:{owner}/{repo}+${query}`,
'--jq', '.total_count',
], { cwd })
if (!raw) return null
const n = parseInt(raw.trim(), 10)
return Number.isNaN(n) ? null : n
}- [ ] Step 2: Add
fetchIssueCounts
/**
* Fetch issue counts from GitHub search API.
* @param {string} cwd โ workspace root
* @returns {Promise<{ open: number, closed: number, unassigned: number } | null>}
*/
async function fetchIssueCounts(cwd) {
const [open, closed, unassigned] = await Promise.all([
searchCount(cwd, 'type:issue+state:open'),
searchCount(cwd, 'type:issue+state:closed'),
searchCount(cwd, 'type:issue+state:open+no:assignee'),
])
if (open === null && closed === null) return null
return { open: open ?? 0, closed: closed ?? 0, unassigned: unassigned ?? 0 }
}- [ ] Step 3: Add
fetchPRCounts
/**
* Fetch pull request counts from GitHub search API.
* @param {string} cwd โ workspace root
* @returns {Promise<{ open: number, merged: number, closed: number, draft: number } | null>}
*/
async function fetchPRCounts(cwd) {
const [open, merged, closed, draft] = await Promise.all([
searchCount(cwd, 'type:pr+state:open'),
searchCount(cwd, 'type:pr+is:merged'),
searchCount(cwd, 'type:pr+state:closed+-is:merged'),
searchCount(cwd, 'type:pr+state:open+draft:true'),
])
if (open === null && merged === null) return null
return { open: open ?? 0, merged: merged ?? 0, closed: closed ?? 0, draft: draft ?? 0 }
}- [ ] Step 4: Update exports
module.exports = { fetchActiveMilestone, fetchBranchPR, fetchIssueCounts, fetchPRCounts }- [ ] Step 5: Commit
git add tooling/vscode-extension/src/lib/githubData.js
git commit -m "feat(vscode-ext): add fetchIssueCounts and fetchPRCounts via GitHub search API"Task 3: Add fetchCIStatus and fetchReviewRequests to githubData โ
Files:
Modify:
tooling/vscode-extension/src/lib/githubData.js[ ] Step 1: Add
fetchCIStatus
/**
* Fetch CI workflow run status for a branch.
* Returns the latest run per unique workflow name.
* @param {string} cwd โ workspace root
* @param {string} branch โ branch name
* @returns {Promise<{ overall: string, runs: Array<{ name: string, conclusion: string, status: string, url: string, createdAt: string }> } | null>}
*/
async function fetchCIStatus(cwd, branch) {
const raw = await execAsync('gh', [
'run', 'list',
'--branch', branch,
'--limit', '20',
'--json', 'name,status,conclusion,createdAt,url',
], { cwd })
if (!raw) return null
try {
const allRuns = JSON.parse(raw)
if (!allRuns.length) return { overall: 'none', runs: [] }
// Dedupe: keep only the latest run per workflow name
const seen = new Set()
const runs = []
for (const run of allRuns) {
if (!seen.has(run.name)) {
seen.add(run.name)
runs.push(run)
}
}
// Derive overall status
let overall = 'passing'
if (runs.some(r => r.status === 'in_progress' || r.status === 'queued')) {
overall = 'running'
}
if (runs.some(r => r.conclusion === 'failure')) {
overall = 'failing'
}
return { overall, runs }
} catch {
return null
}
}- [ ] Step 2: Add
fetchReviewRequests
/**
* Fetch open PRs requesting review from the authenticated user.
* @param {string} cwd โ workspace root
* @returns {Promise<Array<{ number: number, title: string, url: string, createdAt: string }> | null>}
*/
async function fetchReviewRequests(cwd) {
const raw = await execAsync('gh', [
'api', 'search/issues?q=repo:{owner}/{repo}+type:pr+state:open+review-requested:@me',
'--jq', '[.items[] | {number, title, url: .html_url, createdAt: .created_at}]',
], { cwd })
if (!raw) return null
try {
return JSON.parse(raw)
} catch {
return null
}
}- [ ] Step 3: Update exports
module.exports = {
fetchActiveMilestone, fetchBranchPR, fetchIssueCounts, fetchPRCounts,
fetchCIStatus, fetchReviewRequests,
}- [ ] Step 4: Commit
git add tooling/vscode-extension/src/lib/githubData.js
git commit -m "feat(vscode-ext): add fetchCIStatus and fetchReviewRequests"Task 4: Add fetchBranchStats and fetchActivityStats to githubData โ
Files:
Modify:
tooling/vscode-extension/src/lib/githubData.js[ ] Step 1: Add
fetchBranchStats
/**
* Fetch branch statistics from local git.
* @param {string} cwd โ workspace root
* @returns {Promise<{ local: number, remote: number, gone: number, stale: number } | null>}
*/
async function fetchBranchStats(cwd) {
const [localRaw, remoteRaw, vvRaw, refRaw] = await Promise.all([
execAsync('git', ['branch', '--list'], { cwd }),
execAsync('git', ['branch', '-r', '--list'], { cwd }),
execAsync('git', ['branch', '-vv'], { cwd }),
execAsync('git', ['for-each-ref', '--format=%(refname:short) %(committerdate:unix)', 'refs/heads/'], { cwd }),
])
if (!localRaw && !remoteRaw) return null
const local = localRaw ? localRaw.trim().split('\n').filter(l => l.trim()).length : 0
const remote = remoteRaw ? remoteRaw.trim().split('\n').filter(l => l.trim()).length : 0
// Count branches with [gone] upstream
const gone = vvRaw
? vvRaw.trim().split('\n').filter(l => /\[.*gone\]/.test(l)).length
: 0
// Count stale branches (no commits in 30+ days)
const thirtyDaysAgo = Math.floor(Date.now() / 1000) - (30 * 24 * 60 * 60)
let stale = 0
if (refRaw) {
for (const line of refRaw.trim().split('\n')) {
const parts = line.trim().split(' ')
const timestamp = parseInt(parts[parts.length - 1], 10)
if (timestamp && timestamp < thirtyDaysAgo) stale++
}
}
return { local, remote, gone, stale }
}- [ ] Step 2: Add
fetchActivityStats
/**
* Fetch commit activity from local git.
* @param {string} cwd โ workspace root
* @returns {Promise<{ weekCommits: number, totalCommits: number, contributors: number, lastCommit: Date|null } | null>}
*/
async function fetchActivityStats(cwd) {
const [weekRaw, totalRaw, contribRaw, lastRaw] = await Promise.all([
execAsync('git', ['rev-list', '--count', '--since=7 days ago', '--all'], { cwd }),
execAsync('git', ['rev-list', '--count', '--all'], { cwd }),
execAsync('git', ['log', '--format=%ae', '--since=7 days ago', '--all'], { cwd }),
execAsync('git', ['log', '-1', '--format=%ci'], { cwd }),
])
if (!totalRaw) return null
const weekCommits = weekRaw ? parseInt(weekRaw.trim(), 10) : 0
const totalCommits = parseInt(totalRaw.trim(), 10)
// Count unique contributors
const contributors = contribRaw
? new Set(contribRaw.trim().split('\n').filter(l => l.trim())).size
: 0
// Parse last commit date
const lastCommit = lastRaw ? new Date(lastRaw.trim()) : null
return { weekCommits, totalCommits, contributors, lastCommit }
}- [ ] Step 3: Update exports
module.exports = {
fetchActiveMilestone, fetchBranchPR, fetchIssueCounts, fetchPRCounts,
fetchCIStatus, fetchReviewRequests, fetchBranchStats, fetchActivityStats,
}- [ ] Step 4: Commit
git add tooling/vscode-extension/src/lib/githubData.js
git commit -m "feat(vscode-ext): add fetchBranchStats and fetchActivityStats (local git)"Task 5: Refactor ProjectInfoProvider refresh() to fetch all data โ
Files:
Modify:
tooling/vscode-extension/src/providers/ProjectInfoProvider.js[ ] Step 1: Update imports
Replace the existing import line:
const { fetchActiveMilestone, fetchBranchPR } = require('../lib/githubData')With:
const {
fetchActiveMilestone, fetchBranchPR, fetchIssueCounts, fetchPRCounts,
fetchCIStatus, fetchReviewRequests, fetchBranchStats, fetchActivityStats,
} = require('../lib/githubData')- [ ] Step 2: Add new state fields to constructor
Replace the constructor body:
constructor(workspaceRoot) {
this._root = workspaceRoot
this._change = new vscode.EventEmitter()
this.onDidChangeTreeData = this._change.event
this._milestone = null
this._pr = null
this._branch = null
this._issues = null
this._prCounts = null
this._ci = null
this._reviews = null
this._branches = null
this._activity = null
this._lastFetch = null
}- [ ] Step 3: Replace
refresh()to fetch all in parallel
async refresh() {
const branch = getCurrentBranch(this._root)
this._branch = branch
const [milestone, pr, issues, prCounts, ci, reviews, branches, activity] = await Promise.all([
fetchActiveMilestone(this._root),
branch ? fetchBranchPR(this._root, branch) : null,
fetchIssueCounts(this._root),
fetchPRCounts(this._root),
branch ? fetchCIStatus(this._root, branch) : null,
fetchReviewRequests(this._root),
fetchBranchStats(this._root),
fetchActivityStats(this._root),
])
this._milestone = milestone
this._pr = pr
this._issues = issues
this._prCounts = prCounts
this._ci = ci
this._reviews = reviews
this._branches = branches
this._activity = activity
this._lastFetch = new Date()
this._change.fire()
}- [ ] Step 4: Commit
git add tooling/vscode-extension/src/providers/ProjectInfoProvider.js
git commit -m "refactor(vscode-ext): fetch all project info data in parallel"Task 6: Build the grouped tree in _buildTree() โ
Files:
Modify:
tooling/vscode-extension/src/providers/ProjectInfoProvider.js[ ] Step 1: Replace
_buildTree()with full grouped implementation
Replace the entire _buildTree() method with the following. This builds all 7 collapsible groups plus 2 flat items at the bottom:
_buildTree() {
const branch = this._branch || getCurrentBranch(this._root)
const pkg = readPackageJson(this._root)
const version = pkg.version || 'unknown'
const items = []
// โโ Milestone group โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
if (this._milestone) {
const m = this._milestone
items.push(new InfoGroup(`Milestone: ${m.title}`, {
description: `${m.percent}% (${m.closed}/${m.total})`,
tooltip: `${m.title} โ ${m.closed} closed, ${m.open} open of ${m.total}`,
icon: 'milestone',
url: m.url,
children: [
new InfoItem('Open', { description: `${m.open}`, icon: 'circle-filled' }),
new InfoItem('Closed', { description: `${m.closed}`, icon: 'pass' }),
],
}))
} else {
items.push(new InfoItem('Milestone: unavailable', {
description: 'could not fetch from GitHub',
tooltip: 'Run refresh or check that gh CLI is authenticated',
icon: 'milestone',
}))
}
// โโ Issues group โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
if (this._issues) {
const i = this._issues
items.push(new InfoGroup('Issues', {
description: `${i.open} open, ${i.closed} closed`,
tooltip: `${i.open} open, ${i.closed} closed, ${i.unassigned} unassigned`,
icon: 'issues',
url: `https://github.com/${this._getRepo()}/issues`,
children: [
new InfoItem('Open', { description: `${i.open}`, icon: 'circle-filled' }),
new InfoItem('Closed', { description: `${i.closed}`, icon: 'pass' }),
new InfoItem('Unassigned', { description: `${i.unassigned}`, icon: 'warning' }),
],
}))
} else {
items.push(new InfoItem('Issues: unavailable', {
description: 'could not fetch from GitHub',
icon: 'issues',
}))
}
// โโ Pull Requests group โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
if (this._prCounts) {
const p = this._prCounts
const prChildren = [
new InfoItem('Open', { description: `${p.open}`, icon: 'circle-filled' }),
new InfoItem('Merged', { description: `${p.merged}`, icon: 'git-merge' }),
new InfoItem('Closed (unmerged)', { description: `${p.closed}`, icon: 'circle-slash' }),
new InfoItem('Draft', { description: `${p.draft}`, icon: 'edit' }),
]
if (this._pr) {
prChildren.push(new InfoItem(`โ Current: #${this._pr.number}`, {
description: this._pr.title,
tooltip: `#${this._pr.number} โ ${this._pr.title}\n${this._pr.url}`,
icon: 'git-pull-request',
url: this._pr.url,
}))
}
items.push(new InfoGroup('Pull Requests', {
description: `${p.open} open, ${p.merged} merged`,
tooltip: `${p.open} open, ${p.merged} merged, ${p.closed} closed, ${p.draft} draft`,
icon: 'git-pull-request',
url: `https://github.com/${this._getRepo()}/pulls`,
children: prChildren,
}))
} else {
items.push(new InfoItem('Pull Requests: unavailable', {
description: 'could not fetch from GitHub',
icon: 'git-pull-request',
}))
}
// โโ CI Status group โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
if (this._ci) {
const statusIcon = this._ci.overall === 'failing' ? 'error'
: this._ci.overall === 'running' ? 'sync~spin'
: this._ci.overall === 'none' ? 'circle-outline'
: 'pass-filled'
const statusLabel = this._ci.overall === 'failing' ? 'โ failing'
: this._ci.overall === 'running' ? 'โ running'
: this._ci.overall === 'none' ? 'no runs'
: 'โ passing'
const ciChildren = this._ci.runs.map(run => {
const runIcon = run.conclusion === 'failure' ? 'error'
: run.conclusion === 'success' ? 'pass'
: run.status === 'in_progress' ? 'sync~spin'
: 'circle-outline'
const ago = formatRelativeTime(new Date(run.createdAt))
const result = run.conclusion || run.status
return new InfoItem(run.name, {
description: `${result} (${ago})`,
tooltip: `${run.name} โ ${result}\n${run.url}`,
icon: runIcon,
url: run.url,
})
})
items.push(new InfoGroup('CI Status', {
description: statusLabel,
tooltip: `${this._ci.runs.length} workflows for ${branch}`,
icon: statusIcon,
url: `https://github.com/${this._getRepo()}/actions?query=branch:${encodeURIComponent(branch || '')}`,
children: ciChildren,
}))
} else {
items.push(new InfoItem('CI Status: unavailable', {
description: 'could not fetch from GitHub',
icon: 'circle-outline',
}))
}
// โโ Review Requests group โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
if (this._reviews) {
const reviewChildren = this._reviews.map(pr => {
const daysAgo = Math.floor((Date.now() - new Date(pr.createdAt).getTime()) / (1000 * 60 * 60 * 24))
return new InfoItem(`#${pr.number} โ ${pr.title}`, {
description: `waiting ${daysAgo} day${daysAgo !== 1 ? 's' : ''}`,
tooltip: `#${pr.number} โ ${pr.title}\n${pr.url}`,
icon: 'request-changes',
url: pr.url,
})
})
items.push(new InfoGroup('Review Requests', {
description: `${this._reviews.length} awaiting review`,
tooltip: `${this._reviews.length} PRs requesting your review`,
icon: 'eye',
url: `https://github.com/${this._getRepo()}/pulls/review-requested/@me`,
children: reviewChildren,
}))
} else {
items.push(new InfoItem('Review Requests: unavailable', {
description: 'could not fetch from GitHub',
icon: 'eye',
}))
}
// โโ Branches group โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
if (this._branches) {
const b = this._branches
const branchChildren = [
new InfoItem(`โ
Current: ${branch || 'unknown'}`, { icon: 'star-full' }),
new InfoItem('Local', { description: `${b.local}`, icon: 'git-branch' }),
new InfoItem('Remote', { description: `${b.remote}`, icon: 'cloud' }),
]
if (b.gone > 0) {
branchChildren.push(new InfoItem('Gone (remote deleted)', {
description: `${b.gone}`,
tooltip: `${b.gone} branch${b.gone !== 1 ? 'es' : ''} with deleted remote โ consider running clean_gone`,
icon: 'warning',
}))
}
if (b.stale > 0) {
branchChildren.push(new InfoItem('Stale (30+ days)', {
description: `${b.stale}`,
tooltip: `${b.stale} branch${b.stale !== 1 ? 'es' : ''} with no commits in 30+ days`,
icon: 'history',
}))
}
items.push(new InfoGroup('Branches', {
description: `${b.local} local / ${b.remote} remote`,
tooltip: `${b.local} local, ${b.remote} remote, ${b.gone} gone, ${b.stale} stale`,
icon: 'git-branch',
children: branchChildren,
}))
} else {
items.push(new InfoItem('Branches: unavailable', {
description: 'could not read git data',
icon: 'git-branch',
}))
}
// โโ Activity group โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
if (this._activity) {
const a = this._activity
const lastCommitAgo = a.lastCommit ? formatRelativeTime(a.lastCommit) : 'unknown'
items.push(new InfoGroup('Activity', {
description: `${a.weekCommits} this week, ${a.totalCommits.toLocaleString()} total`,
tooltip: `${a.weekCommits} commits in last 7 days, ${a.totalCommits.toLocaleString()} all time`,
icon: 'pulse',
children: [
new InfoItem('Commits (7d)', { description: `${a.weekCommits}`, icon: 'git-commit' }),
new InfoItem('Commits (all time)', { description: `${a.totalCommits.toLocaleString()}`, icon: 'git-commit' }),
new InfoItem('Contributors (7d)', { description: `${a.contributors}`, icon: 'person' }),
new InfoItem('Last commit', { description: lastCommitAgo, icon: 'history' }),
],
}))
} else {
items.push(new InfoItem('Activity: unavailable', {
description: 'could not read git data',
icon: 'pulse',
}))
}
// โโ Flat items โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
items.push(new InfoItem(`Version: ${version}`, { icon: 'tag' }))
if (this._lastFetch) {
const ago = formatRelativeTime(this._lastFetch)
items.push(new InfoItem(`Last refreshed: ${ago}`, {
tooltip: this._lastFetch.toLocaleString(),
icon: 'clock',
}))
}
return items
}- [ ] Step 2: Add
_getRepo()helper method to the class
Add this method to the ProjectInfoProvider class, right after _buildTree(). It extracts the owner/repo slug from the git remote URL for building GitHub links:
/**
* Get the owner/repo string from git remote.
* Cached after first call.
*/
_getRepo() {
if (this._repoSlug) return this._repoSlug
try {
const { execSync } = require('child_process')
const url = execSync('git remote get-url origin', { cwd: this._root, encoding: 'utf-8' }).trim()
// Handle both HTTPS and SSH URLs
const match = url.match(/github\.com[:/](.+?)(?:\.git)?$/)
this._repoSlug = match ? match[1] : 'owner/repo'
} catch {
this._repoSlug = 'owner/repo'
}
return this._repoSlug
}- [ ] Step 3: Verify extension loads with all groups
Open VS Code, check the Lantern Control sidebar. You should see 7 collapsible groups plus Version and Last Refreshed at the bottom. Click each group to expand and verify sub-items appear.
- [ ] Step 4: Commit
git add tooling/vscode-extension/src/providers/ProjectInfoProvider.js
git commit -m "feat(vscode-ext): build grouped tree with all 7 sections in Project Info panel"Task 7: Rebuild the extension and verify โ
Files:
Modify:
tooling/vscode-extension/(rebuild)[ ] Step 1: Build the extension
cd tooling/vscode-extension && npm run build 2>/dev/null || npx vsce package 2>/dev/null || echo "Manual build โ check package.json for build script"- [ ] Step 2: Manual verification checklist
Open VS Code with the Lantern Control sidebar and verify:
- Milestone โ shows title, percent, open/closed sub-items, click opens GitHub
- Issues โ shows open/closed/unassigned counts
- Pull Requests โ shows open/merged/closed/draft, current branch PR if exists
- CI Status โ shows overall status, per-workflow runs with relative times
- Review Requests โ shows count and individual PRs with wait times
- Branches โ shows local/remote counts, gone and stale if any
- Activity โ shows 7d and all-time commits, contributors, last commit
- Version โ still shows correctly
- Last refreshed โ updates on refresh
- Refresh button โ triggers full data reload
- Collapsed view โ each group shows summary description when collapsed
- Error graceful degradation โ disconnect from internet, refresh, verify groups show "unavailable" while local groups (Branches, Activity) still work
- [ ] Step 3: Commit built output
git add tooling/vscode-extension/
git commit -m "build(vscode-ext): rebuild with Project Info panel v2"