Compare commits

..
Author SHA1 Message Date
f56a1add91 fix(usage): complete enterprise spending percentages
Derive missing remaining percentages so CLI spending output matches the Usage card. Preserve explicitly reported percentages and cover missing currency and absent rolling windows.

Co-authored-by: Matt Cowger <matt@cowger.us>
Co-authored-by: JasprW <wang@jaspr.me>
2026-11-07 00:21:38 +00:00
Matt Cowger c9b03eb309 fix(usage): report enterprise spending limits 2026-11-06 12:48:19 -08:00
paseo-bot[bot]andSebastian Arena 9f68af0274 Keep uploaded terminal file paths separate (#8946)
* M1: terminal input-sink bridge, upload button in tab-bar slot and key bar, picked-file routing, space-separated pasted paths

* M2: terminal dictation: mic in tab-bar slot and key bar, recording pill and overlay, per-terminal recording lifetime incl. pending mic acquisition, Cmd+D on macOS by keyboard focus, i18n

* M2: review fixes: retry attempt guard, cancellable native pending start, measured terminal slot width, compact-web Cmd+D for terminal dictation

* fix(terminal): separate uploaded file paths when pasting

Keep the remote file-drop regression and its test coverage from the contributor change, without introducing new terminal controls. Each uploaded path needs a trailing separator because adjacent quoted paths are one shell word.

Co-authored-by: Sebastian Arena <sebastian@walkofcode.io>

---------

Co-authored-by: Sebastian Arena <sebastian@walkofcode.io>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-06 18:57:29 +00:00
paseo-bot[bot] 79f2db8d56 test(server): retry locked provider fixture cleanup on Windows (#8942)
Availability probes already await process completion. Allow bounded filesystem cleanup retries for transient executable locks, matching the neighboring Windows launch tests.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-06 18:30:36 +00:00
13 changed files with 241 additions and 182 deletions
+3
View File
@@ -195,6 +195,9 @@ See the [public usage source reference](../public-docs/plugins/reference.md#usag
contract, account and window identity, provider-derived period names, login fallback, and
read-only credential rules. Usage adapters own the interpretation of provider fields; the app
renders their names and resolves pins without provider-specific duration guesses.
Claude's OAuth usage endpoint is undocumented. Extra-usage amounts are reported in minor currency
units and displayed only for USD; the balance contract does not represent other currencies.
Codex individual spending limits provide percentages, not dollar amounts.
---
@@ -45,6 +45,11 @@ process.stdin.on("data", (chunk) => {
for (;;) {
const paste = /\x1b\[200~([^]*?)\x1b\[201~/.exec(buffer);
if (!paste) break;
const pasted = JSON.stringify(paste[1]).replace(
/\s/g,
(space) => "\\u" + space.charCodeAt(0).toString(16).padStart(4, "0"),
);
process.stdout.write("PASTED:" + pasted + "\r\n");
for (const [, path] of paste[1].matchAll(/'([^']*)'/g)) {
const digest = crypto.createHash("sha256").update(fs.readFileSync(path)).digest("hex");
const location = /[\\/]uploads[\\/]upload_/.test(path) ? "upload" : "local";
@@ -202,6 +207,21 @@ test.describe("Terminal protocol queries", () => {
await expect.poll(() => terminalOutput(terminalId), { timeout: 10_000 }).toContain(text);
}
// Every bracketed paste body the receiver consumed, in order and joined: what a
// shell would read as one command line.
async function pastedText(terminalId: string): Promise<string> {
const output = await terminalOutput(terminalId);
return [...output.matchAll(/PASTED:("(?:[^"\\]|\\.)*")/g)]
.map(([, json]) => JSON.parse(json) as string)
.join("");
}
// Exactly one ASCII space after each quoted path and nothing else between them,
// so the paths stay separate shell words.
async function expectSeparatePastedPaths(terminalId: string): Promise<void> {
await expect.poll(() => pastedText(terminalId)).toMatch(/^'[^']+' '[^']+' $/);
}
test("pastes images from two panes into their own terminals as uploaded host files", async ({
page,
}) => {
@@ -238,6 +258,23 @@ test.describe("Terminal protocol queries", () => {
await expectTerminalOutput(terminal.id, fileRead(pngBytes(3), "upload"));
await expectTerminalOutput(terminal.id, fileRead(Buffer.from("remote notes"), "upload"));
await expectSeparatePastedPaths(terminal.id);
} finally {
await harness.killTerminal(terminal.id);
}
});
test("keeps the paths of two consecutive drops to another machine apart", async ({ page }) => {
const terminal = await openFileReceiver(page, "file-drop-remote-twice");
const first = { name: "first.png", type: "image/png", bytes: pngBytes(10) };
const second = { name: "second.txt", type: "text/plain", bytes: Buffer.from("second drop") };
try {
await dropFiles(page, [first]);
await expectTerminalOutput(terminal.id, fileRead(first.bytes, "upload"));
await dropFiles(page, [second]);
await expectTerminalOutput(terminal.id, fileRead(second.bytes, "upload"));
await expectSeparatePastedPaths(terminal.id);
} finally {
await harness.killTerminal(terminal.id);
}
@@ -1,65 +0,0 @@
import { describe, expect, it } from "vitest";
import {
createFloatingPanelHosts,
type FloatingPanelHostRect,
type MeasurableFloatingPanelHost,
} from "./floating-panel-hosts";
function createHost(rect: FloatingPanelHostRect): MeasurableFloatingPanelHost {
return {
measureInWindow: (callback) => callback(rect.x, rect.y, rect.width, rect.height),
};
}
const WORKSPACE_RECT: FloatingPanelHostRect = { x: 10, y: 20, width: 800, height: 600 };
const RETAINED_VIEWPORT: FloatingPanelHostRect = { x: 0, y: 0, width: 1200, height: 900 };
describe("floating panel hosts", () => {
it("measures the newest host registered under the name", async () => {
const hosts = createFloatingPanelHosts();
hosts.register("workspace", createHost(WORKSPACE_RECT));
hosts.register("workspace", createHost(RETAINED_VIEWPORT));
expect(await hosts.measure("workspace")).toEqual(RETAINED_VIEWPORT);
});
it("keeps measuring a survivor after the newest host unregisters", async () => {
const hosts = createFloatingPanelHosts();
const unregisterSurvivor = hosts.register("workspace", createHost(WORKSPACE_RECT));
const unregisterNewest = hosts.register("workspace", createHost(RETAINED_VIEWPORT));
unregisterNewest();
expect(await hosts.measure("workspace")).toEqual(WORKSPACE_RECT);
unregisterSurvivor();
});
it("returns null once every host under the name is gone", async () => {
const hosts = createFloatingPanelHosts();
const unregisterFirst = hosts.register("workspace", createHost(WORKSPACE_RECT));
const unregisterSecond = hosts.register("workspace", createHost(RETAINED_VIEWPORT));
unregisterFirst();
unregisterSecond();
expect(await hosts.measure("workspace")).toBeNull();
});
it("skips hosts that are not laid out", async () => {
const hosts = createFloatingPanelHosts();
hosts.register("workspace", createHost(WORKSPACE_RECT));
hosts.register("workspace", createHost({ x: 0, y: 0, width: 0, height: 0 }));
expect(await hosts.measure("workspace")).toEqual(WORKSPACE_RECT);
});
it("ignores a repeated unregister of the same host", async () => {
const hosts = createFloatingPanelHosts();
const unregister = hosts.register("workspace", createHost(WORKSPACE_RECT));
unregister();
unregister();
expect(await hosts.measure("workspace")).toBeNull();
});
});
@@ -1,57 +0,0 @@
export interface FloatingPanelHostRect {
x: number;
y: number;
width: number;
height: number;
}
export interface MeasurableFloatingPanelHost {
measureInWindow(callback: (x: number, y: number, width: number, height: number) => void): void;
}
export interface FloatingPanelHosts<T extends MeasurableFloatingPanelHost> {
register(name: string, host: T): () => void;
measure(name: string): Promise<FloatingPanelHostRect | null>;
}
function measureHost(host: MeasurableFloatingPanelHost): Promise<FloatingPanelHostRect> {
return new Promise((resolve) => {
host.measureInWindow((x, y, width, height) => resolve({ x, y, width, height }));
});
}
/**
* Hosts are addressed by name, and one name can have several live hosts: a workspace
* screen mounts its own host while retained copies of that screen stay mounted. Track
* every host so unmounting one leaves the others measurable.
*/
export function createFloatingPanelHosts<
T extends MeasurableFloatingPanelHost,
>(): FloatingPanelHosts<T> {
const hostsByName = new Map<string, T[]>();
function register(name: string, host: T): () => void {
const hosts = hostsByName.get(name) ?? [];
hostsByName.set(name, hosts);
hosts.push(host);
return () => {
const index = hosts.indexOf(host);
if (index === -1) return;
hosts.splice(index, 1);
if (hosts.length === 0 && hostsByName.get(name) === hosts) {
hostsByName.delete(name);
}
};
}
async function measure(name: string): Promise<FloatingPanelHostRect | null> {
const hosts = hostsByName.get(name);
if (!hosts || hosts.length === 0) return null;
const rects = await Promise.all(hosts.toReversed().map(measureHost));
return rects.find((rect) => rect.width > 0 && rect.height > 0) ?? rects[0];
}
return { register, measure };
}
@@ -1,40 +0,0 @@
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { describe, expect, test } from "vitest";
import { Portal, PortalProvider } from "@gorhom/portal";
import { FloatingPanelPortalHost, measureFloatingPanelPortalHost } from "./floating-panel-portal";
describe("Floating panel host lifetime", () => {
test("removing the newest host preserves the surviving host measurement", async () => {
const container = document.createElement("div");
container.style.cssText = "position:relative;width:800px;height:600px";
document.body.appendChild(container);
const root = createRoot(container);
function Content({ overlap }: { overlap: boolean }) {
return (
<PortalProvider shouldAddRootHost={false}>
<FloatingPanelPortalHost name="workspace" />
{overlap && <FloatingPanelPortalHost name="workspace" />}
<Portal hostName="workspace" name="picker">
<div data-testid="picker">Commands</div>
</Portal>
</PortalProvider>
);
}
try {
await act(async () => root.render(<Content overlap={true} />));
expect(await measureFloatingPanelPortalHost("workspace")).toMatchObject({
width: 800,
height: 600,
});
await act(async () => root.render(<Content overlap={false} />));
expect(await measureFloatingPanelPortalHost("workspace")).toMatchObject({
width: 800,
height: 600,
});
} finally {
await act(async () => root.unmount());
container.remove();
}
});
});
@@ -8,21 +8,32 @@ import {
} from "react";
import { StyleSheet, View } from "react-native";
import { PortalHost } from "@gorhom/portal";
import {
createFloatingPanelHosts,
type FloatingPanelHostRect,
} from "@/components/ui/floating-panel-hosts";
export const DEFAULT_FLOATING_PANEL_PORTAL_HOST = "content-floating-panels";
const FloatingPanelPortalHostNameContext = createContext(DEFAULT_FLOATING_PANEL_PORTAL_HOST);
const portalHosts = createFloatingPanelHosts<View>();
interface Rect {
x: number;
y: number;
width: number;
height: number;
}
export function measureFloatingPanelPortalHost(
name: string,
): Promise<FloatingPanelHostRect | null> {
return portalHosts.measure(name);
const portalHostElements = new Map<string, View>();
function measureElement(element: View): Promise<Rect> {
return new Promise((resolve) => {
element.measureInWindow((x, y, width, height) => {
resolve({ x, y, width, height });
});
});
}
export function measureFloatingPanelPortalHost(name: string): Promise<Rect | null> {
const element = portalHostElements.get(name);
if (!element) return Promise.resolve(null);
return measureElement(element);
}
export function FloatingPanelPortalHostNameProvider({
@@ -53,7 +64,12 @@ export function FloatingPanelPortalHost({
useEffect(() => {
const host = hostRef.current;
if (!host) return undefined;
return portalHosts.register(name, host);
portalHostElements.set(name, host);
return () => {
if (portalHostElements.get(name) === host) {
portalHostElements.delete(name);
}
};
}, [name]);
return (
@@ -74,7 +74,8 @@ describe("terminal file paste queue", () => {
firstUpload.resolve("/host/uploads/a.png");
await settle();
expect(host.pasted).toEqual(["'/host/uploads/a.png'", "'/host/uploads/b.png'"]);
expect(host.pasted).toEqual(["'/host/uploads/a.png' ", "'/host/uploads/b.png' "]);
expect(host.pasted.join("")).toBe("'/host/uploads/a.png' '/host/uploads/b.png' ");
expect(events).toEqual({ uploading: [true, false, true, false], uploaded: 2, failures: [] });
});
@@ -85,7 +86,7 @@ describe("terminal file paste queue", () => {
queue.add(Promise.resolve(file("shot.png")), host);
await settle();
expect(host.pasted).toEqual(['"C:\\Users\\Jo Doe\\.paseo\\uploads\\upload_1\\shot.png"']);
expect(host.pasted).toEqual(['"C:\\Users\\Jo Doe\\.paseo\\uploads\\upload_1\\shot.png" ']);
});
it("uploads under a file name that needs no shell quoting", async () => {
@@ -120,7 +121,7 @@ describe("terminal file paste queue", () => {
expect(events.failures).toEqual(["disk full"]);
expect(events.uploaded).toBe(1);
expect(host.pasted).toEqual(["'/host/uploads/b.png'"]);
expect(host.pasted).toEqual(["'/host/uploads/b.png' "]);
});
it("reports unreadable and oversized files without uploading them", async () => {
@@ -166,7 +167,7 @@ describe("terminal file paste queue", () => {
queue.add(Promise.resolve(file("next.png")), host);
await settle();
expect(host.pasted).toEqual(["'/host/uploads/next.png'"]);
expect(host.pasted).toEqual(["'/host/uploads/next.png' "]);
expect(events.uploading).toEqual([true, false, true, false]);
expect(queue.cancel()).toBe(false);
});
@@ -75,7 +75,7 @@ export class TerminalFilePasteQueue {
this.setUploading(true);
const path = await target.upload({ ...file, fileName: toShellSafeFileName(file.fileName) });
if (isCurrent()) {
target.paste(prepareTerminalPath(path));
target.paste(`${prepareTerminalPath(path)} `);
this.events.onUploaded();
}
} catch (error) {
@@ -48,7 +48,8 @@ afterEach(() => {
process.env.PATH = originalEnv.PATH;
process.env.PATHEXT = originalEnv.PATHEXT;
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
// Windows can briefly retain a lock on the copied executable after its probe exits.
rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
});
@@ -162,6 +162,64 @@ describe("claude usage source", () => {
expect.objectContaining({ signal: expect.any(AbortSignal) }),
);
});
it.each([false, true])("reports USD extra usage with rolling windows %j", async (hasWindows) => {
writeClaudeCredentials(claudeHome, "at_valid");
fetchApi = mockFetch(
new Map([
[
"https://api.anthropic.com/api/oauth/usage",
() =>
jsonResponse({
...(hasWindows ? makeClaudeResponse() : {}),
extra_usage: {
is_enabled: true,
monthly_limit: 5000,
used_credits: 1250,
currency: "USD",
},
}),
],
]),
);
const claude = findProvider(await service().listUsage(), "claude");
expect(claude.balances).toEqual([
expect.objectContaining({
id: "extra_usage_monthly",
used: 12.5,
remaining: 37.5,
limit: 50,
unit: "usd",
}),
]);
expect(claude.details).toContainEqual(
expect.objectContaining({ id: "extra_usage", value: "Enabled" }),
);
});
it.each(["EUR", null, undefined])(
"does not report extra usage balances for currency %j",
async (currency) => {
writeClaudeCredentials(claudeHome, "at_valid");
fetchApi = mockFetch(
new Map([
[
"https://api.anthropic.com/api/oauth/usage",
() =>
jsonResponse({
...makeClaudeResponse(),
extra_usage: { monthly_limit: 5000, used_credits: 1250, currency },
}),
],
]),
);
const claude = findProvider(await service().listUsage(), "claude");
expect(claude.balances).toEqual([]);
},
);
it("accepts a null Claude resets_at when a window has no scheduled reset", async () => {
writeClaudeCredentials(claudeHome, "at_valid");
+28 -4
View File
@@ -68,6 +68,9 @@ const ClaudeUsageResponseSchema = z.object({
extra_usage: z
.object({
is_enabled: z.boolean().optional(),
monthly_limit: ApiNumberSchema.nullish(),
used_credits: ApiNumberSchema.nullish(),
currency: z.string().nullish(),
})
.nullish(),
});
@@ -491,6 +494,26 @@ function toCredentialRecord(credentials: ClaudeCredentials): ClaudeCredentialRec
: null;
}
function extraUsageBalances(extraUsage: ClaudeUsageResponse["extra_usage"]) {
if (extraUsage?.currency?.toUpperCase() !== "USD") return [];
const used = extraUsage.used_credits == null ? undefined : extraUsage.used_credits / 100;
const limit = extraUsage.monthly_limit == null ? undefined : extraUsage.monthly_limit / 100;
if (used == null && limit == null) return [];
return [
{
id: "extra_usage_monthly",
label: "Extra usage",
used,
remaining: used != null && limit != null ? Math.max(0, limit - used) : undefined,
limit,
unit: "usd" as const,
tone: toneFromUsedPct(
used != null && limit != null && limit > 0 ? (used / limit) * 100 : null,
),
},
];
}
export async function fetchUsage(
input: UsageInput,
fetchApi: typeof fetch = fetch,
@@ -594,20 +617,21 @@ export async function fetchUsage(
}
const details: UsageDetail[] = [];
const extraUsageEnabled = resp.extra_usage?.is_enabled;
if (extraUsageEnabled !== undefined) {
const extraUsage = resp.extra_usage;
if (extraUsage?.is_enabled !== undefined) {
details.push({
id: "extra_usage",
label: "Extra usage",
value: extraUsageEnabled ? "Enabled" : "Disabled",
value: extraUsage.is_enabled ? "Enabled" : "Disabled",
});
}
const balances = extraUsageBalances(extraUsage);
return {
status: "available",
planLabel: plan ?? undefined,
windows,
balances: [],
balances,
details,
};
}
@@ -117,6 +117,56 @@ test("coerces credit balance and marks a 96 percent window dangerous", async ()
]);
});
test("reports enterprise individual spend limit percentage and reset", async () => {
const report = await fetchUsage(authInput(fixtureHome), async () =>
Response.json({
plan_type: "enterprise",
spend_control: {
individual_limit: { used_percent: 5, remaining_percent: 95, reset_at: 1795996800 },
},
}),
);
expect(report).toMatchObject({
planLabel: "enterprise",
windows: [
expect.objectContaining({
id: "individual_limit",
label: "Individual spend limit",
summary: true,
usedPct: 5,
remainingPct: 95,
resetsAt: new Date(1795996800 * 1000).toISOString(),
}),
],
});
});
test.each([
[{ used_percent: 5 }, 5, 95],
[{ remaining_percent: 95 }, 5, 95],
[{ used_percent: 5, remaining_percent: 90 }, 5, 90],
[{ used_percent: 110 }, 110, 0],
])(
"completes individual spending percentages for %j",
async (individual_limit, usedPct, remainingPct) => {
const report = await fetchUsage(authInput(fixtureHome), async () =>
Response.json({ spend_control: { individual_limit } }),
);
expect(report).toMatchObject({
status: "available",
windows: [expect.objectContaining({ id: "individual_limit", usedPct, remainingPct })],
});
},
);
test("omits individual spend limit without a percentage", async () => {
const report = await fetchUsage(authInput(fixtureHome), async () =>
Response.json({ spend_control: { individual_limit: { reset_at: 1795996800 } } }),
);
expect(report.windows).toEqual([]);
});
test("summarizes the session and weekly windows by default, not code review", async () => {
const report = await fetchUsage(
authInput(fixtureHome),
@@ -34,6 +34,15 @@ const rateLimitSchema = z.object({
primary_window: windowSchema.nullish(),
secondary_window: windowSchema.nullish(),
});
const spendControlSchema = z.object({
individual_limit: z
.object({
used_percent: number.nullish(),
remaining_percent: number.nullish(),
reset_at: number.nullish(),
})
.nullish(),
});
const responseSchema = z.object({
plan_type: z.string().optional(),
email: z.string().optional(),
@@ -48,6 +57,7 @@ const responseSchema = z.object({
)
.nullish(),
code_review_rate_limit: rateLimitSchema.nullish(),
spend_control: spendControlSchema.nullish(),
credits: z.object({ balance: number.optional() }).nullish(),
});
@@ -227,6 +237,27 @@ export async function fetchUsage(
},
});
}),
...(usage.spend_control?.individual_limit
? (() => {
const limit = usage.spend_control.individual_limit;
const usedPct =
limit.used_percent ??
(limit.remaining_percent != null ? 100 - limit.remaining_percent : undefined);
if (usedPct === undefined) return [];
return [
{
id: "individual_limit",
label: "Individual spend limit",
summary: true,
usedPct,
remainingPct: limit.remaining_percent ?? Math.max(0, 100 - usedPct),
resetsAt:
limit.reset_at != null ? new Date(limit.reset_at * 1000).toISOString() : null,
tone: toneFromUsedPct(usedPct),
},
];
})()
: []),
...usageWindows({
rateLimit: usage.code_review_rate_limit,
scope: { id: "code_review", label: "Code review" },