Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
5752fd3
feat: 🎸 themed stats overview and live top-right review badge
theogrillat Sep 26, 2026
1ed416b
feat: 🎸 expose live finding status from the review store
theogrillat Sep 26, 2026
d872066
feat: 🎸 record a session-scoped feed of review jobs
theogrillat Sep 26, 2026
c34449c
feat: 🎸 add a non-capturing review sidebar overlay
theogrillat Sep 26, 2026
e0d9809
feat: 🎸 toggle the live review sidebar with /pair-feed and alt+r
theogrillat Sep 26, 2026
53f9a71
chore: ignore generated vitest reports
theogrillat Sep 26, 2026
5e75615
feat: 🎸 show only running and decided reviews in the sidebar
theogrillat Sep 26, 2026
e47cda5
feat: 🎸 render accepted findings as compact chat cards
theogrillat Sep 26, 2026
ad6c2f0
feat: 🎸 expand accepted finding cards by click
theogrillat Sep 26, 2026
112fbda
feat: 🎸 name reviewers in the sidebar and chat cards
theogrillat Sep 26, 2026
2d8d56a
feat: 🎸 collapse sidebar reviews and expand them by click
theogrillat Sep 26, 2026
81651e9
feat: 🎸 show rejected findings as folded chat cards
theogrillat Sep 26, 2026
3e3545f
feat: 🎸 add a hotspots tab to /pair-stats
theogrillat Sep 26, 2026
e335e39
Merge remote-tracking branch 'origin/main' into feat/live-review-feed…
theogrillat Sep 28, 2026
11b4ff6
refactor: share one finding status classifier in ReviewStore
theogrillat Sep 29, 2026
f04bedd
refactor: return added finding IDs from admission
theogrillat Sep 29, 2026
226cf91
fix: report malformed reviewer JSON with a clear error
theogrillat Sep 29, 2026
9e255f6
refactor: share path truncation and frame drawing helpers
theogrillat Sep 29, 2026
f1c036f
fix: keep rejected findings out of the transcript
theogrillat Sep 29, 2026
371213a
fix: let ctrl+o collapse a card that was clicked open
theogrillat Sep 29, 2026
5502bf8
fix: keep Pi overlays owned and avoid OMP keyboard capture
theogrillat Sep 29, 2026
a3c4b79
feat: offer an on-demand review feed on OMP
theogrillat Sep 29, 2026
4afd617
Merge branch 'main' into feat/live-review-feed-stats
T-moz Sep 29, 2026
5e5511d
Merge remote-tracking branch 'origin/main' into feat/live-review-feed…
theogrillat Oct 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
node_modules/
coverage/
.vitest/
18 changes: 11 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,20 +82,22 @@ export TYPESAFE_API_KEY="your-api-key"

## 🧭 Use

Reviews are on by default. After each successful `write` or `edit`, reviewers run in the background. The coding agent must accept or reject each finding with a reason before continuing with other tools. Only accepted findings appear in your transcript.
Reviews are on by default. After each successful `write` or `edit`, reviewers run in the background. The coding agent must accept or reject each finding with a reason before continuing with other tools. Accepted findings appear in your transcript as compact cards; rejected ones stay out of the transcript and are listed in the review feed (`/pair-feed`). Click a card (fullscreen mode) or press `ctrl+o` to expand it.

| Command | Action |
| ------------------ | --------------------------------------------------------------------------------------------------- |
| `/pair-programmer` | Toggle reviews. |
| `/pair-clear` | Cancel reviews and clear all findings. |
| `/pair-stats` | View review activity, findings, tokens, and estimated costs on demand; missing usage stays unknown. |
| Command | Action |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `/pair-programmer` | Toggle reviews. |
| `/pair-clear` | Cancel reviews and clear all findings. |
| `/pair-feed` or `alt+r` | Toggle a live review sidebar on the right. It lists running reviews and reviews with accepted or rejected findings, newest first. Each review is one row with its file, reviewer and verdict counts; click it (fullscreen mode) to show its duration, decided findings and the agent's reasons. Reviews with no findings, undecided findings, failures and superseded reviews are left out. It never takes keyboard focus, hides in terminals narrower than 90 columns, and holds up to 100 reviews for the current session; `/pair-clear` empties it. On OMP, `/pair-feed` opens the same feed on demand (the **f** tab of `/pair-stats`), since OMP overlays would capture the keyboard. |
| `/pair-stats` | Open a themed session overview with review activity, findings, and estimated cost. Press `d` for detailed token and model accounting, `h` for hotspots (files ranked by accepted findings on the selected branch), `f` for the review feed, `o` to return to the overview, `r` to refresh the snapshot, arrows or Page Up/Down to scroll, and Esc, Enter, or `q` to close. Missing usage stays unknown; review usage covers the session across branches, while findings reflect the selected branch. |

The default reviewer uses your current model and asks: _“Does it add entropy?”_ To change the prompt, model, or files reviewed, create `pair-programmer.reviewers.json` in your working directory:

```json
{
"reviewers": [
{
"name": "entropy",
"model": "current",
"prompt": "Does this code add entropy?",
"include": ["**/*.dart"],
Expand All @@ -111,7 +113,9 @@ The default reviewer uses your current model and asks: _“Does it add entropy?
}
```

Use `current` or a `provider/model` identifier. File patterns are relative to your working directory; exclusions take precedence. Add entries for more reviewers.
The optional `name` (1–24 characters) labels the reviewer in the sidebar and chat cards; unnamed reviewers show their model name, and renaming a reviewer keeps its review history. Use `current` or a `provider/model` identifier. File patterns are relative to your working directory; exclusions take precedence. Add entries for more reviewers.

A small badge pinned to the top-right corner shows whether reviews are watching, running, queued, awaiting a decision, or paused. It never takes keyboard focus, stays visible when extensions such as zentui hide the footer, and hides in terminals narrower than 40 columns. Outside Pi's terminal UI (RPC, OMP), it falls back to a line above the editor. Accepted findings appear in the transcript with their location, evidence, and rationale.

> [!TIP]
> Diagnostics stay in files, never the terminal: OMP uses its native logs; Pi uses `~/.pi/agent/logs/pair-programmer/` (or `$PI_CODING_AGENT_DIR/logs/pair-programmer/`).
Expand Down
38 changes: 38 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
"node": ">=22.19.0"
},
"dependencies": {
"@earendil-works/pi-tui": "^0.87.1",
"@typesafe-ai/sdk": "^0.6.0",
"diff": "^9.0.0",
"minimatch": "^10.2.6",
Expand Down
22 changes: 13 additions & 9 deletions src/finding-admission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ type Observers = Pick<
>;
type Candidate = Finding & { duplicateKey: string };

export interface AdmissionResult {
outcome: "obsolete" | "unchanged" | "added";
ids: readonly string[];
}

const DUPLICATE_THRESHOLD = 0.5;
const dedupSchema = z.object({
answers: z.object({
Expand Down Expand Up @@ -174,9 +179,8 @@ export class FindingAdmission {
this.store = store;
}

async admit(
request: AdmissionRequest,
): Promise<"obsolete" | "unchanged" | "added"> {
/** Admits novel findings; `ids` lists exactly the findings this call added. */
async admit(request: AdmissionRequest): Promise<AdmissionResult> {
const { file, signal } = request;
let history = this.store.history(file);
let novel = await novelFindings(
Expand All @@ -186,17 +190,17 @@ export class FindingAdmission {
request,
);
for (;;) {
if (!(await request.isCurrent()) || signal.aborted) return "obsolete";
if (!(await request.isCurrent()) || signal.aborted)
return { outcome: "obsolete", ids: [] };
const latest = this.store.history(file);
const added = latest.slice(history.length);
if (novel.length === 0 || added.length === 0) break;
history = latest;
novel = await novelFindings(novel, added, false, request);
}
let result: "unchanged" | "added" = "unchanged";
for (const finding of novel) {
if (this.store.add(finding)) result = "added";
}
return result;
const ids = novel
.filter((finding) => this.store.add(finding))
.map((finding) => finding.id);
return { outcome: ids.length > 0 ? "added" : "unchanged", ids };
}
}
111 changes: 111 additions & 0 deletions src/finding-card.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import type { MessageRenderer, Theme } from "@earendil-works/pi-coding-agent";
import {
sliceByColumn,
visibleWidth,
wrapTextWithAnsi,
type Component,
} from "@earendil-works/pi-tui";
import { z } from "zod";
import { truncatePath } from "./format.js";

export const ACCEPTED_MESSAGE = "pair-programmer-accepted";

const CardDetails = z.object({
title: z.string(),
file: z.string(),
line: z.number(),
evidence: z.string(),
reason: z.string(),
reviewer: z.string().optional(),
});
export type CardDetails = z.infer<typeof CardDetails>;

type Painter = Pick<Theme, "fg" | "bold">;
/** The transcript message a card renders; stable across Pi's rebuilds. */
type CardKey = Parameters<MessageRenderer>[0];

/**
* An accepted finding: bold title plus location. Expanding reveals the
* evidence and the agent's reason.
*/
export function cardLines(
details: CardDetails,
theme: Painter,
width: number,
expanded: boolean,
pad = 0,
): string[] {
const indent = " ".repeat(Math.max(0, Math.min(pad, width - 4)));
const inner = Math.max(1, width - visibleWidth(indent) - 2);
const marker = theme.fg("dim", expanded ? " ▾" : " ▸");
const title = wrapTextWithAnsi(details.title, Math.max(1, inner - 2));
const lines = title.map((part, index) => {
const first = index === 0 ? theme.fg("warning", "◆") : " ";
return `${first} ${theme.bold(part)}${index === title.length - 1 ? marker : ""}`;
});
const location = `:${String(details.line)}`;
const by = details.reviewer === undefined ? "" : ` · ${details.reviewer}`;
const path = truncatePath(
details.file,
Math.max(1, inner - visibleWidth(location + by)),
);
lines.push(` ${theme.fg("dim", path + location + by)}`);
if (expanded)
lines.push(
...wrapTextWithAnsi(details.evidence, inner).map(
(part) => ` ${theme.fg("muted", part)}`,
),
...wrapTextWithAnsi(`“${details.reason}”`, inner).map(
(part) => ` ${theme.fg("dim", part)}`,
),
);
return lines.map((line) => sliceByColumn(indent + line, 0, width));
}

/**
* A card's own click toggle, remembered per message because Pi rebuilds the
* component on every expand or theme change. `base` records the global expand
* state when clicked; once ctrl+o changes it, the toggle is dropped so the
* global state wins from then on.
*/
const toggled = new WeakMap<CardKey, { open: boolean; base: boolean }>();

function isOpen(key: CardKey, expanded: boolean): boolean {
const state = toggled.get(key);
if (state === undefined) return expanded;
if (state.base === expanded) return state.open;
toggled.delete(key);
return expanded;
}

/** Accepted findings; unknown payloads fall back to Pi's default rendering. */
export const renderAccepted: MessageRenderer = (
message,
options,
theme,
): Component | undefined => {
const parsed = CardDetails.safeParse(message.details);
if (!parsed.success) return;
const { expanded, outputPad } = options;
return {
render: (width: number): string[] =>
cardLines(
parsed.data,
theme,
width,
isOpen(message, expanded),
outputPad,
),
handleMouse(event) {
if (event.type !== "click" || event.button !== "left") return;
toggled.set(message, {
open: !isOpen(message, expanded),
base: expanded,
});
return { handled: true, render: true };
},
invalidate(): void {
return;
},
};
};
51 changes: 51 additions & 0 deletions src/format.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import type { Theme } from "@earendil-works/pi-coding-agent";
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";

/** Keeps the end of a path: drop whole leading directories first, then characters. */
export function truncatePath(file: string, width: number): string {
if (visibleWidth(file) <= width) return file;
const parts = file.split("/");
for (let index = 1; index < parts.length; index += 1) {
const tail = `…/${parts.slice(index).join("/")}`;
if (visibleWidth(tail) <= width) return tail;
}
const graphemes = Array.from(
new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(file),
({ segment }) => segment,
);
const start = graphemes.findIndex(
(_, index) => visibleWidth(`…${graphemes.slice(index).join("")}`) <= width,
);
return start === -1 ? "…" : `…${graphemes.slice(start).join("")}`;
}

export interface Frame {
/** Columns available to content inside the border and padding. */
inner: number;
/** Truncates and pads each line, then wraps the rows in a rounded border. */
render(lines: readonly string[]): string[];
}

/** A rounded border `width` columns wide with `padding` spaces inside each side. */
export function frame(
theme: Pick<Theme, "fg">,
width: number,
padding: number,
): Frame {
const inner = Math.max(1, width - 2 - 2 * padding);
const space = " ".repeat(padding);
const side = theme.fg("borderMuted", "│");
const rule = "─".repeat(Math.max(0, width - 2));
return {
inner,
render: (lines) => [
theme.fg("borderMuted", `╭${rule}╮`),
...lines.map((text) => {
const clipped = truncateToWidth(text, inner, "…");
const pad = " ".repeat(Math.max(0, inner - visibleWidth(clipped)));
return `${side}${space}${clipped}${pad}${space}${side}`;
}),
theme.fg("borderMuted", `╰${rule}╯`),
],
};
}
20 changes: 20 additions & 0 deletions src/host.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";

export type Host = "pi" | "omp";

/** Pi exposes `streamSimple` on its model registry; OMP does not. */
export function hostOf(ctx: Pick<ExtensionContext, "modelRegistry">): Host {
return typeof ctx.modelRegistry.streamSimple === "function" ? "pi" : "omp";
}

/**
* Whether the host can float a non-capturing overlay. OMP reports
* `mode: "tui"` but ignores `nonCapturing`, so its overlays steal typing.
*/
export function canFloat(ctx: ExtensionContext): boolean {
return (
ctx.hasUI &&
(ctx as Partial<ExtensionContext>).mode === "tui" &&
hostOf(ctx) === "pi"
);
}
Loading