Skip to content

Commit 92c42bf

Browse files
committed
merge master
2 parents 1f5fabf + 15b069c commit 92c42bf

24 files changed

Lines changed: 410 additions & 161 deletions

File tree

.github/workflows/ci.yml

Lines changed: 25 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -77,12 +77,17 @@ jobs:
7777
- name: Format check
7878
run: pnpm run fmt:check
7979

80-
test:
81-
name: Test
80+
test_shard:
81+
name: Test shard (${{ matrix.shard }}/4)
8282
runs-on: ubuntu-latest
83+
strategy:
84+
fail-fast: false
85+
matrix:
86+
shard: [1, 2, 3, 4]
8387
# Unit tests run via vitest in node/jsdom and never launch the Electron
84-
# runtime, so skip the flaky Chromium binary download. Native node modules
85-
# (better-sqlite3, node-pty) are still built by the install scripts below.
88+
# runtime, so skip the flaky Chromium binary download and the root
89+
# postinstall, which rebuilds better-sqlite3 for Electron. The database
90+
# tests need Node-ABI better-sqlite3 and node-pty bindings rebuilt below.
8691
# ELECTRON_OVERRIDE_DIST_PATH lets `require("electron")` resolve to a path
8792
# string without the binary present, so suites that import the module (but
8893
# never spawn it) load instead of throwing from electron/index.js.
@@ -103,19 +108,19 @@ jobs:
103108
cache: pnpm
104109

105110
- name: Install dependencies
106-
run: |
107-
for attempt in 1 2 3; do
108-
if pnpm install --frozen-lockfile; then
109-
exit 0
110-
fi
111-
echo "::warning::pnpm install failed on attempt $attempt; retrying in 15s"
112-
sleep 15
113-
done
114-
echo "::error::pnpm install failed after 3 attempts"
115-
exit 1
116-
117-
- name: Ensure native dependencies
118-
run: node scripts/ensure-native-deps.mjs
119-
120-
- name: Test
121-
run: pnpm run test
111+
run: pnpm install --frozen-lockfile --ignore-scripts
112+
113+
- name: Build test native dependencies
114+
run: pnpm rebuild better-sqlite3 node-pty
115+
116+
- name: Test shard
117+
run: pnpm run test --shard=${{ matrix.shard }}/4
118+
119+
test:
120+
name: Test
121+
if: ${{ always() }}
122+
needs: test_shard
123+
runs-on: ubuntu-latest
124+
steps:
125+
- name: Verify test shards
126+
run: test "${{ needs.test_shard.result }}" = "success"

packages/agents-usage/src/collectors/cursor.test.ts

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -74,11 +74,10 @@ describe("parseCursorUsage", () => {
7474
expect(api.resetsAt).toBe(1_719_600_000_000);
7575
});
7676

77-
it("treats breakdown.total as real spend and derives the allowance from the percent", () => {
78-
// Live payload shape: used/limit/remaining clamp at the included $20 while
79-
// breakdown.total carries the real spend ($20 included + $8.75 bonus
80-
// consumed = $28.75). The authoritative 55.07% then implies a ~$52.21
81-
// allowance (28.75 / 0.5507), keeping the dollars consistent with the bar.
77+
it("treats breakdown.total as real spend against the vendor plan limit", () => {
78+
// Live payload: used/limit clamp at included $20 while breakdown.total is
79+
// real spend ($20 included + $8.75 bonus = $28.75). API % is a separate
80+
// meter — do not invent limit = spend / percent ($28.75 / 55% ≈ $52).
8281
const body = {
8382
membershipType: "pro",
8483
individualUsage: {
@@ -96,12 +95,11 @@ describe("parseCursorUsage", () => {
9695
const api = snap.windows.find((w) => w.id === "cursor-api")!;
9796
expect(api.usedPercent).toBeCloseTo(55.07);
9897
expect(api.used).toBeCloseTo(28.75);
99-
expect(api.limit).toBeCloseTo(52.21, 1);
98+
expect(api.limit).toBeCloseTo(20);
10099
});
101100

102-
it("derives the allowance when the clamped dollars disagree with the percent (no breakdown)", () => {
103-
// Older payloads without a breakdown: used/limit cap at the plan price
104-
// ($20/$20) while the percent says 44% consumed → allowance ≈ $45.45.
101+
it("keeps plan dollars when they disagree with API percent (no breakdown)", () => {
102+
// Clamped plan price $20/$20; API bar can still be 44% of a different pool.
105103
const body = {
106104
membershipType: "pro",
107105
individualUsage: {
@@ -112,7 +110,29 @@ describe("parseCursorUsage", () => {
112110
const api = snap.windows.find((w) => w.id === "cursor-api")!;
113111
expect(api.usedPercent).toBe(44);
114112
expect(api.used).toBeCloseTo(20);
115-
expect(api.limit).toBeCloseTo(45.45, 1);
113+
expect(api.limit).toBeCloseTo(20);
114+
});
115+
116+
it("shows overspend past the plan limit without inventing a percent-derived ceiling", () => {
117+
// User case: ~$35.61 total cost, $20 Pro included, API bar ~4.5%.
118+
// Old math: $35.61 / 0.04555 ≈ $782 nonsense allowance.
119+
const body = {
120+
membershipType: "pro",
121+
individualUsage: {
122+
plan: {
123+
used: 2000,
124+
limit: 2000,
125+
breakdown: { included: 2000, bonus: 1561, total: 3561 },
126+
autoPercentUsed: 22.37,
127+
apiPercentUsed: 4.555,
128+
},
129+
},
130+
};
131+
const snap = parseCursorUsage(body, {}, NOW);
132+
const api = snap.windows.find((w) => w.id === "cursor-api")!;
133+
expect(api.usedPercent).toBeCloseTo(4.555);
134+
expect(api.used).toBeCloseTo(35.61);
135+
expect(api.limit).toBeCloseTo(20);
116136
});
117137

118138
it("keeps the vendor limit when the dollars already agree with the percent", () => {

packages/agents-usage/src/collectors/cursor.ts

Lines changed: 17 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,8 @@ import type { UsageSnapshot, UsageWindow } from "../types";
1212
* Schema (per codexbar) of GET /api/usage-summary → individualUsage.plan:
1313
* { used (cents), limit (cents), breakdown { included, bonus, total },
1414
* totalPercentUsed, autoPercentUsed, apiPercentUsed } + billingCycleEnd +
15-
* membershipType. Surfaced as Auto and API breakdown windows. The dollar
16-
* allowance belongs to API usage; see `apiDollars` for how the clamped
17-
* dollar fields are reconciled with the authoritative percents.
15+
* membershipType. Surfaced as Auto and API windows. API dollars use real
16+
* spend over the vendor plan limit; the bar uses apiPercentUsed separately.
1817
*/
1918

2019
export const CURSOR_USAGE_ENDPOINT = "https://cursor.com/api/usage-summary";
@@ -63,21 +62,22 @@ function clampPercent(value: number | undefined): number | undefined {
6362
}
6463

6564
/**
66-
* Cursor's plan dollar fields need reconciliation (verified against the live
67-
* payload and CodexBar's Cursor probe, which scrapes the same endpoint):
65+
* Cursor plan dollars vs API percent are different meters:
6866
*
69-
* - `used`/`limit`/`remaining` clamp at the nominal plan price (e.g. $20/$20
70-
* while the percent says 55%), so they understate real spend.
71-
* - `breakdown.total` is the total credits *consumed* (included + bonus spend),
72-
* not the allowance — treating it as the limit was CodexBar regression #240.
73-
* Prefer it as the real spend when it exceeds the clamped `used`.
74-
* - `apiPercentUsed` is the authoritative consumed fraction (it's what
75-
* Cursor's own dashboard messages report). When the dollar pair disagrees
76-
* with it, keep the spend and derive the allowance as `spend / percent` so
77-
* the dollar text always matches the bar (e.g. $28.75 / $52.21 at 55%,
78-
* instead of a clamped, full-looking $20 / $20).
67+
* - `used`/`limit` clamp at the nominal plan price (e.g. $20/$20) and can
68+
* understate real spend once bonus credit is consumed.
69+
* - `breakdown.total` is credits *consumed* (included + bonus spend), not the
70+
* allowance — treating it as the limit was CodexBar regression #240.
71+
* - `apiPercentUsed` drives the bar / "% by reset" pace; it is not a fraction
72+
* of the plan dollar cap. Deriving `limit = spend / apiPercent` invents a
73+
* nonsense ceiling (e.g. $35.61 / $775 at 4.5%) that is neither spend nor
74+
* the Pro included allowance.
75+
*
76+
* Surface honest money: real spend over the vendor plan limit. The bar may
77+
* then disagree with the dollar ratio — that is correct; they measure different
78+
* things.
7979
*/
80-
function apiDollars(plan: CursorPlanUsage, percent: number): { used?: number; limit?: number } {
80+
function apiDollars(plan: CursorPlanUsage): { used?: number; limit?: number } {
8181
const reportedUsed = centsToUsd(plan.used);
8282
const reportedLimit = centsToUsd(plan.limit);
8383
const breakdownTotal = centsToUsd(plan.breakdown?.total);
@@ -89,15 +89,6 @@ function apiDollars(plan: CursorPlanUsage, percent: number): { used?: number; li
8989
if (spend === undefined) {
9090
return reportedLimit !== undefined && reportedLimit > 0 ? { limit: reportedLimit } : {};
9191
}
92-
if (spend > 0 && percent > 0) {
93-
const impliedPercent =
94-
reportedLimit !== undefined && reportedLimit > 0 ? (spend / reportedLimit) * 100 : undefined;
95-
// 1-point tolerance so ordinary rounding drift in the reported percent
96-
// doesn't override a limit the spend already agrees with.
97-
if (impliedPercent === undefined || Math.abs(impliedPercent - percent) > 1) {
98-
return { used: spend, limit: spend / (percent / 100) };
99-
}
100-
}
10192
return {
10293
used: spend,
10394
...(reportedLimit !== undefined && reportedLimit > 0 ? { limit: reportedLimit } : {}),
@@ -146,7 +137,7 @@ export function parseCursorUsage(
146137
usedPercent: apiPercent,
147138
unit: "percent",
148139
currency: "USD",
149-
...apiDollars(plan, apiPercent),
140+
...apiDollars(plan),
150141
...withReset,
151142
});
152143
}

src/renderer/components/thread/ChatPane/ChatPane.test.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -985,6 +985,21 @@ describe("ChatPane", () => {
985985
).not.toBeInTheDocument();
986986
});
987987

988+
it("renders selected skills in user messages as skill badges", async () => {
989+
const thread = makeThread();
990+
seedUserMessageContent(thread.id, [
991+
{ kind: "skill", name: "simplify", invocation: "$simplify" },
992+
]);
993+
994+
const { container } = renderChatPane(thread);
995+
await waitFor(() => expect(hydrateThreadRuntimeItems).toHaveBeenCalledWith(thread.id));
996+
997+
const badge = container.querySelector('[data-skill-name="simplify"]');
998+
expect(badge).toHaveTextContent("simplify");
999+
expect(badge?.querySelector("svg")).toBeInTheDocument();
1000+
expect(screen.queryByText("$simplify")).not.toBeInTheDocument();
1001+
});
1002+
9881003
it("copies user message text from the inline action", async () => {
9891004
const thread = makeThread();
9901005
seedUserMessage(thread.id, "Copy this prompt");

src/renderer/components/thread/ChatPane/parts/items/Reasoning.test.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,22 @@ describe("Reasoning", () => {
5555
MockResizeObserver.reset();
5656
});
5757

58+
it("shows the last streamed line as the collapsed Thinking preview", () => {
59+
const { container } = renderReasoning(
60+
makeReasoningItem("Inspecting logs\nChecking the tail output now"),
61+
);
62+
63+
const toggle = container.querySelector("button");
64+
if (!toggle) throw new Error("missing Thinking toggle");
65+
expect(toggle.textContent).toContain("Thinking");
66+
expect(toggle.textContent).toContain("Checking the tail output now");
67+
// Collapsed: no live viewport mounted until the row is expanded.
68+
expect(container.querySelector(".overflow-y-auto")).toBeNull();
69+
});
70+
5871
it("keeps live reasoning pinned to the bottom while new content streams in", async () => {
5972
const { container, rerender } = renderReasoning(makeReasoningItem("Inspecting logs"));
73+
expandReasoning(container);
6074
const viewport = getReasoningViewport(container);
6175
const content = getReasoningContent(viewport);
6276
const metrics = installScrollMetrics(viewport, {
@@ -102,6 +116,7 @@ describe("Reasoning", () => {
102116

103117
it("stops auto-scrolling once the user scrolls up inside the live reasoning block", async () => {
104118
const { container, rerender } = renderReasoning(makeReasoningItem("Inspecting logs"));
119+
expandReasoning(container);
105120
const viewport = getReasoningViewport(container);
106121
const content = getReasoningContent(viewport);
107122
const metrics = installScrollMetrics(viewport, {
@@ -158,6 +173,12 @@ function makeReasoningItem(text: string): RuntimeChatItem {
158173
};
159174
}
160175

176+
function expandReasoning(container: HTMLElement) {
177+
const toggle = container.querySelector("button");
178+
if (!toggle) throw new Error("missing reasoning toggle");
179+
fireEvent.click(toggle);
180+
}
181+
161182
function getReasoningViewport(container: HTMLElement): HTMLDivElement {
162183
const element = container.querySelector(".overflow-y-auto");
163184
if (!(element instanceof HTMLDivElement)) {
Lines changed: 49 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
11
import { memo, useState } from "react";
2-
import { Surface } from "@heroui/react";
32
import { Trans, useLingui } from "@lingui/react/macro";
43
import { Brain, ChevronDown } from "lucide-react";
54
import type { RuntimeChatItem } from "@/renderer/state/slices/runtimeEventSlice";
65
import { useChatPaneActions } from "../../chatPaneActionsContext";
76
import { useBrainThinking, useShimmer } from "@/renderer/thinkingAnimator";
8-
import { chatMessageSurfaceClass } from "./chatMessageSurface";
9-
import { getReasoningPreview } from "./reasoningPreview";
7+
import { getReasoningLastLine, getReasoningPreview } from "./reasoningPreview";
108
import { ReasoningExpandedBody, ReasoningStreamViewport } from "./ReasoningStreamViewport";
119

1210
interface ReasoningProps {
@@ -24,55 +22,55 @@ export const Reasoning = memo(function Reasoning({ item }: ReasoningProps) {
2422
const thinkingTextRef = useShimmer<HTMLSpanElement>(isStreaming);
2523
const brainRef = useBrainThinking(isStreaming);
2624

27-
if (!isStreaming) {
28-
const preview = isOpen ? "" : getReasoningPreview(rawText);
29-
// Compact toggle — visually distinct from tool-call accordions: no border
30-
// tile, dotted left rule when expanded, italic body. Equal vertical
31-
// padding so it doesn't visually bias toward the message above or below.
32-
return (
33-
<div className="flex w-full flex-col items-stretch justify-center py-2 pl-6 pr-3 text-[length:var(--lc-chat-font-size-meta)] text-foreground-muted">
34-
<button
35-
type="button"
36-
onClick={() => {
37-
setIsOpen((v) => !v);
38-
actions?.onContentHeightChange();
39-
}}
40-
aria-expanded={isOpen}
41-
className="group inline-flex min-w-0 max-w-full items-center gap-1.5 self-start leading-none italic opacity-80 hover:text-foreground hover:opacity-100"
42-
>
43-
<Brain className="size-3 shrink-0" />
44-
<span className="shrink-0">
45-
<Trans>Thought</Trans>
46-
</span>
47-
{preview ? <span className="min-w-0 truncate opacity-70">{preview}</span> : null}
48-
<ChevronDown
49-
className={`size-3 shrink-0 opacity-100 transition-[transform,opacity] [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-hover:opacity-100 [@media(hover:hover)]:group-focus-visible:opacity-100 ${isOpen ? "rotate-180" : ""}`}
50-
/>
51-
</button>
52-
{isOpen ? <ReasoningExpandedBody text={rawText} className="mt-2" /> : null}
53-
</div>
54-
);
55-
}
25+
// Collapsed row is identical while thinking and after: only the label, the
26+
// brain/shimmer animation, and the preview source differ. While streaming the
27+
// trailing meta tracks the model's current line (streamed in line by line);
28+
// on completion it settles into the flattened whole-block preview.
29+
const preview = isOpen
30+
? ""
31+
: isStreaming
32+
? getReasoningLastLine(rawText)
33+
: getReasoningPreview(rawText);
5634

35+
// Compact toggle — visually distinct from tool-call accordions: no border
36+
// tile, dotted left rule when expanded, italic body. Equal vertical padding so
37+
// it doesn't visually bias toward the message above or below. Expanding while
38+
// streaming reveals the live pinned viewport; after completion, the static body.
5739
return (
58-
<Surface variant="transparent" className={`${chatMessageSurfaceClass} pl-6`}>
59-
<div className="flex min-w-0 flex-col gap-1.5 text-[length:var(--lc-chat-font-size-meta)] text-foreground-muted">
60-
<div className="inline-flex items-center gap-1.5">
61-
<Brain
62-
ref={brainRef}
63-
className="poracode-brain-thinking size-3 shrink-0"
64-
aria-label={t`Thinking`}
65-
/>
66-
<span
67-
ref={thinkingTextRef}
68-
className="poracode-thinking-text"
69-
data-poracode-shimmer-text={t`Thinking`}
70-
>
71-
<Trans>Thinking</Trans>
72-
</span>
73-
</div>
74-
{hasText ? <ReasoningStreamViewport text={rawText} className="pl-4" /> : null}
75-
</div>
76-
</Surface>
40+
<div className="flex w-full flex-col items-stretch justify-center py-2 pl-6 pr-3 text-[length:var(--lc-chat-font-size-meta)] text-foreground-muted">
41+
<button
42+
type="button"
43+
onClick={() => {
44+
setIsOpen((v) => !v);
45+
actions?.onContentHeightChange();
46+
}}
47+
aria-expanded={isOpen}
48+
className="group inline-flex min-w-0 max-w-full items-center gap-1.5 self-start leading-none italic opacity-80 hover:text-foreground hover:opacity-100"
49+
>
50+
<Brain
51+
ref={brainRef}
52+
className={`size-3 shrink-0 ${isStreaming ? "poracode-brain-thinking" : ""}`}
53+
{...(isStreaming ? { "aria-label": t`Thinking` } : {})}
54+
/>
55+
<span
56+
ref={thinkingTextRef}
57+
className={`shrink-0 ${isStreaming ? "poracode-thinking-text" : ""}`}
58+
{...(isStreaming ? { "data-poracode-shimmer-text": t`Thinking` } : {})}
59+
>
60+
{isStreaming ? <Trans>Thinking</Trans> : <Trans>Thought</Trans>}
61+
</span>
62+
{preview ? <span className="min-w-0 truncate opacity-70">{preview}</span> : null}
63+
<ChevronDown
64+
className={`size-3 shrink-0 opacity-100 transition-[transform,opacity] [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-hover:opacity-100 [@media(hover:hover)]:group-focus-visible:opacity-100 ${isOpen ? "rotate-180" : ""}`}
65+
/>
66+
</button>
67+
{isOpen && hasText ? (
68+
isStreaming ? (
69+
<ReasoningStreamViewport text={rawText} className="mt-2" />
70+
) : (
71+
<ReasoningExpandedBody text={rawText} className="mt-2" />
72+
)
73+
) : null}
74+
</div>
7775
);
7876
});

0 commit comments

Comments
 (0)