From 6c9146b71e8a6ce66f85d3f41bbbb5f8ee8172c5 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Fri, 23 Oct 2026 13:42:01 +0200 Subject: [PATCH 1/2] feat(tui): port Kilo sidebar panels with slot order --- migration-tracking/progress/README.md | 14 +- migration-tracking/progress/badge.svg | 4 +- migration-tracking/progress/history.svg | 6 +- migration-tracking/progress/progress.json | 22 +- migration-tracking/progress/progress.svg | 16 +- packages/plugin/src/tui/context.ts | 2 +- .../tui/src/kilocode/plugins/model-usage.ts | 69 ++++++ .../src/kilocode/plugins/sidebar-footer.tsx | 228 +++++++++++++++++ .../tui/src/kilocode/plugins/sidebar-pr.tsx | 234 ++++++++++++++++++ .../kilocode/plugins/sidebar-usage-row.tsx | 40 +++ .../src/kilocode/plugins/sidebar-usage.tsx | 214 ++++++++++++++++ packages/tui/src/kilocode/routed-model.tsx | 25 +- packages/tui/src/plugin/api.tsx | 2 +- packages/tui/src/plugin/builtins.ts | 6 + packages/tui/src/plugin/structure.ts | 12 +- .../tui/test/kilocode/model-usage.test.ts | 90 +++++++ .../tui/test/kilocode/sidebar-footer.test.ts | 48 ++++ packages/tui/test/kilocode/sidebar-pr.test.ts | 59 +++++ .../test/kilocode/sidebar-usage.view.test.tsx | 158 ++++++++++++ packages/tui/test/kilocode/slot-order.test.ts | 49 ++++ script/upstream/shared-patches.json | 18 +- 21 files changed, 1274 insertions(+), 42 deletions(-) create mode 100644 packages/tui/src/kilocode/plugins/model-usage.ts create mode 100644 packages/tui/src/kilocode/plugins/sidebar-footer.tsx create mode 100644 packages/tui/src/kilocode/plugins/sidebar-pr.tsx create mode 100644 packages/tui/src/kilocode/plugins/sidebar-usage-row.tsx create mode 100644 packages/tui/src/kilocode/plugins/sidebar-usage.tsx create mode 100644 packages/tui/test/kilocode/model-usage.test.ts create mode 100644 packages/tui/test/kilocode/sidebar-footer.test.ts create mode 100644 packages/tui/test/kilocode/sidebar-pr.test.ts create mode 100644 packages/tui/test/kilocode/sidebar-usage.view.test.tsx create mode 100644 packages/tui/test/kilocode/slot-order.test.ts diff --git a/migration-tracking/progress/README.md b/migration-tracking/progress/README.md index bf7effa0134d..ba7b5ac9a836 100644 --- a/migration-tracking/progress/README.md +++ b/migration-tracking/progress/README.md @@ -8,20 +8,20 @@ Measures Kilo's v1 delta: Kilo `main` pinned at `fb7e96eda540` (the v1 target) c ## Summary -**137 / 9828 cells done (1.4%)**, 26 dropped. `░░░░░░░░░░░░░░░░` +**143 / 9828 cells done (1.5%)**, 26 dropped. `░░░░░░░░░░░░░░░░` | Kind | Done | Total | % | |---|---:|---:|---:| -| module | 42 | 3547 | 1.2% | +| module | 47 | 3547 | 1.3% | | api | 2 | 39 | 5.1% | | patch | 65 | 3444 | 1.9% | -| test | 28 | 2798 | 1% | +| test | 29 | 2798 | 1% | Not counted: 1428 branding-only hunks (rename script territory), 561 marker-only hunks (block boundaries), generated files. Patch attribution: 0 by tag, 261 by import, 97 by identifier, 4163 unattributed (see below). Landed by rung, for patch cells closed by a done-proof: 42 plugin, 3 host, 0 seam, 5 patch. -Ratchets: 25 core-internal upstream modules imported by Kilo packages and adapters (`@opencode/core/app`, `@opencode/core/bus`, `@opencode/core/config`, `@opencode/core/config/discovery`, `@opencode/core/config/normalize`, `@opencode/core/effect/app-node-platform`, `@opencode/core/model`, `@opencode/core/model-resolver`, `@opencode/core/permission`, `@opencode/core/plugin/sdk`, `@opencode/core/plugin/system-prompt/anthropic.txt`, `@opencode/core/plugin/system-prompt/gpt-astra.txt`, `@opencode/core/plugin/system-prompt/gpt.txt`, `@opencode/core/plugin/system-prompt/kimi.txt`, `@opencode/core/plugin/system-prompt/trinity.txt`, `@opencode/core/provider`, `@opencode/core/rpc`, `@opencode/core/schema`, `@opencode/core/session/runner/model`, `@opencode/core/session/runner/prompt/system.txt`, `@opencode/core/session/system-prompt`, `@opencode/core/shell`, `@opencode/core/tool-output`, `@opencode/core/util/wildcard`, `@opencode/core/variant`); 44 shared patches by rung: 24 seam, 20 patch (`script/upstream/shared-patches.json`). +Ratchets: 25 core-internal upstream modules imported by Kilo packages and adapters (`@opencode/core/app`, `@opencode/core/bus`, `@opencode/core/config`, `@opencode/core/config/discovery`, `@opencode/core/config/normalize`, `@opencode/core/effect/app-node-platform`, `@opencode/core/model`, `@opencode/core/model-resolver`, `@opencode/core/permission`, `@opencode/core/plugin/sdk`, `@opencode/core/plugin/system-prompt/anthropic.txt`, `@opencode/core/plugin/system-prompt/gpt-astra.txt`, `@opencode/core/plugin/system-prompt/gpt.txt`, `@opencode/core/plugin/system-prompt/kimi.txt`, `@opencode/core/plugin/system-prompt/trinity.txt`, `@opencode/core/provider`, `@opencode/core/rpc`, `@opencode/core/schema`, `@opencode/core/session/runner/model`, `@opencode/core/session/runner/prompt/system.txt`, `@opencode/core/session/system-prompt`, `@opencode/core/shell`, `@opencode/core/tool-output`, `@opencode/core/util/wildcard`, `@opencode/core/variant`); 47 shared patches by rung: 27 seam, 20 patch (`script/upstream/shared-patches.json`). ## History @@ -36,8 +36,8 @@ One point per day from the committed `progress.json` on v2's first-parent histor | 2026-10-20 | 87 | 9829 | 0.9% | | 2026-10-21 | 90 | 9829 | 0.9% | | 2026-10-22 | 93 | 9829 | 0.9% | -| 2026-10-23 | 110 | 9829 | 1.1% | -| now | 137 | 9828 | 1.4% | +| 2026-10-23 | 143 | 9828 | 1.5% | +| now | 143 | 9828 | 1.5% | ## Features @@ -99,7 +99,7 @@ Cells per kind as done/total. A cell is done when it exists on v2 by the rules b | permission | 0/12 | | 0/6 | 0/16 | 0/34 | 0% | `░░░░░░░░░░░░░░░░` | | | pii | 0/1 | | | | 0/1 | 0% | `░░░░░░░░░░░░░░░░` | | | plan | 0/2 | | | 0/4 | 0/6 | 0% | `░░░░░░░░░░░░░░░░` | | -| plugin | 0/26 | | 0/2 | 0/6 | 0/34 | 0% | `░░░░░░░░░░░░░░░░` | | +| plugin | 5/26 | | 0/2 | 1/6 | 6/34 | 17.6% | `███░░░░░░░░░░░░░` | | | presence | 0/3 | | 0/1 | 0/3 | 0/7 | 0% | `░░░░░░░░░░░░░░░░` | | | process | 0/4 | 0/1 | 0/9 | 0/1 | 0/15 | 0% | `░░░░░░░░░░░░░░░░` | | | project | 0/4 | | 0/2 | 0/5 | 0/11 | 0% | `░░░░░░░░░░░░░░░░` | | diff --git a/migration-tracking/progress/badge.svg b/migration-tracking/progress/badge.svg index 985723c088de..ce7da5a0f7b1 100644 --- a/migration-tracking/progress/badge.svg +++ b/migration-tracking/progress/badge.svg @@ -1,4 +1,4 @@ - + -v2 migration1.4% +v2 migration1.5% diff --git a/migration-tracking/progress/history.svg b/migration-tracking/progress/history.svg index 2d6359eb6506..310a4378f1f2 100644 --- a/migration-tracking/progress/history.svg +++ b/migration-tracking/progress/history.svg @@ -6,7 +6,7 @@ 50% 75% 100% - + 2026-10-16: 0.1% (12/9854) 2026-10-16 2026-10-19: 0.9% (87/9829) @@ -17,8 +17,8 @@ 2026-10-21 2026-10-22: 0.9% (93/9829) 2026-10-22 -2026-10-23: 1.1% (110/9829) +2026-10-23: 1.5% (143/9828) 2026-10-23 -now: 1.4% (137/9828) +now: 1.5% (143/9828) now diff --git a/migration-tracking/progress/progress.json b/migration-tracking/progress/progress.json index c04898a90350..391ddcd4d671 100644 --- a/migration-tracking/progress/progress.json +++ b/migration-tracking/progress/progress.json @@ -4,14 +4,14 @@ "v1Base": "8100c68b507b53aac6b891edbc3040a6011d8969" }, "summary": { - "done": 137, + "done": 143, "total": 9828, - "percent": 1.4, + "percent": 1.5, "dropped": 26, "kinds": [ { "kind": "module", - "done": 42, + "done": 47, "total": 3547 }, { @@ -26,7 +26,7 @@ }, { "kind": "test", - "done": 28, + "done": 29, "total": 2798 } ], @@ -38,7 +38,7 @@ }, "coreImports": 25, "sharedPatches": { - "seam": 24, + "seam": 27, "patch": 20 } }, @@ -1427,12 +1427,12 @@ }, { "feature": "plugin", - "done": 0, + "done": 6, "total": 34, "dropped": 0, "kinds": { "module": { - "done": 0, + "done": 5, "total": 26 }, "api": { @@ -1444,7 +1444,7 @@ "total": 2 }, "test": { - "done": 0, + "done": 1, "total": 6 } } @@ -6237,6 +6237,11 @@ "module:packages/opencode/src/kilocode/components/model-info-panel-utils.ts", "module:packages/opencode/src/kilocode/components/model-info-panel.tsx", "module:packages/opencode/src/kilocode/kilo-errors.ts", + "module:packages/opencode/src/kilocode/plugins/model-usage.ts", + "module:packages/opencode/src/kilocode/plugins/sidebar-footer.tsx", + "module:packages/opencode/src/kilocode/plugins/sidebar-pr.tsx", + "module:packages/opencode/src/kilocode/plugins/sidebar-usage-row.tsx", + "module:packages/opencode/src/kilocode/plugins/sidebar-usage.tsx", "module:packages/opencode/test/kilocode/agent-global-config-dirs.test.ts", "module:packages/opencode/test/kilocode/agent-permission-overrides.test.ts", "module:packages/opencode/test/kilocode/agent-skill-permissions.test.ts", @@ -6247,6 +6252,7 @@ "module:packages/opencode/test/kilocode/kilo-gateway-headers.test.ts", "module:packages/opencode/test/kilocode/model-info-panel-utils.test.ts", "module:packages/opencode/test/kilocode/model-info-panel.types.test.ts", + "module:packages/opencode/test/kilocode/sidebar-footer.test.ts", "module:packages/opencode/test/kilocode/tui/usage.test.ts", "module:packages/schema/src/kilocode/provider-usage.ts", "module:packages/tui/src/kilocode/model-picker.ts", diff --git a/migration-tracking/progress/progress.svg b/migration-tracking/progress/progress.svg index aee57524b482..53d227c83bfc 100644 --- a/migration-tracking/progress/progress.svg +++ b/migration-tracking/progress/progress.svg @@ -1,7 +1,7 @@ -Features: 1.6% (92/5677) +Features: 1.7% (98/5677) vscode: 0/2057 (0%) @@ -2035,13 +2035,13 @@ -plugin: 0/34 (0%) - - - - - - +plugin: 6/34 (17.6%) + + + + + + diff --git a/packages/plugin/src/tui/context.ts b/packages/plugin/src/tui/context.ts index fe641b39d5e9..046a1223ef0e 100644 --- a/packages/plugin/src/tui/context.ts +++ b/packages/plugin/src/tui/context.ts @@ -222,7 +222,7 @@ export type SlotPath = keyof SlotMap * keys is a type error, not a silent priority pick. */ export type SlotClaim = Path extends SlotPath - ? { readonly render: (input: SlotMap[Path]) => JSX.Element } & ( + ? { readonly render: (input: SlotMap[Path]) => JSX.Element; readonly order?: number } & ( // kilocode_change(plugin) seam - optional order at the anchor, see resolveSlots | { readonly prepend: Path readonly append?: never diff --git a/packages/tui/src/kilocode/plugins/model-usage.ts b/packages/tui/src/kilocode/plugins/model-usage.ts new file mode 100644 index 000000000000..23c08450f304 --- /dev/null +++ b/packages/tui/src/kilocode/plugins/model-usage.ts @@ -0,0 +1,69 @@ +// Data of the Kilo sidebar usage panel. Same rules as v1 (packages/opencode/src/kilocode/session/model-usage.ts and +// packages/opencode/src/kilocode/plugins/model-usage.ts on main): one row per provider and model over the completed +// steps of the session family, keyed by the routed model of each step, sorted by cost. +import type { SessionMessageInfo } from "@opencode/client" +import { routedModel } from "../routed-model" + +type Tokens = { input: number; output: number; reasoning: number; cache: { read: number; write: number } } + +export type ModelUsage = { providerID: string; modelID: string; steps: number; cost: number; tokens: Tokens } + +/** One v2 assistant message is one step. A step counts when it has token usage, as a v1 step-finish part. */ +export function modelUsage(messages: readonly SessionMessageInfo[]) { + const rows = new Map() + messages.forEach((message) => { + if (message.type !== "assistant" || !message.tokens) return + const model = routedModel(message) ?? message.model + const key = `${model.providerID}/${model.id}` + const row = rows.get(key) ?? { providerID: model.providerID, modelID: model.id, steps: 0, cost: 0, tokens: empty() } + rows.set(key, { + ...row, + steps: row.steps + 1, + cost: row.cost + Math.max(0, message.cost ?? 0), + tokens: sum(row.tokens, message.tokens), + }) + }) + const models = [...rows.values()].toSorted( + (a, b) => b.cost - a.cost || a.providerID.localeCompare(b.providerID) || a.modelID.localeCompare(b.modelID), + ) + const totals = models.reduce( + (total, row) => ({ + steps: total.steps + row.steps, + cost: total.cost + row.cost, + tokens: sum(total.tokens, row.tokens), + }), + { steps: 0, cost: 0, tokens: empty() }, + ) + return { totals, models } +} + +/** Groups the rows by provider in row order, with the provider name when the provider list has it. */ +export function groupModelsByProvider( + models: readonly ModelUsage[], + providers: ReadonlyArray<{ id: string; name: string }>, +) { + const names = new Map(providers.map((provider) => [provider.id, provider.name])) + return [...Map.groupBy(models, (model) => model.providerID)].map(([providerID, rows]) => ({ + providerID, + providerName: names.get(providerID) ?? providerID, + models: rows, + })) +} + +/** Cache read share of the prompt tokens, as v1 `formatRate`. */ +export function cacheRate(tokens: Tokens) { + return { input: tokens.input, read: tokens.cache.read, write: tokens.cache.write } +} + +function sum(a: Tokens, b: Tokens): Tokens { + return { + input: a.input + b.input, + output: a.output + b.output, + reasoning: a.reasoning + b.reasoning, + cache: { read: a.cache.read + b.cache.read, write: a.cache.write + b.cache.write }, + } +} + +function empty(): Tokens { + return { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } +} diff --git a/packages/tui/src/kilocode/plugins/sidebar-footer.tsx b/packages/tui/src/kilocode/plugins/sidebar-footer.tsx new file mode 100644 index 000000000000..c325e82098e8 --- /dev/null +++ b/packages/tui/src/kilocode/plugins/sidebar-footer.tsx @@ -0,0 +1,228 @@ +// Kilo TUI sidebar balance, ported from v1 `packages/opencode/src/kilocode/plugins/sidebar-footer.tsx`: +// the wallet of the active Kilo account, the Kilo Pass sub-rows of the personal scope, the refresh +// policy, and the "Kilo " line below the upstream footer. The getting-started box and the +// path line stay in the upstream footer. v1 privacy mode is not ported: v2 has no privacy_mode config. +import type { LocationRef, SessionMessageInfo } from "@opencode/client" +import { Plugin } from "@opencode/plugin/tui" +import { KiloGatewayRpc } from "@opencode/schema/kilocode/gateway" +import { createEffect, createMemo, createSignal, on, onCleanup, onMount, Show } from "solid-js" + +const TEAM_POLL_MS = 5 * 60_000 +const BILLED_REFRESH_MS = 10_000 +const REFRESH_TIMEOUT_MS = 30_000 + +const usd = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", +}) + +type Pass = NonNullable + +type State = { + balance?: number + scope: ReturnType + pass: Pass | null +} + +export function format(value: number) { + return usd.format(value) +} + +export function scope(org: string | null | undefined, list?: readonly { id: string; name: string }[]) { + if (!org) { + return { kind: "Personal" as const } + } + return { + kind: "Team" as const, + name: list?.find((item) => item.id === org)?.name, + } +} + +export function creditLabel(value: ReturnType) { + if (value.kind === "Personal") return "Personal credits" + return value.name ? `${value.name} team` : "Team credits" +} + +const short = (value: number) => "$" + Math.round(value) + +// Pass credits are part of the personal balance, so we show this period's usage against the base allotment. +export function passLine(pass: Pass) { + return `${short(pass.currentPeriodUsageUsd)} / ${short(pass.currentPeriodBaseCreditsUsd)}` +} + +export function resetLabel(iso?: string | null) { + if (!iso) return undefined + const date = new Date(iso) + if (Number.isNaN(date.getTime())) return undefined + return new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" }).format(date) +} + +// A billed step: a completed Kilo assistant message with a non-zero cost. +export function billable(info: SessionMessageInfo) { + if (info.type !== "assistant") return false + return info.model.providerID === "kilo" && info.time.completed !== undefined && (info.cost ?? 0) > 0 +} + +function View(props: { context: Plugin.Context }) { + const theme = () => props.context.theme + const rpc = props.context.client.rpc(KiloGatewayRpc.Definition) + const [state, setState] = createSignal() + let seq = 0 + let inflight: AbortController | undefined + let teamPoll: ReturnType | undefined + const wallet = createMemo(() => { + const data = state() + if (!data) return undefined + if (data.balance !== undefined) return data + if (data.scope.kind === "Personal" && data.pass) return data + return undefined + }) + const tone = createMemo(() => { + const value = state()?.balance + return value !== undefined && value <= 2 ? theme().text.feedback.error.base : theme().text.muted + }) + const personalPass = createMemo(() => { + const data = wallet() + if (!data || data.scope.kind !== "Personal") return null + return data.pass + }) + const location = (): LocationRef => props.context.location ?? props.context.data.location.default() + // Signed out, the Gateway has no wallet, so the panel sends no request. + const signedIn = createMemo(() => + (props.context.data.location.integration.list(location()) ?? []).some( + (item) => item.id === "kilo" && item.connections.length > 0, + ), + ) + const refresh = () => { + const id = ++seq + if (!signedIn()) { + inflight?.abort() + clearInterval(teamPoll) + setState(undefined) + return + } + // Cancel any prior request and time this one out. The client path has no fetch timeout, so a stalled + // Gateway call would otherwise leak the in-flight request and its closure. + inflight?.abort() + const controller = new AbortController() + inflight = controller + const timeout = setTimeout(() => controller.abort(), REFRESH_TIMEOUT_MS) + const options = { location: { directory: location().directory }, signal: controller.signal } + void Promise.all([rpc.profile({}, options), rpc.balance({}, options), rpc.kiloPass({}, options).catch(() => null)]) + .then((result) => { + if (id !== seq) return + // Show the wallet whenever authenticated. An empty or zero balance is real data, not "still loading". + const next: State = { + balance: result[1].balance ?? undefined, + scope: scope(result[0].organizationID, result[0].organizations), + pass: result[2] ?? null, + } + setState(next) + // Team balances move with other members' usage, so poll while on a team; personal updates on spend. + clearInterval(teamPoll) + teamPoll = next.scope.kind === "Team" ? setInterval(refresh, TEAM_POLL_MS) : undefined + }) + .catch(() => { + if (id !== seq) return + setState(undefined) + }) + .finally(() => { + clearTimeout(timeout) + if (inflight === controller) inflight = undefined + }) + } + + createEffect(on(signedIn, refresh)) + onMount(() => { + // Switching org via /teams publishes this, so the balance updates immediately. + onCleanup(rpc.events.on("organization", () => refresh())) + // Refresh shortly after a billed turn so the balance reflects new spend. The store applies the event + // before this handler, so the assistant message already carries the completed time and cost. + let billed: ReturnType | undefined + onCleanup( + props.context.data.on("session.step.ended", (event) => { + const info = props.context.data.session.message.get(event.data.sessionID, event.data.assistantMessageID) + if (!info || !billable(info)) return + clearTimeout(billed) + billed = setTimeout(refresh, BILLED_REFRESH_MS) + }), + ) + onCleanup(() => { + clearTimeout(billed) + clearInterval(teamPoll) + inflight?.abort() + }) + }) + + return ( + + {(data) => ( + + + Balance + + + + + • + + {creditLabel(data().scope)} + + + {format(data().balance ?? 0)} + + + + {(pass) => ( + + + {" └ Kilo Pass"} + {passLine(pass())} + + 0}> + + {" Bonus"} + {"+" + format(pass().currentPeriodBonusCreditsUsd)} + + + + {(date) => ( + + {" Renews"} + {date()} + + )} + + + )} + + + )} + + ) +} + +export default Plugin.define({ + id: "kilocode.sidebar.footer", + setup(context) { + context.ui.slot({ + append: "sidebar.footer", + order: 99, + render: () => , + }) + // The version line sits below the upstream footer (location path), as the last v1 footer line. + context.ui.slot({ + append: "sidebar.footer", + order: 101, + render: () => ( + + •{" "} + + Kilo + {" "} + {context.app.version} + + ), + }) + }, +}) diff --git a/packages/tui/src/kilocode/plugins/sidebar-pr.tsx b/packages/tui/src/kilocode/plugins/sidebar-pr.tsx new file mode 100644 index 000000000000..3e314c65822d --- /dev/null +++ b/packages/tui/src/kilocode/plugins/sidebar-pr.tsx @@ -0,0 +1,234 @@ +// Kilo TUI sidebar PR panel, ported from v1 `packages/opencode/src/kilocode/plugins/sidebar-pr.tsx`: +// shows "PR # - " for the current branch through the `gh` CLI, ordered first in the sidebar. +import { Plugin } from "@opencode/plugin/tui" +import { createMemo, createResource, Show } from "solid-js" + +const GH_PROBE_TTL = 300_000 +const DEADLINE = 20_000 +const COMMAND_TIMEOUT = 1_000 + +export type Pr = { readonly number: number; readonly title: string } +export type RunResult = { readonly code: number; readonly text: string } +export type Run = (cmd: string[], cwd: string, signal: AbortSignal) => Promise<RunResult> + +type Item = Partial<Pr> & { readonly headRefOid?: string } +type Repo = { + readonly nameWithOwner?: unknown + readonly parent?: { + readonly nameWithOwner?: unknown + readonly name?: unknown + readonly owner?: { readonly login?: unknown } + } | null +} +type Exec = (cmd: string[]) => Promise<RunResult> + +let ghPath: string | null | undefined +let ghProbeTime = 0 + +export async function lookup(cwd: string, branch: string, run: Run): Promise<Pr | null> { + const ctrl = new AbortController() + const deadline = setTimeout(() => ctrl.abort(), DEADLINE) + const exec: Exec = (cmd) => run(cmd, cwd, ctrl.signal) + try { + // Try the tracking ref first (works when the PR was checked out through `gh pr checkout` or when the + // branch's upstream is a fork). Fall back to an explicit branch lookup for same-repo branches. + const view = (b?: string) => ["gh", "pr", "view", ...(b ? [b] : []), "--json", "number,title"] + for (const cmd of [view(), view(branch)]) { + const res = await exec(cmd) + if (res.code !== 0) continue + const text = res.text.trim() + if (!text) continue + const data = parse<Partial<Pr>>(text) + if (!data || typeof data.number !== "number" || typeof data.title !== "string") continue + return { number: data.number, title: data.title } + } + + const head = await headOf(exec) + if (!head) return null + + const local = await bySha(exec, head, undefined) + if (local) return local + + const fork = await forkParent(exec) + if (!fork) return null + + return (await byHead(exec, branch, head, fork)) ?? (await bySha(exec, head, fork)) + } finally { + clearTimeout(deadline) + ctrl.abort() + } +} + +export function select(text: string, head: string): Pr | null { + const items = parse<Item[]>(text) + if (!Array.isArray(items)) return null + // Only accept a PR whose HEAD matches ours exactly, to avoid 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 +} + +export function parent(text: string): string | null { + const data = parse<Repo>(text) + if (!data || typeof data.nameWithOwner !== "string") return null + if (!data.parent) return null + const name = + 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 (!name || name === data.nameWithOwner) return null + return name +} + +function parse<Value>(text: string): Value | null { + try { + return JSON.parse(text) as Value + } catch { + return null + } +} + +async function headOf(exec: Exec) { + const res = await exec(["git", "rev-parse", "HEAD"]) + if (res.code !== 0) return null + const head = res.text.trim() + return head ? head : null +} + +async function forkParent(exec: Exec) { + const res = await exec(["gh", "repo", "view", "--json", "nameWithOwner,parent"]) + if (res.code !== 0) return null + const text = res.text.trim() + if (!text) return null + return parent(text) +} + +async function byHead(exec: Exec, branch: string, head: string, repo: string) { + const res = await exec([ + "gh", + "pr", + "list", + "-R", + repo, + "--state", + "open", + "--head", + branch, + "--limit", + "10", + "--json", + "number,title,headRefOid", + ]) + if (res.code !== 0) return null + const text = res.text.trim() + if (!text) return null + return select(text, head) +} + +async function bySha(exec: Exec, head: string, repo: string | undefined) { + const res = await exec([ + "gh", + "pr", + "list", + ...(repo ? ["-R", repo] : []), + "--state", + "open", + "--search", + `${head} is:pr`, + "--limit", + "5", + "--json", + "number,title,headRefOid", + ]) + if (res.code !== 0) return null + const text = res.text.trim() + if (!text) return null + return select(text, head) +} + +function probeGh(): boolean { + const now = Date.now() + if (ghPath !== undefined && now - ghProbeTime < GH_PROBE_TTL) return ghPath !== null + ghPath = Bun.which("gh") ?? null + ghProbeTime = now + return ghPath !== null +} + +const systemRun: Run = async (cmd, cwd, signal) => { + if (cmd[0] === "gh" && !probeGh()) return { code: 1, text: "" } + return spawnText(cmd, cwd, signal) +} + +async function spawnText(cmd: string[], cwd: string, signal: AbortSignal): Promise<RunResult> { + const ctrl = new AbortController() + const stop = () => ctrl.abort() + const timer = setTimeout(stop, COMMAND_TIMEOUT) + signal.addEventListener("abort", stop, { once: true }) + if (signal.aborted) stop() + try { + const proc = Bun.spawn(cmd, { cwd, stdout: "pipe", stderr: "ignore", signal: ctrl.signal }) + const text = await new Response(proc.stdout).text() + const code = await proc.exited + return { code, text } + } catch { + return { code: 1, text: "" } + } finally { + clearTimeout(timer) + signal.removeEventListener("abort", stop) + } +} + +function View(props: { context: Plugin.Context; sessionID: string }) { + const session = createMemo(() => props.context.data.session.get(props.sessionID)) + const location = createMemo(() => session()?.location ?? props.context.location) + const directory = createMemo(() => location()?.directory) + const branch = createMemo(() => { + const ref = location() + if (!ref) return undefined + return props.context.data.location.vcs.info(ref)?.branch.current + }) + + // Primitive string key: createResource 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 dir = directory() + const name = branch() + if (!dir || !name) return false as const + return `${dir}\0${name}` + }) + + const [pr] = createResource(key, async (k) => { + if (!k) return null + const parts = k.split("\0") + if (parts.length < 2) return null + return lookup(parts[0], parts[1], systemRun).catch(() => null) + }) + + return ( + <Show when={pr()}> + {(value) => ( + <text fg={props.context.theme.text.muted}> + PR #{value().number} - {value().title} + </text> + )} + </Show> + ) +} + +export default Plugin.define({ + id: "kilocode.sidebar.pr", + setup(context) { + context.ui.slot({ + append: "sidebar.content", + order: 50, + render: (props) => <View context={context} sessionID={props.sessionID} />, + }) + }, +}) diff --git a/packages/tui/src/kilocode/plugins/sidebar-usage-row.tsx b/packages/tui/src/kilocode/plugins/sidebar-usage-row.tsx new file mode 100644 index 000000000000..4fed61c96907 --- /dev/null +++ b/packages/tui/src/kilocode/plugins/sidebar-usage-row.tsx @@ -0,0 +1,40 @@ +// Rows of the Kilo sidebar usage panel, as v1 (packages/opencode/src/kilocode/plugins/sidebar-usage-row.tsx on main). +import type { RGBA } from "@opentui/core" + +export function UsageRow(props: { label: string; value: string; color: RGBA }) { + return ( + <box flexDirection="row" justifyContent="space-between"> + <text fg={props.color}>{props.label}</text> + <text fg={props.color}>{props.value}</text> + </box> + ) +} + +export function ModelRow(props: { + label: string + steps: string + cost: string + expanded: boolean + text: RGBA + muted: RGBA + toggle: () => void +}) { + return ( + <box flexDirection="row" gap={1} onMouseDown={props.toggle}> + <text fg={props.text} flexShrink={0}> + {props.expanded ? "▼" : "▶"} + </text> + <box flexGrow={1} minWidth={0} overflow="hidden"> + <text fg={props.text} wrapMode="none"> + <b>{props.label}</b> + </text> + </box> + <box width={5} flexDirection="row" flexShrink={0} justifyContent="flex-end"> + <text fg={props.muted}>{props.steps}</text> + </box> + <box width={9} flexDirection="row" flexShrink={0} justifyContent="flex-end"> + <text fg={props.muted}>{props.cost}</text> + </box> + </box> + ) +} diff --git a/packages/tui/src/kilocode/plugins/sidebar-usage.tsx b/packages/tui/src/kilocode/plugins/sidebar-usage.tsx new file mode 100644 index 000000000000..c3ef02b875dd --- /dev/null +++ b/packages/tui/src/kilocode/plugins/sidebar-usage.tsx @@ -0,0 +1,214 @@ +// Kilo sidebar usage panel (order 150, between Context and MCP), as v1 +// (packages/opencode/src/kilocode/plugins/sidebar-usage.tsx on main): token usage and cost of the session family, +// usage per provider and routed model, and the Terminal Bench score of the current Kilo model. +import type { SessionMessageInfo } from "@opencode/client" +import { Plugin } from "@opencode/plugin/tui" +import { KiloGatewayRpc } from "@opencode/schema/kilocode/gateway" +import { createMemo, createResource, createSignal, For, Show } from "solid-js" +import { useLocal } from "../../context/local" +import { Locale } from "../../util/locale" +import { fmtAttemptCost, fmtScore } from "../components/model-info-panel-utils" +import { modelLabel } from "../routed-model" +import { familyUsage, formatCacheRate, formatCost, formatCount, formatSpeed } from "../usage-data" +import { cacheRate, groupModelsByProvider, modelUsage } from "./model-usage" +import { ModelRow, UsageRow } from "./sidebar-usage-row" + +export function SidebarUsage(props: { context: Plugin.Context; sessionID: string }) { + const data = props.context.data + const theme = props.context.theme + const local = useLocal() + const [usageOpen, setUsageOpen] = createSignal(true) + const [modelsOpen, setModelsOpen] = createSignal(true) + const [benchOpen, setBenchOpen] = createSignal(true) + const [expanded, setExpanded] = createSignal(new Set<string>()) + const root = () => data.session.root(props.sessionID) + const family = createMemo(() => { + const members = data.session.family(root()) + return members.includes(root()) ? members : [root(), ...members] + }) + const location = () => data.session.get(root())?.location ?? props.context.location ?? data.location.default() + const models = createMemo(() => data.location.model.list(location()) ?? []) + // The message store keeps only a page of each session, so the panel reads every message of the family. A step + // updates the session, so a new version reads them again, as v1 read its usage query after each step. + const version = createMemo(() => + family() + .map((id) => `${id}:${data.session.get(id)?.time.updated ?? 0}`) + .join(","), + ) + const [result] = createResource(version, () => + Promise.all(family().map((id) => messages(props.context, id))).then( + (list) => { + const flat = list.flat() + return { ...modelUsage(flat), generation: familyUsage([], flat).generation } + }, + () => undefined, + ), + ) + const usage = () => result.latest + const groups = createMemo(() => + groupModelsByProvider(usage()?.models ?? [], data.location.provider.list(location()) ?? []), + ) + const [catalog] = createResource( + () => (local.model.current()?.providerID === "kilo" ? location().directory : undefined), + (directory) => + props.context.client + .rpc(KiloGatewayRpc.Definition) + .models({}, { location: { directory } }) + .catch(() => []), + ) + const bench = createMemo(() => { + const current = local.model.current() + if (current?.providerID !== "kilo") return + return catalog.latest?.find((entry) => entry.id === current.modelID)?.terminalBench + }) + const Row = (row: { label: string; value: string }) => ( + <UsageRow label={row.label} value={row.value} color={theme.text.muted} /> + ) + const Header = (header: { open: boolean; toggle: () => void; title: string }) => ( + <box flexDirection="row" gap={1} onMouseDown={header.toggle}> + <text fg={theme.text.base}>{header.open ? "▼" : "▶"}</text> + <text fg={theme.text.base}> + <b>{header.title}</b> + </text> + </box> + ) + const toggle = (key: string) => + setExpanded((current) => { + const next = new Set(current) + if (next.has(key)) next.delete(key) + else next.add(key) + return next + }) + + return ( + <box gap={1}> + <box> + <Header open={usageOpen()} toggle={() => setUsageOpen((open) => !open)} title="Token Usage" /> + <Show when={usageOpen()}> + <Show + when={usage()} + fallback={ + <text fg={theme.text.muted}> + {result.state === "ready" || result.state === "errored" ? "Usage unavailable" : "Loading usage..."} + </text> + } + > + {(value) => ( + <> + <Row label="Input" value={formatCount(value().totals.tokens.input)} /> + <Row label="Output" value={formatCount(value().totals.tokens.output)} /> + <Row label="Reasoning" value={formatCount(value().totals.tokens.reasoning)} /> + <Row label="Cache read" value={formatCount(value().totals.tokens.cache.read)} /> + <Row label="Cache write" value={formatCount(value().totals.tokens.cache.write)} /> + <Row label="Cache rate" value={formatCacheRate(cacheRate(value().totals.tokens))} /> + <Show when={value().generation !== undefined}> + <Row label="Generation speed" value={formatSpeed(value().generation)} /> + </Show> + <Row label="Cost" value={formatCost(value().totals.cost)} /> + </> + )} + </Show> + </Show> + </box> + <Show when={usage()}> + {(value) => ( + <box> + <Header + open={modelsOpen()} + toggle={() => setModelsOpen((open) => !open)} + title={`Models (${value().models.length})`} + /> + <Show when={modelsOpen()}> + <Show when={value().models.length > 0} fallback={<text fg={theme.text.muted}>No model usage yet</text>}> + <box gap={1} paddingTop={1}> + <For each={groups()}> + {(group) => ( + <box gap={1}> + <text fg={theme.text.base}> + <b>{group.providerName}</b> + </text> + <box> + <box flexDirection="row" gap={1}> + <box width={1} flexShrink={0} /> + <text fg={theme.text.muted} flexGrow={1} minWidth={0} wrapMode="none"> + Model + </text> + <box width={5} flexDirection="row" flexShrink={0} justifyContent="flex-end"> + <text fg={theme.text.muted}>Steps</text> + </box> + <box width={9} flexDirection="row" flexShrink={0} justifyContent="flex-end"> + <text fg={theme.text.muted}>Cost</text> + </box> + </box> + <For each={group.models}> + {(model) => { + const key = `${model.providerID}/${model.modelID}` + return ( + <box> + <ModelRow + label={Locale.truncate( + modelLabel({ providerID: model.providerID, id: model.modelID }, models()), + 19, + )} + steps={formatCount(model.steps)} + cost={formatCost(model.cost)} + expanded={expanded().has(key)} + text={theme.text.base} + muted={theme.text.muted} + toggle={() => toggle(key)} + /> + <Show when={expanded().has(key)}> + <box paddingLeft={2}> + <Row label="Input" value={formatCount(model.tokens.input)} /> + <Row label="Output" value={formatCount(model.tokens.output)} /> + <Row label="Reasoning" value={formatCount(model.tokens.reasoning)} /> + <Row label="Cache read" value={formatCount(model.tokens.cache.read)} /> + <Row label="Cache write" value={formatCount(model.tokens.cache.write)} /> + <Row label="Cache rate" value={formatCacheRate(cacheRate(model.tokens))} /> + </box> + </Show> + </box> + ) + }} + </For> + </box> + </box> + )} + </For> + </box> + </Show> + </Show> + </box> + )} + </Show> + <Show when={bench()}> + {(value) => ( + <box> + <Header open={benchOpen()} toggle={() => setBenchOpen((open) => !open)} title="Terminal Bench 2.0" /> + <Show when={benchOpen()}> + <Row label="Completion" value={fmtScore(value().overallScore)} /> + <Row label="Cost / attempt" value={fmtAttemptCost(value().avgAttemptCostUsd)} /> + </Show> + </box> + )} + </Show> + </box> + ) +} + +async function messages(context: Plugin.Context, sessionID: string, cursor?: string): Promise<SessionMessageInfo[]> { + const page = await context.client.message.list({ sessionID, limit: 200, cursor }) + if (!page.cursor.next) return [...page.data] + return [...page.data, ...(await messages(context, sessionID, page.cursor.next))] +} + +export default Plugin.define({ + id: "kilocode.sidebar.usage", + setup(context) { + context.ui.slot({ + append: "sidebar.content", + order: 150, + render: (props) => <SidebarUsage context={context} sessionID={props.sessionID} />, + }) + }, +}) diff --git a/packages/tui/src/kilocode/routed-model.tsx b/packages/tui/src/kilocode/routed-model.tsx index c5b84f129780..a11df33d491b 100644 --- a/packages/tui/src/kilocode/routed-model.tsx +++ b/packages/tui/src/kilocode/routed-model.tsx @@ -21,9 +21,12 @@ export function routedModelLabel(message: Message, models: Models) { if (!message.model.id.startsWith("kilo-auto/")) return const route = routedModel(message) if (!route) return - const id = route.id.replace(/-(?:\d{8}|\d{4}-\d{2}-\d{2})$/, "") - const name = routedModelInfo(message, models)?.name - return (name ?? id) + return modelLabel(route, models) +} + +/** The short display name of a model: its catalog name, or its ID without a date suffix, cleaned as in v1. */ +export function modelLabel(model: Pick<ModelInfo, "providerID" | "id">, models: Models) { + return (findModel(model, models)?.name ?? model.id.replace(DATE, "")) .trim() .replace(/^[^:]+:\s+/, "") .replace(/^[^/\s]+\/(?=[^/]+$)/, "") @@ -38,12 +41,18 @@ export function routedModelInfo<T extends Pick<ModelInfo, "providerID" | "id">>( ) { const route = routedModel(message) if (!route) return + return findModel(route, models) +} + +const DATE = /-(?:\d{8}|\d{4}-\d{2}-\d{2})$/ + +function findModel<T extends Pick<ModelInfo, "providerID" | "id">>( + model: Pick<ModelInfo, "providerID" | "id">, + models: ReadonlyArray<T> | undefined, +) { return ( - models?.find((model) => model.providerID === route.providerID && model.id === route.id) ?? - models?.find( - (model) => - model.providerID === route.providerID && model.id === route.id.replace(/-(?:\d{8}|\d{4}-\d{2}-\d{2})$/, ""), - ) + models?.find((item) => item.providerID === model.providerID && item.id === model.id) ?? + models?.find((item) => item.providerID === model.providerID && item.id === model.id.replace(DATE, "")) ) } diff --git a/packages/tui/src/plugin/api.tsx b/packages/tui/src/plugin/api.tsx index 35a4eac6e563..ebb4a679836c 100644 --- a/packages/tui/src/plugin/api.tsx +++ b/packages/tui/src/plugin/api.tsx @@ -304,7 +304,7 @@ export function createPluginContext(input: { if (kinds.length !== 1) throw new Error("Slot claim requires exactly one placement key") const kind = kinds[0] input.registry.set("slots", key, { - placement: { kind, target: value[kind] as string }, + placement: { kind, target: value[kind] as string, order: value.order }, // kilocode_change(plugin) seam - claim order // The registration map erases the path-specific input type. render: (slotInput) => provide(() => (value.render as SlotRender)(slotInput)), }) diff --git a/packages/tui/src/plugin/builtins.ts b/packages/tui/src/plugin/builtins.ts index 85d3ad12f8a1..c9cf82e4813a 100644 --- a/packages/tui/src/plugin/builtins.ts +++ b/packages/tui/src/plugin/builtins.ts @@ -14,6 +14,9 @@ import Latex from "@opencode/latex/plugin" import Merman from "@opencode/merman/plugin" import KiloTeams from "../kilocode/gateway" // kilocode_change - Kilo /teams command import KiloUsage from "../kilocode/usage" // kilocode_change(provider-usage) - Kilo /usage command +import KiloSidebarPr from "../kilocode/plugins/sidebar-pr" // kilocode_change(plugin) - Kilo sidebar PR panel +import KiloSidebarFooter from "../kilocode/plugins/sidebar-footer" // kilocode_change(plugin) - Kilo sidebar balance +import KiloSidebarUsage from "../kilocode/plugins/sidebar-usage" // kilocode_change(plugin) - Kilo sidebar usage panel export const builtins = [ HomeFooter, @@ -34,4 +37,7 @@ export const builtins = [ DiffViewer, KiloTeams, // kilocode_change KiloUsage, // kilocode_change(provider-usage) + KiloSidebarPr, // kilocode_change(plugin) + KiloSidebarFooter, // kilocode_change(plugin) + KiloSidebarUsage, // kilocode_change(plugin) ] diff --git a/packages/tui/src/plugin/structure.ts b/packages/tui/src/plugin/structure.ts index 1a5e8617672a..fc1a3f673c5d 100644 --- a/packages/tui/src/plugin/structure.ts +++ b/packages/tui/src/plugin/structure.ts @@ -9,7 +9,7 @@ export type PlacementKind = "prepend" | "append" | "before" | "after" | "replace // Normalized from the public SlotClaim shape by the plugin API: exactly one // placement kind, the target path erased to a string so the resolver stays // independent of the slot map. -export type Placement = { readonly kind: PlacementKind; readonly target: string } +export type Placement = { readonly kind: PlacementKind; readonly target: string; readonly order?: number } // kilocode_change(plugin) seam - claim order // One plugin's registered slot claim, in enable order within the claims array. export type Claim<Render> = { @@ -141,6 +141,16 @@ export function resolveSlots<Render>(input: { } const slotted = new Map<string, Slotted<Render>>() + // kilocode_change start - plugin seam: sort each anchor by claim order (stable). A claim without an order counts as + // 100, 200, ... in enable order, so an ordered claim can sit between them. Without any order, nothing moves. + for (const lists of buckets.values()) + for (const list of Object.values(lists)) { + if (!list.some((claim) => claim.placement.order !== undefined)) continue + let step = 0 + const order = new Map(list.map((claim) => [claim, claim.placement.order ?? ++step * 100])) + list.sort((a, b) => order.get(a)! - order.get(b)!) + } + // kilocode_change end for (const [path, lists] of buckets) slotted.set(path, lists) for (const [path, winner] of boundaries) slotted.set(path, { ...(buckets.get(path) ?? EMPTY), replace: winner }) return { slotted, suppressed, degraded } diff --git a/packages/tui/test/kilocode/model-usage.test.ts b/packages/tui/test/kilocode/model-usage.test.ts new file mode 100644 index 000000000000..dcbc4b8eb84b --- /dev/null +++ b/packages/tui/test/kilocode/model-usage.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, test } from "bun:test" +import type { SessionMessageInfo } from "@opencode/client" +import { groupModelsByProvider, modelUsage } from "../../src/kilocode/plugins/model-usage" + +const step = (input: { + id: string + providerID: string + model: string + route?: string + cost?: number + tokens?: boolean +}): SessionMessageInfo => ({ + type: "assistant", + id: input.id, + agent: "build", + model: { providerID: input.providerID, id: input.model }, + ...(input.route ? { native: { kilo: { routedModelID: input.route } } } : {}), + content: [], + ...(input.cost === undefined ? {} : { cost: input.cost }), + ...(input.tokens === false + ? {} + : { tokens: { input: 100, output: 20, reasoning: 5, cache: { read: 300, write: 10 } } }), + time: { created: 0, completed: 1 }, +}) + +describe("modelUsage", () => { + test("counts each completed step under its routed model, sorted by cost", () => { + const usage = modelUsage([ + { type: "user", id: "msg_user", text: "hi", time: { created: 0 } }, + step({ + id: "a", + providerID: "kilo", + model: "kilo-auto/frontier", + route: "anthropic/claude-sonnet-4.6", + cost: 0.2, + }), + step({ + id: "b", + providerID: "kilo", + model: "kilo-auto/frontier", + route: "anthropic/claude-sonnet-4.6", + cost: 0.1, + }), + step({ id: "c", providerID: "kilo", model: "kilo-auto/frontier", route: "openai/gpt-5.4", cost: 0.4 }), + step({ id: "d", providerID: "openai", model: "gpt-5.4", cost: 0.05 }), + // A step without usage is still running, and v1 counted only finished steps. + step({ id: "e", providerID: "openai", model: "gpt-5.4", cost: 1, tokens: false }), + ]) + expect(usage.models.map((row) => [row.providerID, row.modelID, row.steps, Number(row.cost.toFixed(2))])).toEqual([ + ["kilo", "openai/gpt-5.4", 1, 0.4], + ["kilo", "anthropic/claude-sonnet-4.6", 2, 0.3], + ["openai", "gpt-5.4", 1, 0.05], + ]) + expect(usage.models[1].tokens).toEqual({ input: 200, output: 40, reasoning: 10, cache: { read: 600, write: 20 } }) + expect(usage.totals.steps).toBe(4) + expect(usage.totals.cost).toBeCloseTo(0.75) + expect(usage.totals.tokens.cache.read).toBe(1200) + }) + + test("ties sort by provider and model, and a missing cost counts as zero", () => { + const usage = modelUsage([ + step({ id: "a", providerID: "zai", model: "glm-5" }), + step({ id: "b", providerID: "anthropic", model: "opus" }), + step({ id: "c", providerID: "anthropic", model: "haiku" }), + ]) + expect(usage.models.map((row) => `${row.providerID}/${row.modelID}`)).toEqual([ + "anthropic/haiku", + "anthropic/opus", + "zai/glm-5", + ]) + expect(usage.totals.cost).toBe(0) + }) + + test("groups rows by provider in row order with provider names", () => { + const usage = modelUsage([ + step({ id: "a", providerID: "kilo", model: "a", cost: 3 }), + step({ id: "b", providerID: "openai", model: "b", cost: 2 }), + step({ id: "c", providerID: "kilo", model: "c", cost: 1 }), + ]) + expect( + groupModelsByProvider(usage.models, [{ id: "kilo", name: "Kilo Gateway" }]).map((group) => [ + group.providerName, + group.models.map((row) => row.modelID), + ]), + ).toEqual([ + ["Kilo Gateway", ["a", "c"]], + ["openai", ["b"]], + ]) + }) +}) diff --git a/packages/tui/test/kilocode/sidebar-footer.test.ts b/packages/tui/test/kilocode/sidebar-footer.test.ts new file mode 100644 index 000000000000..3f62b3ed4231 --- /dev/null +++ b/packages/tui/test/kilocode/sidebar-footer.test.ts @@ -0,0 +1,48 @@ +import { expect, test } from "bun:test" +import type { SessionMessageAssistant } from "@opencode/client" +import type { KiloGatewayRpc } from "@opencode/schema/kilocode/gateway" + +import { billable, creditLabel, format, passLine, resetLabel, scope } from "../../src/kilocode/plugins/sidebar-footer" + +const kiloPass = { + currentPeriodBaseCreditsUsd: 199, + currentPeriodUsageUsd: 73.27, + currentPeriodBonusCreditsUsd: 99.5, + nextBillingAt: "2026-07-01T00:00:00.000Z", +} satisfies NonNullable<KiloGatewayRpc.KiloPass> + +const message = { + id: "msg_1", + time: { created: 1, completed: 2 }, + type: "assistant", + agent: "build", + model: { id: "kilo-auto/balanced", providerID: "kilo" }, + content: [], + cost: 0.1, +} satisfies SessionMessageAssistant + +test("formats money", () => { + expect(format(12.345)).toBe("$12.35") + expect(format(0)).toBe("$0.00") +}) + +test("labels balance scope", () => { + expect(scope(null)).toEqual({ kind: "Personal" }) + expect(scope("org_1", [{ id: "org_1", name: "Acme" }])).toEqual({ kind: "Team", name: "Acme" }) + expect(creditLabel(scope(null))).toBe("Personal credits") + expect(creditLabel(scope("org_1", [{ id: "org_1", name: "Acme" }]))).toBe("Acme team") +}) + +test("shows pass period usage and reset date", () => { + expect(passLine(kiloPass)).toBe("$73 / $199") + expect(resetLabel(kiloPass.nextBillingAt)).toBe("Jul 1") + expect(resetLabel(null)).toBeUndefined() + expect(resetLabel("not-a-date")).toBeUndefined() +}) + +test("refreshes only after completed billed Kilo turns", () => { + expect(billable(message)).toBeTrue() + expect(billable({ ...message, model: { ...message.model, providerID: "anthropic" } })).toBeFalse() + expect(billable({ ...message, cost: 0 })).toBeFalse() + expect(billable({ ...message, time: { created: 1 } })).toBeFalse() +}) diff --git a/packages/tui/test/kilocode/sidebar-pr.test.ts b/packages/tui/test/kilocode/sidebar-pr.test.ts new file mode 100644 index 000000000000..2f152b54c751 --- /dev/null +++ b/packages/tui/test/kilocode/sidebar-pr.test.ts @@ -0,0 +1,59 @@ +import { expect, test } from "bun:test" +import { lookup, parent, select, type Run } from "../../src/kilocode/plugins/sidebar-pr" + +test("shows the PR of the checked-out tracking ref first", async () => { + const run: Run = async (cmd) => { + if (cmd.join(" ") === "gh pr view --json number,title") return { code: 0, text: `{"number":7,"title":"Tracking"}` } + return { code: 1, text: "" } + } + expect(await lookup("/repo", "feature", run)).toEqual({ number: 7, title: "Tracking" }) +}) + +test("falls back to the branch name when the tracking ref has no PR", async () => { + const run: Run = async (cmd) => { + if (cmd.join(" ") === "gh pr view feature --json number,title") + return { code: 0, text: `{"number":9,"title":"Branch"}` } + return { code: 1, text: "" } + } + expect(await lookup("/repo", "feature", run)).toEqual({ number: 9, title: "Branch" }) +}) + +test("accepts a SHA lookup only when headRefOid equals HEAD", async () => { + const run: Run = async (cmd) => { + const line = cmd.join(" ") + if (line === "git rev-parse HEAD") return { code: 0, text: "abc123" } + if (line.startsWith("gh pr list --state open --search abc123 is:pr")) + return { + code: 0, + text: JSON.stringify([ + { number: 1, title: "Other", headRefOid: "zzz" }, + { number: 2, title: "Match", headRefOid: "abc123" }, + ]), + } + return { code: 1, text: "" } + } + expect(await lookup("/repo", "feature", run)).toEqual({ number: 2, title: "Match" }) + expect(select(`[{"number":1,"title":"Other","headRefOid":"zzz"}]`, "abc123")).toBeNull() +}) + +test("looks up the fork parent by head branch when the local repo has no PR", async () => { + const run: Run = async (cmd) => { + const line = cmd.join(" ") + if (line === "git rev-parse HEAD") return { code: 0, text: "abc123" } + if (line === "gh repo view --json nameWithOwner,parent") + return { + code: 0, + text: JSON.stringify({ nameWithOwner: "me/fork", parent: { owner: { login: "up" }, name: "repo" } }), + } + if (line === "gh pr list -R up/repo --state open --head feature --limit 10 --json number,title,headRefOid") + return { code: 0, text: JSON.stringify([{ number: 4, title: "Fork", headRefOid: "abc123" }]) } + return { code: 1, text: "" } + } + expect(await lookup("/repo", "feature", run)).toEqual({ number: 4, title: "Fork" }) + expect(parent(JSON.stringify({ nameWithOwner: "up/repo", parent: null }))).toBeNull() +}) + +test("shows nothing when gh is unavailable", async () => { + const run: Run = async () => ({ code: 127, text: "" }) + expect(await lookup("/repo", "feature", run)).toBeNull() +}) diff --git a/packages/tui/test/kilocode/sidebar-usage.view.test.tsx b/packages/tui/test/kilocode/sidebar-usage.view.test.tsx new file mode 100644 index 000000000000..0bed612d8b91 --- /dev/null +++ b/packages/tui/test/kilocode/sidebar-usage.view.test.tsx @@ -0,0 +1,158 @@ +import { expect, test } from "bun:test" +import { createTestRenderer } from "@opentui/core/testing" +import { Effect, FileSystem } from "effect" +import { Global } from "@opencode/util/global" +import type { SessionMessageInfo } from "@opencode/client" +import { createEventStream, createFetch, directory, json } from "../fixture/tui-client" +import { tmpdir } from "../fixture/fixture" +import { catalogModel, catalogProvider } from "../mini/fixture/catalog" + +test("sidebar shows the Kilo panels in v1 order, with family usage per routed model and the balance", async () => { + await using state = await tmpdir() + const setup = await createTestRenderer({ width: 170, height: 70, kittyKeyboard: true }) + await setup.renderer.setupTerminal() + setup.renderer.start() + const model = { providerID: "kilo", id: "kilo-auto/frontier" } + const tokens = { input: 1_000, output: 200, reasoning: 0, cache: { read: 1_000, write: 0 } } + const info = (id: string, parentID?: string) => ({ + id, + ...(parentID ? { parentID } : {}), + title: id, + projectID: "proj_test", + location: { directory }, + agent: "build", + model, + cost: 0.35, + tokens, + time: { created: 0, updated: 10 }, + }) + const root = info("ses_sidebar_root") + const child = info("ses_sidebar_child", root.id) + const step = (id: string, input: { model?: typeof model; route?: string; cost: number }): SessionMessageInfo => ({ + type: "assistant", + id, + agent: "build", + model: input.model ?? model, + ...(input.route ? { native: { kilo: { routedModelID: input.route } } } : {}), + finish: "stop", + content: [{ type: "text", text: "Done" }], + cost: input.cost, + tokens, + time: { created: 1_000, completed: 2_000 }, + }) + const messages: Record<string, SessionMessageInfo[]> = { + [root.id]: [ + { type: "user", id: "msg_user", text: "Check sidebar", time: { created: 0 } }, + step("msg_a", { route: "anthropic/claude-sonnet-4.6", cost: 0.2 }), + step("msg_b", { route: "anthropic/claude-sonnet-4.6", cost: 0.1 }), + ], + [child.id]: [step("msg_c", { model: { providerID: "openai", id: "gpt-5.4" }, cost: 0.05 })], + } + const calls = createFetch((url) => { + const location = { directory } + if (url.pathname === "/api/session") + return json({ data: url.searchParams.get("parentID") === root.id ? [child] : [root], cursor: {} }) + for (const session of [root, child]) { + if (url.pathname === `/api/session/${session.id}`) return json({ data: session }) + if (url.pathname === `/api/session/${session.id}/message`) { + // The panel reads every page; the transcript reads the first page only. + const list = messages[session.id].toReversed() + if (url.searchParams.get("limit") !== "200") return json({ data: list, cursor: {} }) + if (!url.searchParams.get("cursor")) return json({ data: list.slice(0, 1), cursor: { next: "page-2" } }) + return json({ data: list.slice(1), cursor: {} }) + } + if (url.pathname === `/api/session/${session.id}/inbox`) return json({ data: [] }) + } + if (url.pathname === "/api/model") + return json({ + location, + data: [ + catalogModel({ ...model, name: "Kilo Auto Frontier" }), + catalogModel({ providerID: "kilo", id: "anthropic/claude-sonnet-4.6", name: "Anthropic: Claude Sonnet 4.6" }), + catalogModel({ providerID: "openai", id: "gpt-5.4", name: "GPT-5.4" }), + ], + }) + if (url.pathname === "/api/agent") + return json({ location, data: [{ id: "build", mode: "primary", hidden: false, permissions: [] }] }) + if (url.pathname === "/api/provider") + return json({ location, data: [catalogProvider("kilo", "Kilo Gateway"), catalogProvider("openai", "OpenAI")] }) + if (url.pathname === "/api/mcp") + return json({ location, data: [{ name: "docs", status: { status: "connected" } }] }) + if (url.pathname === "/api/integration") + return json({ + location, + data: [{ id: "kilo", name: "Kilo Gateway", methods: [], connections: [{ type: "env", name: "KILO_API_KEY" }] }], + }) + if (url.pathname === "/api/rpc/kilocode.gateway/profile") + return json({ + output: { email: "fixture@example.test", organizations: [], hasPersonalAccount: true, organizationID: null }, + }) + if (url.pathname === "/api/rpc/kilocode.gateway/balance") return json({ output: { balance: 1.5 } }) + if (url.pathname === "/api/rpc/kilocode.gateway/models") + return json({ + output: [ + { + id: "kilo-auto/frontier", + name: "Kilo Auto Frontier", + isFree: false, + terminalBench: { overallScore: 0.61, avgAttemptCostUsd: 2.1 }, + }, + ], + }) + if (url.pathname.startsWith("/api/rpc/")) return json({ output: null }) + }, createEventStream()) + const server = Bun.serve({ port: 0, idleTimeout: 0, fetch: (request) => calls.fetch(request) }) + const { run } = await import("../../src/app") + const task = Effect.runPromise( + run({ + app: { name: "test", version: "test", channel: "test" }, + server: { endpoint: { url: server.url.toString() } }, + config: { get: async () => ({ animations: false, tabs: { enabled: false } }), update: async () => ({}) }, + packages: { prepare: async () => ({ directory: "" }) }, + args: { sessionID: root.id }, + terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }), + log: () => {}, + }).pipe(Effect.provide(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))), + ) + try { + const wait = { maxPasses: 200 } + await setup.waitForFrame( + (frame) => + frame.includes("Models (2)") && + frame.includes("Terminal Bench 2.0") && + frame.includes("MCP") && + frame.includes("Balance"), + wait, + ) + await setup.waitForVisualIdle() + const lines = setup.captureCharFrame().split("\n") + const row = (text: string) => lines.findIndex((line) => line.includes(text)) + // v1 order: Context 100, usage 150, MCP 200. + expect(row("Context")).toBeLessThan(row("Token Usage")) + expect(row("Token Usage")).toBeLessThan(row("Models (2)")) + expect(row("Terminal Bench 2.0")).toBeLessThan(row("MCP")) + // v1 footer order: Kilo balance 99, upstream footer (location path) 100, Kilo version last. + const sidebar = lines.map((line) => line.slice(120)) + const path = sidebar.findIndex((line) => line.includes("/tmp/opencode/packages/tui:main")) + expect(row("Balance")).toBeLessThan(path) + expect(path).toBeLessThan(sidebar.findIndex((line) => line.includes("• Kilo test"))) + const frame = lines.join("\n") + expect(frame).toMatch(/• Personal credits\s+\$1\.50/) + // Three steps over two pages each: the totals cover the root and the subagent. + expect(frame).toMatch(/Input\s+3,000/) + expect(frame).toMatch(/Cache rate\s+50\.0%/) + expect(frame).toMatch(/Generation speed\s+200 t\/s/) + expect(frame).toMatch(/Cost\s+\$0\.35/) + expect(row("Kilo Gateway")).toBeLessThan(row("OpenAI")) + expect(frame).toMatch(/▶ Claude Sonnet 4\.6\s+2\s+\$0\.30/) + expect(frame).toMatch(/▶ GPT-5\.4\s+1\s+\$0\.05/) + expect(frame).toMatch(/Completion\s+61\.0%/) + expect(frame).toMatch(/Cost \/ attempt\s+\$2\.10/) + await setup.mockMouse.click(lines[row("▶ Claude Sonnet 4.6")].indexOf("▶"), row("▶ Claude Sonnet 4.6")) + await setup.waitForFrame((next) => next.includes("▼ Claude Sonnet 4.6"), wait) + expect(setup.captureCharFrame()).toMatch(/▼ Claude Sonnet 4\.6[\s\S]*Input\s+2,000/) + } finally { + if (!setup.renderer.isDestroyed) setup.renderer.destroy() + await task.finally(() => server.stop(true)) + } +}) diff --git a/packages/tui/test/kilocode/slot-order.test.ts b/packages/tui/test/kilocode/slot-order.test.ts new file mode 100644 index 000000000000..8609cb6a5452 --- /dev/null +++ b/packages/tui/test/kilocode/slot-order.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from "bun:test" +import { resolveSlots, type Claim } from "../../src/plugin/structure" + +describe("slot order", () => { + const claim = (key: string, order?: number): Claim<string> => ({ + key, + plugin: key, + placement: { kind: "append", target: "sidebar.content", order }, + render: key, + }) + const keys = (claims: Claim<string>[]) => + resolveSlots({ paths: new Set(["sidebar.content"]), claims }) + .slotted.get("sidebar.content") + ?.append.map((item) => item.key) + + test("without an order, claims keep enable order", () => { + expect(keys([claim("context"), claim("mcp"), claim("todo")])).toEqual(["context", "mcp", "todo"]) + }) + + test("Kilo sidebar orders sit between the upstream claims as on v1", () => { + // Upstream claims count as 100, 200, 300 in enable order: context, mcp, todo. + expect( + keys([ + claim("context"), + claim("mcp"), + claim("todo"), + claim("memory", 1000), + claim("usage", 150), + claim("processes", 250), + claim("indexing", 225), + claim("pr", 50), + ]), + ).toEqual(["pr", "context", "usage", "mcp", "indexing", "processes", "todo", "memory"]) + }) + + test("the Kilo footer at 99 comes before the upstream footer", () => { + const footer = (key: string, order?: number): Claim<string> => ({ + key, + plugin: key, + placement: { kind: "append", target: "sidebar.footer", order }, + render: key, + }) + expect( + resolveSlots({ paths: new Set(["sidebar.footer"]), claims: [footer("opencode"), footer("kilo", 99)] }) + .slotted.get("sidebar.footer") + ?.append.map((item) => item.key), + ).toEqual(["kilo", "opencode"]) + }) +}) diff --git a/script/upstream/shared-patches.json b/script/upstream/shared-patches.json index ceb97f6ddc32..5b0fe075d994 100644 --- a/script/upstream/shared-patches.json +++ b/script/upstream/shared-patches.json @@ -1,8 +1,8 @@ { "$comment": "Shared-patch baseline (U-L3-01). Read by script/check-shared-patches.ts and script/check-opencode-annotations.ts. Each shared upstream file that Kilo adds or changes needs an entry: rung (3 = seam patch, 4 = feature patch) and the missing seam. `unmarked` allows lines without markers and states why. Raise a limit only in the PR that adds the patch, and lower it when a patch goes away.", "limits": { - "files": 44, - "markedLines": 329, + "files": 47, + "markedLines": 348, "unmarkedLines": 39, "deletedFiles": 0 }, @@ -126,7 +126,7 @@ }, "packages/tui/src/plugin/builtins.ts": { "rung": 3, - "seam": "Register the built-in `KiloTeams` TUI plugin (`/teams`, `/team`, `/org`, `/orgs`) and the `KiloUsage` TUI plugin (`/usage`, `/plans`, `/quota`). Stays after U-E0-01: the TUI loads its built-in list in the TUI process, which `SdkPlugins` (a server store) does not reach. The config-driven path (`plugins` in the TUI config, loaded by `tui/src/plugin/context.tsx`) loads only npm packages or local files, which a compiled binary does not have. A generic seam needs an `@opencode/tui` export of the list (package.json) plus an extension point in this file, which is not fewer shared lines than these two." + "seam": "Register the built-in `KiloTeams` TUI plugin (`/teams`, `/team`, `/org`, `/orgs`), the `KiloUsage` TUI plugin (`/usage`, `/plans`, `/quota`), and the Kilo sidebar panels (`KiloSidebarPr`, `KiloSidebarFooter`, `KiloSidebarUsage`). Stays after U-E0-01: the TUI loads its built-in list in the TUI process, which `SdkPlugins` (a server store) does not reach. The config-driven path (`plugins` in the TUI config, loaded by `tui/src/plugin/context.tsx`) loads only npm packages or local files, which a compiled binary does not have. A generic seam needs an `@opencode/tui` export of the list (package.json) plus an extension point in this file, which is not fewer shared lines than these two." }, "packages/tui/test/fixture/local.tsx": { "rung": 3, @@ -188,6 +188,18 @@ "packages/tui/src/ui/dialog-select.tsx": { "rung": 4, "seam": "Keep the list footer mounted when its action list becomes empty. The footer is wrapped in a Show that unmounts it whenever no action is visible, and each mount registers a renderer resize listener through useTerminalDimensions. Selection changes that hide or show an action (for example the account row in the Kilo gateway dialog) therefore add listeners until the renderer exceeds its budget and writes a MaxListenersExceededWarning over the TUI. Rungs 1 to 3 cannot reach it: the footer is rendered by upstream's DialogSelect, and no plugin hook or generic knob controls whether that element stays mounted. Separately (rung 3 lines, no Kilo value): match the active row by option reference instead of by value (`active`, mouse move and mouse down, the scroll-to-top check), as on v1. A list may show one value twice: the model picker keeps a recent model in Recent and in its provider section (v1 placement). With value equality both copies render active and the pointer targets the first copy. For lists with unique values the behavior is the same, so upstream's DialogSelect tests pass. No plugin hook or host option reaches DialogSelect's private row matching." + }, + "packages/plugin/src/tui/context.ts": { + "rung": 3, + "seam": "Public `SlotClaim` type: the optional `order` field. Optional numeric `order` on TUI slot claims, so a plugin can place its claim between other claims at one anchor (v1 slots had `order`; the Kilo sidebar panels need pr 50, footer 99, usage 150 between the upstream Context, MCP and Todo panels). A claim without an order counts as 100, 200, ... in enable order. Without any order the result is the enable order, as before, so upstream claims and tests do not change. No plugin hook or host option reaches the slot resolver: the claim type, its normalization and the resolver are the only places that know claim positions, so the knob needs one line in each of the three files. Generic, no Kilo value." + }, + "packages/tui/src/plugin/api.tsx": { + "rung": 3, + "seam": "Pass the optional claim `order` into the normalized placement. Optional numeric `order` on TUI slot claims, so a plugin can place its claim between other claims at one anchor (v1 slots had `order`; the Kilo sidebar panels need pr 50, footer 99, usage 150 between the upstream Context, MCP and Todo panels). A claim without an order counts as 100, 200, ... in enable order. Without any order the result is the enable order, as before, so upstream claims and tests do not change. No plugin hook or host option reaches the slot resolver: the claim type, its normalization and the resolver are the only places that know claim positions, so the knob needs one line in each of the three files. Generic, no Kilo value." + }, + "packages/tui/src/plugin/structure.ts": { + "rung": 3, + "seam": "`Placement.order` and a stable sort of each anchor in `resolveSlots`. Optional numeric `order` on TUI slot claims, so a plugin can place its claim between other claims at one anchor (v1 slots had `order`; the Kilo sidebar panels need pr 50, footer 99, usage 150 between the upstream Context, MCP and Todo panels). A claim without an order counts as 100, 200, ... in enable order. Without any order the result is the enable order, as before, so upstream claims and tests do not change. No plugin hook or host option reaches the slot resolver: the claim type, its normalization and the resolver are the only places that know claim positions, so the knob needs one line in each of the three files. Generic, no Kilo value." } } } From 84beea07a6efd8e6b7ac754a897f21382069ba37 Mon Sep 17 00:00:00 2001 From: marius-kilocode <marius@kilocode.ai> Date: Fri, 23 Oct 2026 14:06:27 +0200 Subject: [PATCH 2/2] fix(tui): read sidebar usage again only when session totals change --- packages/tui/src/kilocode/plugins/sidebar-usage.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/tui/src/kilocode/plugins/sidebar-usage.tsx b/packages/tui/src/kilocode/plugins/sidebar-usage.tsx index c3ef02b875dd..e3a765d12170 100644 --- a/packages/tui/src/kilocode/plugins/sidebar-usage.tsx +++ b/packages/tui/src/kilocode/plugins/sidebar-usage.tsx @@ -28,11 +28,15 @@ export function SidebarUsage(props: { context: Plugin.Context; sessionID: string }) const location = () => data.session.get(root())?.location ?? props.context.location ?? data.location.default() const models = createMemo(() => data.location.model.list(location()) ?? []) - // The message store keeps only a page of each session, so the panel reads every message of the family. A step - // updates the session, so a new version reads them again, as v1 read its usage query after each step. + // The message store keeps only a page of each session, so the panel reads every message of the family. A finished + // step adds its usage to the session totals, so a change of the totals reads them again, as v1 read its usage + // query after each step. Other session updates (title, time) do not. const version = createMemo(() => family() - .map((id) => `${id}:${data.session.get(id)?.time.updated ?? 0}`) + .map((id) => { + const session = data.session.get(id) + return `${id}:${session?.cost ?? 0}:${JSON.stringify(session?.tokens ?? {})}` + }) .join(","), ) const [result] = createResource(version, () =>