Merge pull request #9418 from Kilo-Org/feat/show-pr-in-cli

feat(cli): show branch pr in sidebar (rename test)
This commit is contained in:
Marian Alexandru Alecu
2026-04-24 18:07:45 +03:00
committed by GitHub
3 changed files with 293 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": minor
---
Show the open GitHub PR for the current branch in the session sidebar.
@@ -5,6 +5,7 @@ import HomeNews from "@/kilocode/plugins/home-news"
import HomeOnboarding from "@/kilocode/plugins/home-onboarding"
import KiloHomeFooter from "@/kilocode/plugins/home-footer"
import KiloSidebarFooter from "@/kilocode/plugins/sidebar-footer"
import KiloSidebarPr from "@/kilocode/plugins/sidebar-pr"
import KiloSidebarUsage from "@/kilocode/plugins/sidebar-usage"
// kilocode_change end
import SidebarContext from "../feature-plugins/sidebar/context"
@@ -26,6 +27,7 @@ export const INTERNAL_TUI_PLUGINS: InternalTuiPlugin[] = [
HomeOnboarding, // kilocode_change
KiloHomeFooter, // kilocode_change
KiloSidebarFooter, // kilocode_change
KiloSidebarPr, // kilocode_change
KiloSidebarUsage, // kilocode_change
HomeFooter,
HomeTips,
@@ -0,0 +1,286 @@
// kilocode_change - new file
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@kilocode/plugin/tui"
import { createMemo, createResource, Show } from "solid-js"
import { Process } from "@/util"
const id = "internal:kilo-sidebar-pr"
const GH_PROBE_TTL = 300_000
type Pr = { number: number; title: string }
type Item = Partial<Pr> & { headRefOid?: string }
type Repo = {
nameWithOwner?: unknown
parent?: { nameWithOwner?: unknown; name?: unknown; owner?: { login?: unknown } } | null
}
let ghPath: string | null | undefined
let ghProbeTime = 0
function wait(ms: number, signal: AbortSignal) {
return new Promise<void>((resolve, reject) => {
const done = () => {
clearTimeout(id)
signal.removeEventListener("abort", stop)
}
const stop = () => {
done()
reject(signal.reason)
}
const id = setTimeout(() => {
done()
resolve()
}, ms)
signal.addEventListener("abort", stop, { once: true })
})
}
async function lookup(cwd: string, branch: string): Promise<Pr | null> {
const ctrl = new AbortController()
const deadline = wait(20_000, ctrl.signal).then(() => ctrl.abort())
try {
if (!probeGh()) return null
// Try the tracking ref first (works when PR was checked out via `gh pr checkout`
// or when the branch's upstream is a fork). Fall back to an explicit branch
// lookup (works for same-repo branches pushed to origin).
const build = (b?: string) => {
const a = ["gh", "pr", "view"]
if (b) a.push(b)
a.push("--json", "number,title")
return a
}
for (const cmd of [build(), build(branch)]) {
const res = await Process.text(cmd, {
cwd,
abort: ctrl.signal,
nothrow: true,
timeout: 1_000,
})
if (res.code !== 0) continue
const text = res.text.trim()
if (!text) continue
const data = JSON.parse(text) as Partial<Pr>
if (typeof data.number === "number" && typeof data.title === "string") {
return { number: data.number, title: data.title }
}
}
const head = await lookupHead(cwd, ctrl.signal)
if (!head) return null
const local = await lookupBySha(cwd, head, undefined, ctrl.signal)
if (local) return local
const parent = await lookupParent(cwd, ctrl.signal)
if (!parent) return null
const pr = await lookupByHead(cwd, branch, head, parent, ctrl.signal)
if (pr) return pr
return await lookupBySha(cwd, head, parent, ctrl.signal)
} finally {
ctrl.abort()
await deadline.catch(() => undefined)
}
}
async function lookupHead(cwd: string, signal: AbortSignal): Promise<string | null> {
const res = await Process.text(["git", "rev-parse", "HEAD"], {
cwd,
abort: signal,
nothrow: true,
timeout: 1_000,
})
if (res.code !== 0) return null
const head = res.text.trim()
if (!head) return null
return head
}
async function lookupParent(cwd: string, signal: AbortSignal): Promise<string | null> {
const res = await Process.text(["gh", "repo", "view", "--json", "nameWithOwner,parent"], {
cwd,
abort: signal,
nothrow: true,
timeout: 1_000,
})
if (res.code !== 0) return null
const text = res.text.trim()
if (!text) return null
try {
const data = JSON.parse(text) as Repo
if (typeof data.nameWithOwner !== "string") return null
if (!data.parent) return null
const parent =
typeof data.parent.nameWithOwner === "string"
? data.parent.nameWithOwner
: typeof data.parent.owner?.login === "string" && typeof data.parent.name === "string"
? `${data.parent.owner.login}/${data.parent.name}`
: null
if (!parent || parent === data.nameWithOwner) return null
return parent
} catch {
return null
}
}
async function lookupByHead(
cwd: string,
branch: string,
head: string,
repo: string,
signal: AbortSignal,
): Promise<Pr | null> {
const res = await Process.text(
[
"gh",
"pr",
"list",
"-R",
repo,
"--state",
"open",
"--head",
branch,
"--limit",
"10",
"--json",
"number,title,headRefOid",
],
{
cwd,
abort: signal,
nothrow: true,
timeout: 1_000,
},
)
if (res.code !== 0) return null
const text = res.text.trim()
if (!text) return null
return select(text, head)
}
async function lookupBySha(
cwd: string,
head: string,
repo: string | undefined,
signal: AbortSignal,
): Promise<Pr | null> {
const cmd = [
"gh",
"pr",
"list",
...(repo ? ["-R", repo] : []),
"--state",
"open",
"--search",
`${head} is:pr`,
"--limit",
"5",
"--json",
"number,title,headRefOid",
]
const res = await Process.text(cmd, {
cwd,
abort: signal,
nothrow: true,
timeout: 1_000,
})
if (res.code !== 0) return null
const text = res.text.trim()
if (!text) return null
return select(text, head)
}
function select(text: string, head: string): Pr | null {
try {
const items = JSON.parse(text) as Item[]
if (!Array.isArray(items) || items.length === 0) return null
// Only accept a PR whose HEAD matches ours exactly — avoids returning a
// random PR that merely references the SHA in a commit message.
for (const item of items) {
if (item.headRefOid !== head) continue
if (typeof item.number !== "number") continue
if (typeof item.title !== "string") continue
return { number: item.number, title: item.title }
}
return null
} catch {
return null
}
}
function probeGh(): string | null {
const now = Date.now()
if (ghPath !== undefined && now - ghProbeTime < GH_PROBE_TTL) return ghPath
ghPath = Bun.which("gh") ?? null
ghProbeTime = now
return ghPath
}
function View(props: { api: TuiPluginApi }) {
const theme = () => props.api.theme.current
const branch = createMemo(() => props.api.state.vcs?.branch)
const cwd = createMemo(() => props.api.state.path.directory)
// Primitive string key: createResource wraps source in createMemo and
// compares with === for equality, so the same inputs produce a stable key
// and the fetcher is not retriggered on every render.
const key = createMemo(() => {
const b = branch()
const d = cwd()
if (!b || !d) return false as const
return `${d}\0${b}`
})
const [pr] = createResource(key, async (k) => {
if (!k) return null
const [d, b] = k.split("\0")
if (!d || !b) return null
return lookup(d, b).catch(() => null)
})
// The wrapper <box> must be present unconditionally — the OpenTUI slot
// registry relies on a stable root node per plugin. Gating the whole tree
// on `<Show>` breaks the mount. Conditionally render only the inner text.
return (
<box>
<Show when={pr()}>
<text fg={theme().textMuted}>
PR #{pr()!.number} - {pr()!.title}
</text>
</Show>
</box>
)
}
const tui: TuiPlugin = async (api) => {
api.slots.register({
order: 50,
slots: {
sidebar_content(_ctx, _props) {
return <View api={api} />
},
},
})
}
const plugin: TuiPluginModule & { id: string } = { id, tui }
export default plugin