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
15 changes: 11 additions & 4 deletions apps/desktop/src/main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,11 +244,9 @@ function ensurePi(projectDir: string, sessionFile?: string, fresh = false): Prom
return startup;
}

async function bindPi(projectDir: string, sessionFile: string): Promise<PiProcess> {
async function bindPi(projectDir: string, sessionFile: string, mayWrite = true): Promise<PiProcess> {
const pi = await ensurePi(projectDir, sessionFile);
// Everything reached through bindPi may write the session file (rename, fork,
// clone, compact), so claim the write before it happens.
markBusy(sessionFile, Date.now() + SETTLE_GRACE_MS);
if (mayWrite) markBusy(sessionFile, Date.now() + SETTLE_GRACE_MS);
return pi;
}

Expand Down Expand Up @@ -682,6 +680,15 @@ const handlers: HandlerMap = {
}
},

getContextInspector: async ({ projectDir, sessionFile }) => {
try {
const pi = sessionFile ? await bindPi(projectDir, sessionFile, false) : await ensurePi(projectDir);
return { inspector: await pi.getContextInspector() };
} catch (err) {
return { error: errorMessage(err) };
}
},

compact: async ({ projectDir, sessionFile }) => {
try {
const pi = await bindPi(projectDir, sessionFile);
Expand Down
8 changes: 8 additions & 0 deletions apps/desktop/src/main/pi/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { fileURLToPath } from "node:url";
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { isTuiFrameType, tuiHostFrameSchema, type TuiClientFrame, type TuiHostFrame } from "../../shared/tui-frames.ts";
import type { AuthProviderInfo } from "../../shared/rpc-schema.ts";
import type { ContextInspector } from "../../shared/pi-types.ts";
import { contextInspectorSchema } from "../../shared/tui-frames.ts";
import { drainLines, serializeCommand, type PiCommand, type PiMessage } from "./protocol.ts";

/**
Expand Down Expand Up @@ -176,6 +178,12 @@ export class PiProcess {
return this.frameRequest<AuthProviderInfo[]>((requestId) => ({ type: "nativepi_tui_get_providers", requestId }));
}

getContextInspector(): Promise<ContextInspector> {
return this.frameRequest<unknown>((requestId) => ({ type: "nativepi_tui_get_context_inspector", requestId })).then(
(data) => contextInspectorSchema.parse(data),
);
}

loginProvider(providerId: string, authType: "api_key" | "oauth"): Promise<void> {
return this.frameRequest<true>(
(requestId) => ({ type: "nativepi_tui_login", requestId, providerId, authType }),
Expand Down
20 changes: 20 additions & 0 deletions apps/desktop/src/main/pi/host/entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
import { isTuiFrameType, type TuiClientFrame, type TuiHostFrame } from "../../../shared/tui-frames.ts";
import { toNotice, toPromptRequest } from "../../../shared/providerAuth.ts";
import { shapeProviders } from "../../../shared/providerShape.ts";
import type { ContextInspector } from "../../../shared/pi-types.ts";
import { hostInternals, withTerminalUi, type HostInternals } from "./uiContext.ts";

/**
Expand Down Expand Up @@ -91,6 +92,10 @@ function handleClientFrame(frame: TuiClientFrame): void {
void respondProviders(frame.requestId);
return;
}
if (frame.type === "nativepi_tui_get_context_inspector") {
void respondContextInspector(frame.requestId);
return;
}
if (frame.type === "nativepi_tui_login") {
void respondLogin(frame.requestId, frame.providerId, frame.authType);
return;
Expand All @@ -102,6 +107,21 @@ function handleClientFrame(frame: TuiClientFrame): void {
internals?.handle(frame);
}

async function respondContextInspector(requestId: string): Promise<void> {
try {
if (!currentSession) throw new Error("No active Pi session");
const session = currentSession;
const usage = session.getContextUsage();
const inspector: ContextInspector = {
usedTokens: usage?.tokens ?? null,
contextWindow: usage?.contextWindow ?? session.model?.contextWindow ?? 0,
};
send({ type: "nativepi_tui_reply", requestId, data: inspector });
} catch (err) {
send({ type: "nativepi_tui_reply", requestId, error: err instanceof Error ? err.message : String(err) });
}
}

async function respondProviders(requestId: string): Promise<void> {
try {
const providers = await shapeProviders(await providerRuntime());
Expand Down
114 changes: 73 additions & 41 deletions apps/desktop/src/renderer/components/Composer.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { ArrowBendUpRightIcon, CaretDownIcon, CheckIcon, GitBranchIcon, PaperPlaneRightIcon, PlusIcon, TreeStructureIcon } from "../../shared/icons.ts"
import { CircleNotchIcon } from "@phosphor-icons/react/CircleNotch";
import { PaperclipIcon } from "@phosphor-icons/react/Paperclip";
import { useEffect, useId, useMemo, useRef, useState } from "react";
import type { AssistantMessage } from "../../shared/pi-types.ts";
import { useEffect, useMemo, useRef, useState } from "react";
import type { AssistantMessage, ContextInspector as ContextInspectorData } from "../../shared/pi-types.ts";
import { draftKeyFor } from "../../shared/messages.ts";
import { ACCEPTED_IMAGE_TYPES } from "../lib/attachments.ts";
import { classifyDrop, draggingFiles, mentionPath } from "../lib/drops.ts";
Expand All @@ -18,6 +18,7 @@ import { Button } from "@/components/ui/button.tsx";
import { Input } from "@/components/ui/input.tsx";
import { Menu, MenuItem, MenuPopup, MenuTrigger } from "@/components/ui/menu.tsx";
import { Kbd } from "@/components/ui/kbd.tsx";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog.tsx";
import { SCROLLBAR_GUTTER_OFFSET, cn } from "@/lib/utils.ts";
import ComposerAttachments from "./ComposerAttachments.tsx";
import ComposerInput from "./ComposerInput.tsx";
Expand Down Expand Up @@ -625,8 +626,9 @@ function ContextWindow() {
const entries = useAppStore((s) => activeConversation(s).entries);
const streaming = useAppStore((s) => activeConversation(s).streaming);
const model = useAppStore((s) => s.model);
const [pinned, setPinned] = useState(false);
const panelId = useId();
const projectDir = useAppStore((s) => s.activeProjectPath);
const sessionFile = useAppStore((s) => s.activeSessionFile);
const [open, setOpen] = useState(false);

// Walking the whole transcript backwards on every keystroke in the composer is
// what this used to do; the answer only changes when the transcript does.
Expand All @@ -646,7 +648,13 @@ function ContextWindow() {
}, [entries, streaming]);

const total = model?.contextWindow ?? 0;
const percent = total ? Math.min(100, Math.round((used / total) * 100)) : 0;
const inspection = useRequest(
async () => (open && projectDir ? await rpc.request.getContextInspector({ projectDir, sessionFile: sessionFile ?? undefined }) : null),
[open, projectDir, sessionFile, entries, streaming],
);
const inspectedUsed = inspection.data?.inspector?.usedTokens ?? used;
const inspectedTotal = inspection.data?.inspector?.contextWindow || total;
const percent = inspectedTotal ? Math.min(100, Math.round((inspectedUsed / inspectedTotal) * 100)) : 0;

// A ring with nothing behind it is decoration. Until Pi reports a window for
// this model there is no reading to give, so the control is not interactive —
Expand All @@ -668,12 +676,7 @@ function ContextWindow() {
// The figure lives in the accessible name, so the ring is not a
// visual-only readout for anyone who can't see it.
aria-label={`Context window ${percent}% used, ${formatTokens(used)} of ${formatTokens(total)} tokens`}
aria-expanded={pinned}
aria-controls={panelId}
onClick={() => setPinned((current) => !current)}
onKeyDown={(event) => {
if (event.key === "Escape") setPinned(false);
}}
onClick={() => setOpen(true)}
className={cn(
"flex h-8 items-center gap-1.5 rounded-lg px-1.5 outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring",
tone,
Expand All @@ -685,37 +688,66 @@ function ContextWindow() {
</svg>
{tight ? <span className="text-sm tabular-nums" aria-hidden="true">{percent}%</span> : null}
</button>
{/*
Not a live region: this panel is permanently mounted and its numbers
change on every token, so role="status" made screen readers narrate
token counts continuously. It is not aria-hidden either — the button
above claims to expand it, and it holds the only statement anywhere in
the app that Pi compacts before the model limit.
*/}
<div
id={panelId}
className={cn(
// Anchored left on a phone, where this control has wrapped into the
// middle of the toolbar and a right-anchored 18rem panel would hang
// off the side of the screen.
"pointer-events-none absolute bottom-full left-0 mb-2 hidden w-72 max-w-[calc(100vw-2rem)] rounded-xl border bg-popover p-4 text-popover-foreground shadow-lg group-hover:block group-focus-within:block sm:left-auto sm:right-0",
pinned && "block",
)}
>
<div className="flex items-baseline justify-between gap-4">
<p className="text-sm font-semibold">Context window</p>
<p className={cn("text-xs tabular-nums", tone)}>
{percent}% · {formatTokens(used)}/{formatTokens(total)}
</p>
</div>
<div className="mt-3 h-1.5 overflow-hidden rounded-full bg-muted">
<div className={cn("h-full rounded-full bg-current transition-[width]", tone)} style={{ width: `${percent}%` }} />
</div>
<p className="mt-3 text-xs leading-relaxed text-muted-foreground">
Pi automatically compacts the conversation before it reaches the model limit.
</p>
</div>
<ContextInspector
open={open}
onOpenChange={setOpen}
loading={inspection.loading}
inspector={inspection.data?.inspector}
error={inspection.data?.error ?? inspection.error ?? undefined}
used={inspectedUsed}
total={inspectedTotal}
percent={percent}
/>
</div>
);
}

function ContextInspector({
open,
onOpenChange,
loading,
inspector,
error,
used,
total,
percent,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
loading: boolean;
inspector?: ContextInspectorData;
error?: string;
used: number;
total: number;
percent: number;
}) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-xl gap-5">
<DialogHeader>
<DialogTitle className="font-heading text-base font-semibold">Context window</DialogTitle>
<DialogDescription className="text-sm text-muted-foreground">
Pi’s latest context-window measurement for this conversation.
</DialogDescription>
</DialogHeader>

<section aria-label="Context usage">
<div className="flex items-baseline justify-between gap-4">
<p className="text-sm font-medium">{percent}% used</p>
Comment thread
nonlooped marked this conversation as resolved.
<p className="font-mono text-xs tabular-nums text-muted-foreground">
{formatTokens(used)} / {formatTokens(total)} tokens
</p>
</div>
<div className="mt-2 h-2 overflow-hidden rounded-full bg-muted" aria-hidden="true">
<div className="h-full rounded-full bg-foreground transition-[width]" style={{ width: `${percent}%` }} />
</div>
</section>

{loading ? <p className="text-sm text-muted-foreground">Reading the active Pi session…</p> : null}
{error ? <p className="text-sm text-destructive">Could not inspect this context: {error}</p> : null}
{inspector?.usedTokens === null ? <p className="text-sm text-muted-foreground">Pi has not received a context measurement from this model yet.</p> : null}
</DialogContent>
</Dialog>
);
}

6 changes: 6 additions & 0 deletions apps/desktop/src/shared/pi-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,12 @@ export interface SessionStats {
cost: number;
}

/** Pi's latest read-only context-window measurement. */
export interface ContextInspector {
usedTokens: number | null;
contextWindow: number;
}

export interface SessionSummary {
path: string;
id: string;
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/src/shared/rpc-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type {
ResolvedExtension,
RpcSessionState,
SessionStats,
ContextInspector,
SessionSearchResult,
SessionSummary,
SessionTreeNode,
Expand Down Expand Up @@ -365,6 +366,10 @@ export type HostRequests = {
params: { projectDir: string; sessionFile: string };
response: { stats?: SessionStats; error?: string };
};
getContextInspector: {
params: { projectDir: string; sessionFile?: string };
response: { inspector?: ContextInspector; error?: string };
};
compact: {
params: { projectDir: string; sessionFile: string };
response: { ok: boolean; error?: string };
Expand Down
12 changes: 11 additions & 1 deletion apps/desktop/src/shared/tui-frames.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { expect, test } from "bun:test";
import { isTuiFrameType, tuiClientFrameSchema, tuiHostFrameSchema } from "./tui-frames.ts";
import { contextInspectorSchema, isTuiFrameType, tuiClientFrameSchema, tuiHostFrameSchema } from "./tui-frames.ts";

/**
* What keeps the side channel and Pi's protocol apart, and what keeps extension
Expand Down Expand Up @@ -51,3 +51,13 @@ test("a resize from the window is bounded", () => {
tuiClientFrameSchema.safeParse({ type: "nativepi_tui_resize", surfaceId: "s1", cols: 0, rows: 24 }).success,
).toBe(false);
});

test("the context inspector accepts only a non-negative Pi measurement", () => {
const inspector = {
usedTokens: 42,
contextWindow: 200_000,
};

expect(contextInspectorSchema.safeParse(inspector).success).toBe(true);
expect(contextInspectorSchema.safeParse({ ...inspector, usedTokens: -1 }).success).toBe(false);
});
7 changes: 7 additions & 0 deletions apps/desktop/src/shared/tui-frames.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { z } from "zod";
import type { ContextInspector } from "./pi-types.ts";

const authPromptSchema = z.discriminatedUnion("kind", [
z.object({ kind: z.literal("text"), message: z.string(), placeholder: z.string().optional() }),
Expand Down Expand Up @@ -104,6 +105,11 @@ const completionItemSchema = z.object({
description: z.string().max(500).optional(),
});

export const contextInspectorSchema = z.object({
usedTokens: z.number().int().nonnegative().nullable(),
contextWindow: z.number().int().nonnegative(),
}) satisfies z.ZodType<ContextInspector>;

export type TuiCompletionItem = z.infer<typeof completionItemSchema>;

/** An extension autocomplete provider's answer, as `AutocompleteSuggestions`. */
Expand Down Expand Up @@ -206,6 +212,7 @@ export const tuiClientFrameSchema = z.discriminatedUnion("type", [
* extensions need this round trip to reach the picker and Settings.
*/
z.object({ type: z.literal("nativepi_tui_get_providers"), requestId: z.string().min(1).max(64) }),
z.object({ type: z.literal("nativepi_tui_get_context_inspector"), requestId: z.string().min(1).max(64) }),
z.object({
type: z.literal("nativepi_tui_login"),
requestId: z.string().min(1).max(64),
Expand Down