Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
17 changes: 11 additions & 6 deletions extensions/gentle-ai.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { appendSystemPromptOnce } from "../lib/append-system-prompt.ts";
import { consumeReviewMutation, pendingReviewMutation, recordReviewMutation } from "../lib/review-reminder-receipt.ts";
import { createReviewSidebarPublisher } from "../lib/review-sidebar-state.ts";
import { isOddPhase, oddPhaseRegistry, ODD_PHASES } from "../lib/odd-phase.ts";
import { resolveSessionWorktree } from "../lib/session-worktree-registry.ts";
import { declareReviewRelayHandshake } from "../lib/review-relay-contract.ts";
Expand Down Expand Up @@ -8768,9 +8769,12 @@ function createGentleAiExtensionForTesting(
return revoked;
};

const reviewSidebar = createReviewSidebarPublisher(pi);
pi.on("session_tree", (_event, ctx) => reviewSidebar.reset(ctx));
let reminderSessionActive = true;
let reminderEpoch = 0;
pi.on("session_shutdown", (event, context) => {
reviewSidebar.reset();
reminderSessionActive = false;
reminderEpoch += 1;
// Pi tears down this registry on reload as well as session replacement/quit.
Expand Down Expand Up @@ -8905,7 +8909,7 @@ function createGentleAiExtensionForTesting(
return named.length === 0 ? operation : `${operation} · ${named.join(" · ")}`;
};

pi.registerTool({
pi.registerTool(reviewSidebar.tool({
name: "gentle_review_capture_group",
renderShell: "self",
label: "Gentle Review Capture Group",
Expand Down Expand Up @@ -8943,9 +8947,9 @@ function createGentleAiExtensionForTesting(
);
return { content: [{ type: "text", text: JSON.stringify(details) }], details };
},
});
}));

pi.registerTool({
pi.registerTool(reviewSidebar.tool({
name: "gentle_review_capture",
renderShell: "self",
label: "Gentle Review Capture",
Expand Down Expand Up @@ -8989,9 +8993,9 @@ function createGentleAiExtensionForTesting(
details,
};
},
});
}));

pi.registerTool({
pi.registerTool(reviewSidebar.tool({
name: "gentle_review",
renderShell: "self",
label: "Gentle Review Controller",
Expand Down Expand Up @@ -9137,9 +9141,10 @@ function createGentleAiExtensionForTesting(
details,
};
},
});
}));

pi.on("session_start", async (event, ctx) => {
reviewSidebar.reset(ctx);
elapsedTiming = new GentleAiElapsedTimingLedger(ctx.sessionManager, pi);
reminderSessionActive = true;
reminderEpoch += 1;
Expand Down
29 changes: 29 additions & 0 deletions extensions/gentle-shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ import { UsageView } from "../lib/shell-usage-view.ts";
import { sidebarHeader, sidebarPart, sidebarState, VISUAL_SETTINGS_CHANGED } from "../lib/shell-sidebar.ts";
import { installSidebar, invalidateSidebar, narrowStatusOwner, STATUS_OWNER } from "../lib/shell-sidebar-layout.ts";
import { SessionChanges, SESSION_CHANGE_EVENT } from "../lib/session-changes.ts";
import { REVIEW_SIDEBAR_EVENT, isReviewSidebarSnapshot, type ReviewSidebarSnapshot } from "../lib/review-sidebar-state.ts";
import { installSessionChangeCapture } from "../lib/session-change-capture.ts";
import { SelectionEngine } from "../lib/selection-engine.ts";
import { withOverlayRepaint } from "../lib/overlay-repaint.ts";
Expand Down Expand Up @@ -1547,6 +1548,22 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
let changes: SessionChanges | undefined;
let registry: SessionWorktreeRegistry | undefined;
let currentContext: ExtensionContext | undefined;
let review: ReviewSidebarSnapshot | undefined;
const redrawReview = () => {
renderHost?.invalidateSidebar?.();
renderHost?.requestRender();
};
const unsubscribeReview = pi.events.on(REVIEW_SIDEBAR_EVENT, (value) => {
const event = value as { sessionId?: unknown; snapshot?: unknown } | undefined;
if (!currentContext || event?.sessionId !== currentContext.sessionManager.getSessionId()) return;
if (!isReviewSidebarSnapshot(event.snapshot)) return;
review = { state: event.snapshot.state, scope: event.snapshot.scope };
redrawReview();
});
pi.on("session_tree", () => {
review = undefined;
redrawReview();
});
let shown = "";
const applyChanges = (ctx: ExtensionContext, model: ChangesModel) => {
const fingerprint = changesFingerprint(model);
Expand Down Expand Up @@ -1581,6 +1598,10 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
},
});
pi.on("session_start", async (_event, ctx) => {
if (review) {
review = undefined;
redrawReview();
}
stopProfilePoll();
registry?.close();
currentContext = ctx;
Expand Down Expand Up @@ -1614,6 +1635,7 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
const footerModel = (): ShellBarModel => ({
...buildShellBarModel(pi, ctx, footerData, { dirty: tracker.model.files.length, usage: usage.get(ctx.model?.provider ?? ""), profile: deps.activeProfile() }),
changes: { files: tracker.model.files.length, added: tracker.model.added, deleted: tracker.model.deleted, notice: tracker.model.notice },
review,
});
// At narrow fullscreen widths only one status row paints: a top header
// suppresses the bottom bar in the layout, and otherwise the bottom bar
Expand Down Expand Up @@ -1684,6 +1706,13 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
applyChanges(ctx, tracker.model);
});
pi.on("session_shutdown", (_event, ctx) => {
if (review) {
review = undefined;
redrawReview();
}
// Pi rebuilds the extension runtime after every shutdown (reload, replacement,
// fork, quit), so the factory-level subscription never needs to be restored.
unsubscribeReview();
stopProfilePoll();
oddPhaseRegistry.clear(ctx.sessionManager.getSessionId());
oddPhaseRegistry.clearRenderRequest(ctx.sessionManager.getSessionId());
Expand Down
136 changes: 136 additions & 0 deletions tests/gentle-shell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ import { CHANGE_STATUS } from "../lib/shell-changes.ts";
import { sidebarState, type SidebarRail } from "../lib/shell-sidebar.ts";
import type { ShellBarTheme } from "../lib/shell-bar.ts";
import { stripAnsi } from "../lib/terminal-theme.ts";
import { createGentleAiExtension } from "../extensions/gentle-ai.ts";
import { decodeReviewStatusV3 } from "../lib/review-integration-v2.ts";
import { REVIEW_SIDEBAR_EVENT } from "../lib/review-sidebar-state.ts";
import type { NativeReviewCli } from "../lib/native-review-cli.ts";
import { resolveVisualSettings, writeVisualSettings } from "../lib/visual-customization-policy.ts";
import { resolveAnimationPolicy } from "../lib/animation-policy.ts";
import { resolveVimPolicy, writeVimPolicy } from "../lib/vim-policy.ts";
Expand Down Expand Up @@ -285,6 +289,138 @@ test("gentleShell installs the footer on session_start when a UI exists", () =>
assert.match(lines[0], /main ⟡ gpt-5\.5 · medium/);
});

async function reviewSidebarHarness() {
const { pi, handlers, tools } = fakePi();
gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { activeProfile: () => undefined });
const { ctx, ui } = fakeContext();
ctx.cwd = process.cwd();
let sessionId = "shell-session";
ctx.sessionManager.getSessionId = () => sessionId;
const raw = JSON.parse(readFileSync(new URL("./fixtures/devbinary/status-v5-capture-result-submission.captured.json", import.meta.url), "utf8"));
raw.action = "stop";
raw.projection.paths = ["src/fresh.ts"];
const status = decodeReviewStatusV3(raw);
// Each hold() parks the next native status call until the returned release runs.
const held: Array<(release: (result: typeof status) => void) => void> = [];
const native = { targetStatus: async () => {
const park = held.shift();
return park ? new Promise<typeof status>((resolve) => park(resolve)) : status;
} } as unknown as NativeReviewCli;
const hold = () => {
let release!: () => void;
held.push((resolve) => { release = () => resolve(status); });
return () => release();
};
const published: unknown[] = [];
const bus = pi.events;
const observedPi = { ...pi, events: {
...bus,
emit(name: string, value: unknown) {
if (name === REVIEW_SIDEBAR_EVENT) published.push(value);
bus.emit(name, value);
},
} } as ExtensionAPI;
const producerHooks = new Map<string, Array<(event: unknown, context: ExtensionContext) => unknown>>();
createGentleAiExtension({ nativeReviewCli: native, candidateViews: null, processEnv: {} })({
...observedPi,
on(name: string, handler: (event: unknown, context: ExtensionContext) => unknown) {
producerHooks.set(name, [...(producerHooks.get(name) ?? []), handler]);
},
} as ExtensionAPI);
const produce = async (name: string, event: unknown = {}) => {
for (const hook of producerHooks.get(name) ?? []) await hook(event, ctx);
};
await fire(handlers, "session_start", ctx);
await produce("session_start");
const tui = { terminal: { rows: 40, columns: 160 }, requestRender() {} };
const factory = ui.footerFactory as (tui: unknown, theme: ShellBarTheme, footerData: unknown) => { dispose(): void };
const component = factory(tui, plainTheme, { getGitBranch: () => "main", getExtensionStatuses: () => new Map(), getAvailableProviderCount: () => 1, onBranchChange: () => () => {} });
const rail = sidebarState(tui as unknown as TUI).parts.get("footer") as SidebarRail;
return {
pi,
rail,
published,
hold,
text: () => rail.render(60).join("\n"),
sessionId: () => sessionId,
run: () => tools.get("gentle_review")!.execute("reset-test", { operation: "status", lineageId: status.authority!.lineageId }, undefined, undefined, ctx),
async startSession(next: string) {
sessionId = next;
await fire(handlers, "session_start", ctx);
await produce("session_start");
},
async navigateTree() {
await fire(handlers, "session_tree", ctx);
await produce("session_tree");
},
shutdownShell: () => fire(handlers, "session_shutdown", ctx),
async dispose() {
await produce("session_shutdown", { reason: "quit" });
await fire(handlers, "session_shutdown", ctx);
component.dispose();
},
};
}

test("review sidebar ignores a stale completion after session_start and accepts the new session", async () => {
const harness = await reviewSidebarHarness();
try {
const release = harness.hold();
const pending = harness.run();
assert.match(harness.text(), /Checking/);
await harness.startSession("next-session");
assert.doesNotMatch(harness.text(), /RDD/);
const before = harness.published.length;
release();
await pending;
assert.equal(harness.published.length, before, "producer must not publish the old session completion");
assert.doesNotMatch(harness.text(), /RDD/, "old completion must not repaint the new session");
await harness.run();
assert.match(harness.text(), /fresh\.ts/, "a fresh result in the new session is accepted");
} finally {
await harness.dispose();
}
});

test("review sidebar ignores a stale completion after same-session tree navigation", async () => {
const harness = await reviewSidebarHarness();
try {
await harness.run();
assert.match(harness.text(), /fresh\.ts/);
const release = harness.hold();
const pending = harness.run();
assert.match(harness.text(), /Checking/);
await harness.navigateTree();
assert.doesNotMatch(harness.text(), /RDD/, "tree navigation clears the visible snapshot");
const before = harness.published.length;
release();
await pending;
assert.equal(harness.published.length, before, "producer must not publish the old tree completion with the same session ID");
assert.doesNotMatch(harness.text(), /RDD/);
await harness.run();
assert.match(harness.text(), /fresh\.ts/, "a fresh tree-bound result is accepted");
} finally {
await harness.dispose();
}
});

test("review sidebar rejects foreign-session events and unsubscribes on shutdown", async () => {
const harness = await reviewSidebarHarness();
try {
await harness.run();
assert.match(harness.text(), /fresh\.ts/);
const previous = harness.rail.digest?.();
harness.pi.events.emit(REVIEW_SIDEBAR_EVENT, { sessionId: "foreign", snapshot: { state: "closed", scope: "foreign.ts" } });
assert.equal(harness.rail.digest?.(), previous, "foreign events cannot replace the current snapshot");
await harness.shutdownShell();
assert.doesNotMatch(harness.text(), /RDD/);
harness.pi.events.emit(REVIEW_SIDEBAR_EVENT, { sessionId: harness.sessionId(), snapshot: { state: "closed", scope: "late.ts" } });
assert.doesNotMatch(harness.text(), /RDD/, "shutdown unsubscribes the consumer");
} finally {
await harness.dispose();
}
});

test("the fullscreen Status rail carries a live digest so a profile switch refreshes it", async () => {
const { pi, handlers } = fakePi();
let profile: string | undefined = "team";
Expand Down
Loading