diff --git a/docs/superpowers/plans/2026-07-27-stepfun-credits-compact-insights-phase-1-pi-082-upgrade.md b/docs/superpowers/plans/2026-07-27-stepfun-credits-compact-insights-phase-1-pi-082-upgrade.md new file mode 100644 index 0000000..ec7b503 --- /dev/null +++ b/docs/superpowers/plans/2026-07-27-stepfun-credits-compact-insights-phase-1-pi-082-upgrade.md @@ -0,0 +1,114 @@ +# Phase 1: Pi 0.82.0 Upgrade Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Parent plan:** `docs/superpowers/plans/2026-07-27-stepfun-credits-compact-insights-refactor.md` + +**Goal:** Upgrade the project to `@earendil-works/pi-coding-agent` 0.82.0 and `@earendil-works/pi-tui` 0.82.0 without changing application behavior. + +**Architecture:** This phase changes only dependency metadata. It establishes the exact Pi API baseline used by all later provider and TUI phases. + +**Tech Stack:** pnpm, TypeScript 6, Node.js 24, Pi 0.82.0. + +**Phase dependency:** The parent design specification must be approved before execution. + +**Usable result:** The unchanged extension installs, typechecks, and passes its existing test suite against Pi 0.82.0. This phase can be released independently as a dependency-only maintenance update. + +**Out of scope:** StepFun behavior, Insights behavior, README content, screenshots, and unrelated dependency updates. + +--- + +### Task 1: Pin the Pi packages to the 0.82 line + +**Files:** +- Modify: `package.json` +- Modify: `pnpm-lock.yaml` + +- [ ] **Step 1: Record the current dependency state** + +Run: + +```sh +pnpm list @earendil-works/pi-coding-agent @earendil-works/pi-tui --depth 0 +``` + +Expected before the change: both packages report `0.80.3`. + +- [ ] **Step 2: Update both Pi development dependencies** + +Run: + +```sh +pnpm add -D '@earendil-works/pi-coding-agent@0.82.0' '@earendil-works/pi-tui@0.82.0' +``` + +Expected: `package.json` contains: + +```json +"@earendil-works/pi-coding-agent": "^0.82.0", +"@earendil-works/pi-tui": "^0.82.0" +``` + +Expected: the importer and package snapshots in `pnpm-lock.yaml` resolve both packages to exactly `0.82.0`. + +- [ ] **Step 3: Verify only intended dependency metadata changed** + +Run: + +```sh +git diff -- package.json pnpm-lock.yaml +``` + +Expected: the two Pi package specifications and their transitive lockfile entries change; no unrelated direct dependency changes appear. + +- [ ] **Step 4: Verify the installed versions** + +Run: + +```sh +pnpm list @earendil-works/pi-coding-agent @earendil-works/pi-tui --depth 0 +``` + +Expected: both direct dependencies report `0.82.0`. + +- [ ] **Step 5: Verify the existing code against Pi 0.82.0** + +Run: + +```sh +pnpm typecheck +pnpm test +``` + +Expected: both commands PASS with no TypeScript diagnostics or failed tests. Do not add compatibility shims unless the typecheck exposes a concrete 0.82.0 API change. + +- [ ] **Step 6: Commit the atomic upgrade** + +```sh +git add package.json pnpm-lock.yaml +git commit -m "chore: update Pi dependencies to 0.82.0" +``` + +--- + +### Phase verification + +- [ ] Run the project quality gate: + +```sh +pnpm check +``` + +Expected: Biome lint, TypeScript typecheck, and Vitest all PASS. + +- [ ] Verify the phase diff and commit: + +```sh +git status --short +git diff --check +git log -1 --oneline +``` + +Expected: no uncommitted Phase 1 files, no whitespace errors, and the latest commit is `chore: update Pi dependencies to 0.82.0`. + +**Stop here.** Phase 2 starts from this passing Pi 0.82.0 baseline. \ No newline at end of file diff --git a/docs/superpowers/plans/2026-07-27-stepfun-credits-compact-insights-phase-2-stepfun-browser-session.md b/docs/superpowers/plans/2026-07-27-stepfun-credits-compact-insights-phase-2-stepfun-browser-session.md new file mode 100644 index 0000000..268d612 --- /dev/null +++ b/docs/superpowers/plans/2026-07-27-stepfun-credits-compact-insights-phase-2-stepfun-browser-session.md @@ -0,0 +1,279 @@ +# Phase 2: StepFun Browser Session Migration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Parent plan:** `docs/superpowers/plans/2026-07-27-stepfun-credits-compact-insights-refactor.md` + +**Goal:** Replace the legacy StepFun `.com` username/password login with `platform.stepfun.ai` browser-session authentication while preserving legacy 5-hour and weekly usage windows. + +**Architecture:** `src/providers/stepfun.ts` will resolve one credential shape—`STEPFUN_TOKEN` plus `STEPFUN_WEB_ID`—and send it to the existing StepFun dashboard RPC paths on the `.ai` host. Credit parsing remains out of scope until Phase 3, so this phase leaves the existing legacy window normalization intact. + +**Tech Stack:** TypeScript 6, Vitest 4, Pi 0.82.0, existing provider runtime helpers. + +**Phase dependency:** Phase 1 is committed and `pnpm check` passes. + +**Usable result:** Users with a `platform.stepfun.ai` browser session can retrieve legacy 5-hour/weekly StepFun usage and plan names. Passwords are no longer accepted or stored. + +**Out of scope:** Step Plan Credit payloads, standard API balance, `.com` fallback, Insights UI, documentation, and new dependencies. + +**Credential contract:** + +- `STEPFUN_TOKEN` may be a bare token or cookie-style text containing `Oasis-Token=`. +- `STEPFUN_WEB_ID` is the raw `Oasis-WebId` cookie value. +- The exact request cookie is `Oasis-Token=; Oasis-WebId=`. +- The request header is `oasis-webid: `. + +--- + +### Task 1: Specify the browser-session behavior with failing tests + +**Files:** +- Modify: `tests/provider-stepfun.test.ts` + +- [ ] **Step 1: Remove obsolete password-login coverage** + +Delete the tests that: + +- prefer `STEPFUN_TOKEN` over username/password, +- log in with username/password, +- diagnose invalid username/password. + +Retain the plan-status failure, invalid-session, 429, and legacy-window coverage. + +- [ ] **Step 2: Add the missing-variable test** + +Add inside `describe("StepFun provider", ...)`: + +```ts +it("requires both STEPFUN_TOKEN and STEPFUN_WEB_ID", async () => { + for (const env of [ + { STEPFUN_TOKEN: "token" }, + { STEPFUN_WEB_ID: "web-id" }, + ]) { + const root = mkTmp(); + const fetchImpl = vi.fn(); + const result = await stepfunProvider( + createLiveDeps(root, () => 1_000, fetchImpl, env), + ).fetch(); + + expect(result.snapshot.diagnostic).toBe( + "Missing StepFun browser session. Set STEPFUN_TOKEN and STEPFUN_WEB_ID.", + ); + expect(fetchImpl).not.toHaveBeenCalled(); + rmSync(root, { recursive: true, force: true }); + } +}); +``` + +- [ ] **Step 3: Add the `.ai` endpoint and cookie test** + +```ts +it("uses the .ai dashboard with the matching browser Web ID", async () => { + const root = mkTmp(); + const calls: string[] = []; + const fetchImpl = vi.fn(async (url, init) => { + const textUrl = String(url); + calls.push(textUrl); + const headers = new Headers(init?.headers); + expect(headers.get("oasis-webid")).toBe("browser-web-id"); + expect(headers.get("cookie")).toBe( + "Oasis-Token=test-token; Oasis-WebId=browser-web-id", + ); + + if (textUrl.includes("QueryStepPlanRateLimit")) { + return new Response( + JSON.stringify({ + status: 1, + five_hour_usage_left_rate: 0.8, + weekly_usage_left_rate: 0.5, + five_hour_usage_reset_time: "1777528800", + weekly_usage_reset_time: "1778000000", + }), + { status: 200 }, + ); + } + if (textUrl.includes("GetStepPlanStatus")) { + return new Response( + JSON.stringify({ status: 1, subscription: { name: "Plus" } }), + { status: 200 }, + ); + } + throw new Error(`unexpected url: ${textUrl}`); + }); + + const result = await stepfunProvider( + createLiveDeps(root, () => 1_000, fetchImpl, { + STEPFUN_TOKEN: "Oasis-Token=test-token; Path=/", + STEPFUN_WEB_ID: "browser-web-id", + }), + ).fetch(); + + expect(result.snapshot.status).toBe("live"); + expect(result.snapshot.planName).toBe("Plus"); + expect(result.snapshot.windows).toEqual([ + expect.objectContaining({ key: "fiveHour", usedPercent: 20 }), + expect.objectContaining({ key: "weekly", usedPercent: 50 }), + ]); + expect(calls).toHaveLength(2); + expect(calls.every((url) => url.startsWith("https://platform.stepfun.ai"))).toBe( + true, + ); + rmSync(root, { recursive: true, force: true }); +}); +``` + +- [ ] **Step 4: Update retained test credentials and diagnostics** + +Add `STEPFUN_WEB_ID: "web-id"` to every retained StepFun test environment. Change the invalid-token assertion to: + +```ts +expect(result.snapshot.diagnostic).toBe( + "Invalid StepFun browser session. Refresh STEPFUN_TOKEN and STEPFUN_WEB_ID.", +); +``` + +- [ ] **Step 5: Run the tests and verify the new contract fails** + +```sh +pnpm test -- tests/provider-stepfun.test.ts +``` + +Expected: FAIL because the provider still accepts password auth, does not require `STEPFUN_WEB_ID`, uses `.com`, and sends a hard-coded Web ID. + +--- + +### Task 2: Implement the minimum browser-session provider + +**Files:** +- Modify: `src/providers/stepfun.ts` + +- [ ] **Step 1: Replace static host and authentication declarations** + +Set: + +```ts +const STEPFUN_BASE_URL = "https://platform.stepfun.ai"; +const STEPFUN_APP_ID = "10300"; + +interface StepFunBrowserSession { + token: string; + webId: string; +} +``` + +Delete `STEPFUN_WEB_ID`, `INVALID_STEPFUN_CREDENTIALS`, `StepFunCredentialError`, `tokenFromPayload`, `isStepFunCredentialError`, and `loginStepFun`. + +- [ ] **Step 2: Resolve only the two browser-session variables** + +Keep `cleanEnvValue` and `normalizeStepFunToken`. Replace `resolveStepFunAuth` with: + +```ts +function resolveStepFunSession( + env: NodeJS.ProcessEnv, +): StepFunBrowserSession | undefined { + const token = normalizeStepFunToken(env.STEPFUN_TOKEN); + const webId = cleanEnvValue(env.STEPFUN_WEB_ID); + return token && webId ? { token, webId } : undefined; +} +``` + +- [ ] **Step 3: Build headers from the configured Web ID** + +Replace `baseHeaders` with: + +```ts +function baseHeaders(webId: string): Record { + return { + "content-type": "application/json", + "oasis-appid": STEPFUN_APP_ID, + "oasis-platform": "web", + "oasis-webid": webId, + "user-agent": + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/147 Safari/537.36", + }; +} +``` + +- [ ] **Step 4: Pass the complete session to both dashboard RPCs** + +Change `fetchStepFunUsage` to accept `session: StepFunBrowserSession` and construct: + +```ts +const headers = { + ...baseHeaders(session.webId), + Cookie: `Oasis-Token=${session.token}; Oasis-WebId=${session.webId}`, +}; +``` + +Use this same `headers` object for `QueryStepPlanRateLimit` and `GetStepPlanStatus`. + +- [ ] **Step 5: Delete login and retry orchestration** + +At the start of `fetchLive`, resolve the session: + +```ts +const session = resolveStepFunSession(deps.env); +if (!session) { + return { + kind: "credentials" as const, + message: + "Missing StepFun browser session. Set STEPFUN_TOKEN and STEPFUN_WEB_ID.", + }; +} + +const usage = await fetchStepFunUsage(deps, session, signal); +``` + +Delete username/password login, ingress-cookie acquisition, registration, password retry, and credential-exception branches. + +Map a `credentials` result to: + +```ts +return { + kind: "credentials" as const, + message: + "Invalid StepFun browser session. Refresh STEPFUN_TOKEN and STEPFUN_WEB_ID.", +}; +``` + +Keep the existing rate-limit, error, plan-name, cache, and snapshot behavior. + +- [ ] **Step 6: Run focused checks** + +```sh +pnpm test -- tests/provider-stepfun.test.ts +pnpm typecheck +``` + +Expected: PASS. Legacy 5-hour/weekly payloads still render, plan-status failure remains non-fatal, 401/403 produces the browser-session diagnostic, and 429 preserves backoff. + +- [ ] **Step 7: Commit the atomic migration** + +```sh +git add src/providers/stepfun.ts tests/provider-stepfun.test.ts +git commit -m "fix: migrate StepFun usage to browser sessions" +``` + +--- + +### Phase verification + +- [ ] Run: + +```sh +pnpm check +``` + +Expected: PASS. + +- [ ] Inspect the diff for removed password handling and absence of `.com`: + +```sh +git grep -n 'STEPFUN_USERNAME\|STEPFUN_PASSWORD\|platform.stepfun.com' -- src tests || true +git diff --check +git status --short +``` + +Expected: no matching legacy credentials or host under `src` or `tests`, no whitespace errors, and no uncommitted Phase 2 files. + +**Stop here.** The provider is usable for `.ai` browser sessions returning legacy windows. Phase 3 adds Credit-plan payloads without changing this authentication boundary. \ No newline at end of file diff --git a/docs/superpowers/plans/2026-07-27-stepfun-credits-compact-insights-phase-3-stepfun-credits.md b/docs/superpowers/plans/2026-07-27-stepfun-credits-compact-insights-phase-3-stepfun-credits.md new file mode 100644 index 0000000..aace064 --- /dev/null +++ b/docs/superpowers/plans/2026-07-27-stepfun-credits-compact-insights-phase-3-stepfun-credits.md @@ -0,0 +1,462 @@ +# Phase 3: StepFun Step Plan Credits Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Parent plan:** `docs/superpowers/plans/2026-07-27-stepfun-credits-compact-insights-refactor.md` + +**Goal:** Normalize StepFun monthly subscription and booster Credits into one accurate `LiveUsageWindow` without showing false exhaustion. + +**Architecture:** Add two small provider-local boundaries: one exact Credit-plan classifier and one Credit-window builder. Valid complete bucket sets provide absolute totals; otherwise independent remaining-rate fields provide a percentage-only fallback. Legacy 5-hour/weekly normalization remains unchanged. + +**Tech Stack:** TypeScript 6, Vitest 4, existing `LiveUsageWindow`, `toFinite`, `parseEpochMs`, and `clampPercentRounded` helpers. + +**Phase dependency:** Phase 2 is committed and `.ai` browser-session legacy usage passes. + +**Usable result:** New Step Plan subscribers see a `Credits` bar, absolute used/total values when available, and the subscription reset. Legacy plans remain usable. + +**Out of scope:** Standard API account balance, separate booster bars, bucket-expiry UI, `.com`, password authentication, Insights UI, documentation, and new dependencies. + +## Fixed normalization contract + +- A payload is a Credit plan when `plan_family` parses to `2`. +- Without `plan_family: 2`, it is Credit-only when `plan_credit_rate_limit` is an object and all four legacy rate/reset fields are absent or numerically zero. +- Use bucket arithmetic only when every bucket is an object with finite `credit_total > 0` and finite `0 <= credit_residual <= credit_total`. +- One invalid bucket discards the entire bucket set. +- Without usable buckets, prefer a valid `subscription_credit_left_rate`; use `topup_credit_left_rate` only when the subscription fraction is absent or outside `[0, 1]`. +- Never add subscription and top-up fractions. +- Use only `subscription_credit_reset_time` for `resetAt`. Do not map `expire_at` or `next_reset_at` into the combined bar. +- A classified Credit payload with no usable buckets or rates is malformed, not 100% used. + +--- + +### Task 1: Specify weighted Credit arithmetic + +**Files:** +- Modify: `tests/provider-stepfun.test.ts` + +- [ ] **Step 1: Add the complete-bucket test** + +```ts +it("combines only a complete valid Credit bucket set", async () => { + const root = mkTmp(); + const provider = stepfunProvider( + createLiveDeps( + root, + () => 1_000, + vi.fn(async (url) => { + if (String(url).includes("QueryStepPlanRateLimit")) { + return new Response( + JSON.stringify({ + status: 1, + plan_family: "2", + plan_credit_rate_limit: { + subscription_credit_left_rate: "0.25", + subscription_credit_reset_time: "1778000000", + topup_credit_left_rate: 1, + credit_buckets: [ + { + credit_total: "400000000", + credit_residual: 100000000, + }, + { + credit_total: 100000000, + credit_residual: "100000000", + }, + ], + }, + }), + { status: 200 }, + ); + } + if (String(url).includes("GetStepPlanStatus")) { + return new Response("boom", { status: 500 }); + } + throw new Error(`unexpected url: ${String(url)}`); + }), + { STEPFUN_TOKEN: "token", STEPFUN_WEB_ID: "web-id" }, + ), + ); + + expect((await provider.fetch()).snapshot.windows).toEqual([ + { + key: "credits", + label: "Credits", + used: 300_000_000, + limit: 500_000_000, + unit: "credits", + usedPercent: 60, + resetAt: 1778000000_000, + }, + ]); + rmSync(root, { recursive: true, force: true }); +}); +``` + +- [ ] **Step 2: Add the all-or-nothing bucket fallback test** + +```ts +it("falls back to subscription rate when any Credit bucket is invalid", async () => { + const root = mkTmp(); + const provider = stepfunProvider( + createLiveDeps( + root, + () => 1_000, + vi.fn(async (url) => { + if (String(url).includes("QueryStepPlanRateLimit")) { + return new Response( + JSON.stringify({ + status: 1, + plan_family: 2, + plan_credit_rate_limit: { + subscription_credit_left_rate: 0.8, + topup_credit_left_rate: 0.5, + credit_buckets: [ + { credit_total: 100, credit_residual: 50 }, + { credit_total: 0, credit_residual: 0 }, + ], + }, + }), + { status: 200 }, + ); + } + if (String(url).includes("GetStepPlanStatus")) { + return new Response("boom", { status: 500 }); + } + throw new Error(`unexpected url: ${String(url)}`); + }), + { STEPFUN_TOKEN: "token", STEPFUN_WEB_ID: "web-id" }, + ), + ); + + expect((await provider.fetch()).snapshot.windows).toEqual([ + { + key: "credits", + label: "Credits", + unit: "credits", + usedPercent: 20, + resetAt: undefined, + }, + ]); + rmSync(root, { recursive: true, force: true }); +}); +``` + +- [ ] **Step 3: Add the ordered-rate fallback test** + +```ts +it("uses top-up rate only when subscription rate is unavailable", async () => { + const root = mkTmp(); + const provider = stepfunProvider( + createLiveDeps( + root, + () => 1_000, + vi.fn(async (url) => { + if (String(url).includes("QueryStepPlanRateLimit")) { + return new Response( + JSON.stringify({ + status: 1, + plan_family: 2, + plan_credit_rate_limit: { + subscription_credit_left_rate: 2, + topup_credit_left_rate: "0.4", + }, + }), + { status: 200 }, + ); + } + if (String(url).includes("GetStepPlanStatus")) { + return new Response("boom", { status: 500 }); + } + throw new Error(`unexpected url: ${String(url)}`); + }), + { STEPFUN_TOKEN: "token", STEPFUN_WEB_ID: "web-id" }, + ), + ); + + expect((await provider.fetch()).snapshot.windows[0]).toEqual( + expect.objectContaining({ + key: "credits", + usedPercent: 60, + resetAt: undefined, + }), + ); + rmSync(root, { recursive: true, force: true }); +}); +``` + +- [ ] **Step 4: Run the focused tests and confirm failure** + +```sh +pnpm test -- tests/provider-stepfun.test.ts +``` + +Expected: FAIL because `plan_credit_rate_limit` is not normalized. + +--- + +### Task 2: Specify Credit classification and malformed behavior + +**Files:** +- Modify: `tests/provider-stepfun.test.ts` + +- [ ] **Step 1: Add Credit-only classification without `plan_family`** + +```ts +it("recognizes a Credit-only response without plan_family", async () => { + const root = mkTmp(); + const provider = stepfunProvider( + createLiveDeps( + root, + () => 1_000, + vi.fn(async (url) => { + if (String(url).includes("QueryStepPlanRateLimit")) { + return new Response( + JSON.stringify({ + status: 1, + five_hour_usage_left_rate: 0, + weekly_usage_left_rate: "0", + five_hour_usage_reset_time: "0", + weekly_usage_reset_time: 0, + plan_credit_rate_limit: { + subscription_credit_left_rate: "0.75", + subscription_credit_reset_time: 1778000000, + }, + }), + { status: 200 }, + ); + } + if (String(url).includes("GetStepPlanStatus")) { + return new Response("boom", { status: 500 }); + } + throw new Error(`unexpected url: ${String(url)}`); + }), + { STEPFUN_TOKEN: "token", STEPFUN_WEB_ID: "web-id" }, + ), + ); + + expect((await provider.fetch()).snapshot.windows[0]).toEqual( + expect.objectContaining({ + key: "credits", + usedPercent: 25, + resetAt: 1778000000_000, + }), + ); + rmSync(root, { recursive: true, force: true }); +}); +``` + +- [ ] **Step 2: Add malformed Credit-only rejection** + +```ts +it("rejects malformed Credit-only responses instead of showing exhaustion", async () => { + const root = mkTmp(); + const provider = stepfunProvider( + createLiveDeps( + root, + () => 1_000, + vi.fn(async (url) => { + if (String(url).includes("QueryStepPlanRateLimit")) { + return new Response( + JSON.stringify({ + status: 1, + plan_family: 2, + plan_credit_rate_limit: {}, + }), + { status: 200 }, + ); + } + throw new Error(`unexpected url: ${String(url)}`); + }), + { STEPFUN_TOKEN: "token", STEPFUN_WEB_ID: "web-id" }, + ), + ); + + const result = await provider.fetch(); + expect(result.snapshot.diagnostic).toBe("StepFun response malformed."); + expect(result.snapshot.windows).toEqual([]); + rmSync(root, { recursive: true, force: true }); +}); +``` + +- [ ] **Step 3: Verify classification tests fail for the intended reason** + +```sh +pnpm test -- tests/provider-stepfun.test.ts +``` + +Expected: FAIL because zero legacy windows are still rendered as exhausted and malformed Credit data is not rejected through a Credit-specific path. + +--- + +### Task 3: Implement exact Credit classification and normalization + +**Files:** +- Modify: `src/providers/stepfun.ts` + +- [ ] **Step 1: Add the object and zero-value boundaries** + +```ts +function objectValue(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function zeroOrMissing(value: unknown): boolean { + const number = toFinite(value); + return number === undefined || number === 0; +} +``` + +- [ ] **Step 2: Add the exact classifier** + +```ts +function isCreditPlanPayload(payload: Record): boolean { + if (toFinite(payload.plan_family) === 2) return true; + if (!objectValue(payload.plan_credit_rate_limit)) return false; + return ( + zeroOrMissing(payload.five_hour_usage_left_rate) && + zeroOrMissing(payload.weekly_usage_left_rate) && + zeroOrMissing(payload.five_hour_usage_reset_time) && + zeroOrMissing(payload.weekly_usage_reset_time) + ); +} +``` + +- [ ] **Step 3: Add the complete Credit-window builder** + +```ts +function buildCreditWindow( + payload: Record, +): LiveUsageWindow | undefined { + const credit = objectValue(payload.plan_credit_rate_limit); + if (!credit) return undefined; + + const resetAt = parseEpochMs(credit.subscription_credit_reset_time); + const buckets = Array.isArray(credit.credit_buckets) + ? credit.credit_buckets + : undefined; + + if (buckets && buckets.length > 0) { + const parsed = buckets.map((bucket) => { + const row = objectValue(bucket); + const total = toFinite(row?.credit_total); + const residual = toFinite(row?.credit_residual); + if ( + total === undefined || + residual === undefined || + total <= 0 || + residual < 0 || + residual > total + ) { + return undefined; + } + return { total, residual }; + }); + const valid = parsed.filter( + (bucket): bucket is { total: number; residual: number } => + bucket !== undefined, + ); + if (valid.length === buckets.length) { + const limit = valid.reduce((sum, bucket) => sum + bucket.total, 0); + const remaining = valid.reduce( + (sum, bucket) => sum + bucket.residual, + 0, + ); + const used = limit - remaining; + return { + key: "credits", + label: "Credits", + used, + limit, + unit: "credits", + usedPercent: clampPercentRounded((used / limit) * 100), + resetAt, + }; + } + } + + const leftRate = [ + credit.subscription_credit_left_rate, + credit.topup_credit_left_rate, + ] + .map(toFinite) + .find((rate) => rate !== undefined && rate >= 0 && rate <= 1); + if (leftRate === undefined) return undefined; + + return { + key: "credits", + label: "Credits", + unit: "credits", + usedPercent: clampPercentRounded((1 - leftRate) * 100), + resetAt, + }; +} +``` + +- [ ] **Step 4: Select Credit or legacy windows once** + +In `fetchStepFunUsage`, replace the unconditional legacy window array with: + +```ts +const windows = isCreditPlanPayload(payload) + ? [buildCreditWindow(payload)].filter( + (window): window is LiveUsageWindow => window !== undefined, + ) + : [ + buildWindow( + "fiveHour", + "5h", + payload.five_hour_usage_left_rate, + payload.five_hour_usage_reset_time, + ), + buildWindow( + "weekly", + "Weekly", + payload.weekly_usage_left_rate, + payload.weekly_usage_reset_time, + ), + ].filter((window): window is LiveUsageWindow => window !== undefined); +``` + +Keep the existing `windows.length === 0` malformed-response branch and non-fatal plan-status request. + +- [ ] **Step 5: Run focused and type checks** + +```sh +pnpm test -- tests/provider-stepfun.test.ts +pnpm typecheck +``` + +Expected: PASS. Weighted buckets show 60% used and 300M/500M credits; any invalid bucket falls back to the 20% subscription usage; top-up-only fallback shows 60%; malformed Credit-only data returns no windows. + +- [ ] **Step 6: Commit the atomic Credit feature** + +```sh +git add src/providers/stepfun.ts tests/provider-stepfun.test.ts +git commit -m "feat: track StepFun Step Plan Credits" +``` + +--- + +### Phase verification + +- [ ] Run: + +```sh +pnpm check +``` + +Expected: PASS, including retained legacy StepFun tests. + +- [ ] Review provider scope: + +```sh +git diff --check +git status --short +git log -1 --oneline +``` + +Expected: no whitespace errors, no uncommitted Phase 3 files, and the latest commit is `feat: track StepFun Step Plan Credits`. + +**Stop here.** StepFun browser-session users now receive either a valid Credit bar or preserved legacy windows. Phase 4 changes only Insights UI behavior. \ No newline at end of file diff --git a/docs/superpowers/plans/2026-07-27-stepfun-credits-compact-insights-phase-4-compact-insights.md b/docs/superpowers/plans/2026-07-27-stepfun-credits-compact-insights-phase-4-compact-insights.md new file mode 100644 index 0000000..c971474 --- /dev/null +++ b/docs/superpowers/plans/2026-07-27-stepfun-credits-compact-insights-phase-4-compact-insights.md @@ -0,0 +1,443 @@ +# Phase 4: Compact Insights Categories Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Parent plan:** `docs/superpowers/plans/2026-07-27-stepfun-credits-compact-insights-refactor.md` + +**Goal:** Replace the unsupported Insights period selector with all-time category navigation that remains fully visible in Pi's overlay at the minimum supported terminal size. + +**Architecture:** Keep the existing dashboard component, frame, and tab renderer. Derive populated Insight categories in fixed order, retain one category ID as UI state, and render only that category's existing capped items. + +**Tech Stack:** TypeScript 6, Vitest 4, Pi TUI 0.82.0. + +**Phase dependency:** Phase 3 is committed and `pnpm check` passes. + +**Usable result:** Insights truthfully displays all-time data, Left/Right switches populated categories, and the footer and bottom frame remain visible at 40×24 and larger terminals. + +**Out of scope:** Offline insight calculations, category item caps, provider behavior, scrolling, new TUI components, and unrelated dashboard refactors. + +## Fixed layout contract + +- Category order: Projects, Skills, MCP servers, Cost patterns. +- Categories with no items are omitted. +- Missing `category` values retain current behavior and belong to Cost patterns. +- The first populated category is the effective default. +- If state updates remove the selected category, selection permanently falls back to the first populated category. +- Insights are all-time; Today, This Week, Last Week, and All Time controls do not render in this tab. +- At a 40×24 terminal, Pi computes overlay width `floor(40 × 0.92) = 36` and maximum height `floor(24 × 0.85) = 20`. The component must render no more than 20 lines at width 36. +- At widths 73 and 100, the component must render no more than 17 lines. + +--- + +### Task 1: Specify category selection behavior + +**Files:** +- Modify: `tests/dashboard.test.ts` + +- [ ] **Step 1: Add a shared tab-switch helper** + +Add near the dashboard tests: + +```ts +function switchToInsights(component: UsageDashboardComponent): void { + component.handleInput("\t"); + component.handleInput("\t"); +} +``` + +Use it in new and existing tests that switch from Statistics to Insights. + +- [ ] **Step 2: Replace the independent-period test** + +Delete `has independent period selector for Insights tab`. Add: + +```ts +it("shows only populated Insight categories and defaults to the first", () => { + const state = mkState(); + state.insights = [ + { category: "project", label: "pi-usage", cost: 9, detail: "90.0%" }, + { category: "cost", label: "Large context", cost: 1, detail: "10.0%" }, + ]; + const c = new UsageDashboardComponent(state, () => undefined, { + theme: noTheme, + }); + switchToInsights(c); + + const out = c.render(100).join("\n"); + expect(out).toContain("[Projects]"); + expect(out).toContain("Cost patterns"); + expect(out).not.toContain("Skills"); + expect(out).not.toContain("MCP servers"); + expect(out).toContain("pi-usage"); + expect(out).not.toContain("Large context"); + expect(out).not.toContain("Today"); + expect(out).not.toContain("This Week"); + expect(out).not.toContain("Last Week"); + expect(out).not.toContain("All Time"); +}); +``` + +- [ ] **Step 3: Add category cycling without Statistics-state coupling** + +```ts +it("cycles Insight categories without changing the Statistics period", () => { + const state = mkState(); + state.insights = [ + { category: "project", label: "pi-usage", cost: 9, detail: "90.0%" }, + { category: "cost", label: "Large context", cost: 1, detail: "10.0%" }, + ]; + const c = new UsageDashboardComponent(state, () => undefined, { + theme: noTheme, + }); + + c.handleInput("\u001b[D"); + switchToInsights(c); + c.handleInput("\u001b[C"); + let out = c.render(100).join("\n"); + expect(out).toContain("[Cost patterns]"); + expect(out).toContain("Large context"); + expect(out).not.toContain("pi-usage"); + + c.handleInput("\t"); + out = c.render(100).join("\n"); + expect(out).toContain("[Last Week]"); +}); +``` + +- [ ] **Step 4: Add durable fallback after a state update** + +```ts +it("falls back when the selected Insight category disappears", () => { + const state = mkState(); + state.insights = [ + { category: "project", label: "pi-usage", cost: 9, detail: "90.0%" }, + { category: "cost", label: "Large context", cost: 1, detail: "10.0%" }, + ]; + const c = new UsageDashboardComponent(state, () => undefined, { + theme: noTheme, + }); + switchToInsights(c); + c.handleInput("\u001b[C"); + expect(c.render(100).join("\n")).toContain("[Cost patterns]"); + + state.insights = [ + { category: "project", label: "pi-usage", cost: 9, detail: "100.0%" }, + ]; + expect(c.render(100).join("\n")).toContain("[Projects]"); + + state.insights.push({ + category: "cost", + label: "Large context", + cost: 1, + detail: "10.0%", + }); + expect(c.render(100).join("\n")).toContain("[Projects]"); +}); +``` + +The final assertion proves fallback updates the selected ID rather than temporarily substituting rendered content. + +- [ ] **Step 5: Run the dashboard tests and confirm failure** + +```sh +pnpm test -- tests/dashboard.test.ts +``` + +Expected: FAIL because Insights still renders periods and all categories simultaneously. + +--- + +### Task 2: Specify the Pi overlay height budget and footer + +**Files:** +- Modify: `tests/dashboard.test.ts` +- Modify: `tests/constants.test.ts` + +- [ ] **Step 1: Add the maximum-category line-budget test** + +```ts +it("keeps a maximum Insight category inside the supported overlay height", () => { + const state = mkState(); + state.insights = [ + ...Array.from({ length: 6 }, (_, index) => ({ + category: "project", + label: index === 5 ? "+20 more" : `project-${index + 1}`, + cost: 6 - index, + detail: `${30 - index * 4}.0%`, + })), + { category: "skill", label: "/brainstorming", cost: 1, detail: "5.0%" }, + { category: "mcp", label: "playwright", cost: 1, detail: "5.0%" }, + { category: "cost", label: "Large context", cost: 1, detail: "5.0%" }, + ]; + const c = new UsageDashboardComponent(state, () => undefined, { + theme: noTheme, + }); + switchToInsights(c); + + expect(c.render(36).length).toBeLessThanOrEqual(20); + expect(c.render(73).length).toBeLessThanOrEqual(17); + expect(c.render(100).length).toBeLessThanOrEqual(17); +}); +``` + +- [ ] **Step 2: Change dashboard footer expectations** + +In `renders context-aware footer per tab`, replace the Insights period assertion with: + +```ts +expect(stripped).toContain("[Left/Right] Category"); +``` + +- [ ] **Step 3: Change the constant test** + +In `tests/constants.test.ts`, expect: + +```ts +expect(UI_STRINGS.dashboardFooters.insights).toBe( + "[Tab/Shift-Tab] Switch tab • [Left/Right] Category • [q/Esc] Close", +); +``` + +- [ ] **Step 4: Run focused tests and confirm failure** + +```sh +pnpm test -- tests/dashboard.test.ts tests/constants.test.ts +``` + +Expected: FAIL because the current Insights view exceeds the line budget and still advertises Period navigation. + +--- + +### Task 3: Implement available category state and rendering + +**Files:** +- Modify: `src/tui/dashboard.ts` + +- [ ] **Step 1: Define category order and types** + +Add below `DASHBOARD_TABS`: + +```ts +const INSIGHT_CATEGORIES = [ + { id: "project", label: "Projects" }, + { id: "skill", label: "Skills" }, + { id: "mcp", label: "MCP servers" }, + { id: "cost", label: "Cost patterns" }, +] as const; + +type InsightCategoryId = (typeof INSIGHT_CATEGORIES)[number]["id"]; + +type AvailableInsightCategory = { + id: InsightCategoryId; + label: string; + items: UsageCoreState["insights"]; +}; +``` + +- [ ] **Step 2: Replace the unused period state** + +Replace: + +```ts +private insightsPeriodIndex = DEFAULT_PERIOD_INDEX; +``` + +with: + +```ts +private insightsCategory: InsightCategoryId = "project"; +``` + +Do not change `periodIndex`; Statistics still uses it. + +- [ ] **Step 3: Derive populated categories and durable selection** + +Add: + +```ts +private availableInsightCategories(): AvailableInsightCategory[] { + return INSIGHT_CATEGORIES.map((category) => ({ + ...category, + items: this.state.insights.filter( + (item) => (item.category ?? "cost") === category.id, + ), + })).filter((category) => category.items.length > 0); +} + +private activeInsightCategory( + categories: AvailableInsightCategory[], +): AvailableInsightCategory | undefined { + const selected = categories.find( + (category) => category.id === this.insightsCategory, + ); + if (selected) return selected; + const fallback = categories[0]; + if (fallback) this.insightsCategory = fallback.id; + return fallback; +} +``` + +- [ ] **Step 4: Replace all-category rendering with one complete renderer** + +Replace `renderInsightsByCategory` with: + +```ts +private renderInsightCategory(category: AvailableInsightCategory): string[] { + const lines: string[] = []; + if (category.id === "cost") { + lines.push(this.theme.dim(category.label)); + for (const item of category.items) { + lines.push( + this.theme.dim( + ` - ${item.label}: ${formatCurrency(item.cost)} (${item.detail})`, + ), + ); + } + return lines; + } + + const maxLabelLen = Math.max( + ...category.items.map((item) => item.label.length), + category.label.length, + ); + lines.push( + ` ${padVisible(this.theme.dim(category.label), maxLabelLen + 2, "left")} ${this.theme.dim("% of usage")}`, + ); + for (const item of category.items) { + const label = padVisible( + this.theme.dim(item.label), + maxLabelLen + 2, + "left", + ); + lines.push(` ${label} ${this.theme.dim(item.detail)}`); + } + return lines; +} +``` + +This preserves the current formatting and item caps; it only removes the outer category loop and leading blank lines. + +- [ ] **Step 5: Replace `renderInsightsTab`** + +```ts +private renderInsightsTab(w: number, lines: string[]): void { + if (this.state.insights.length === 0) { + lines.push(this.theme.dim("No insights yet.")); + return; + } + + const categories = this.availableInsightCategories(); + const active = this.activeInsightCategory(categories); + if (!active) { + lines.push(this.theme.dim("No insights yet.")); + return; + } + + lines.push( + ...this.renderTabs( + categories.map((category) => category.label), + categories.findIndex((category) => category.id === active.id), + w, + ), + ); + lines.push(""); + lines.push(...this.renderInsightCategory(active)); +} +``` + +Delete the stale period-filter comment. + +- [ ] **Step 6: Replace period input with category input** + +```ts +private handleInsightsInput(data: string): void { + const delta = matchesKey(data, Key.left) + ? -1 + : matchesKey(data, Key.right) + ? 1 + : 0; + if (!delta) return; + + const categories = this.availableInsightCategories(); + const active = this.activeInsightCategory(categories); + if (!active) return; + const index = categories.findIndex( + (category) => category.id === active.id, + ); + this.insightsCategory = + categories[(index + delta + categories.length) % categories.length].id; +} +``` + +--- + +### Task 4: Update footer and verify the compact component + +**Files:** +- Modify: `src/shared/constants.ts` +- Test: `tests/dashboard.test.ts` +- Test: `tests/constants.test.ts` + +- [ ] **Step 1: Change the Insights footer constant** + +```ts +insights: [ + "[Tab/Shift-Tab] Switch tab", + "[Left/Right] Category", + "[q/Esc] Close", +].join(" • "), +``` + +- [ ] **Step 2: Run focused tests and typecheck** + +```sh +pnpm test -- tests/dashboard.test.ts tests/constants.test.ts +pnpm typecheck +``` + +Expected: PASS. Width 36 renders at most 20 lines; widths 73 and 100 render at most 17 lines. + +- [ ] **Step 3: Commit the atomic UI change** + +```sh +git add src/tui/dashboard.ts src/shared/constants.ts tests/dashboard.test.ts tests/constants.test.ts +git commit -m "fix: keep Insights within the dashboard height" +``` + +--- + +### Phase verification + +- [ ] Run: + +```sh +pnpm check +``` + +Expected: PASS. + +- [ ] Verify at the minimum supported terminal size: + +```sh +tmux new-session -d -s pi-usage-40 -x 40 -y 24 +tmux send-keys -t pi-usage-40 "cd $(pwd) && pi -e ." Enter +sleep 3 +tmux send-keys -t pi-usage-40 "/usage" Enter +sleep 2 +tmux capture-pane -t pi-usage-40 -p +tmux kill-session -t pi-usage-40 +``` + +Expected: category tabs, selected rows, contextual footer, and bottom frame are visible; Pi has not sliced off the end of the component. + +- [ ] Verify the phase diff: + +```sh +git diff --check +git status --short +git log -1 --oneline +``` + +Expected: no whitespace errors, no uncommitted Phase 4 files, and the latest commit is `fix: keep Insights within the dashboard height`. + +**Stop here.** The compact all-time Insights UI is usable independently. Phase 5 documents both completed features and performs release-level verification. \ No newline at end of file diff --git a/docs/superpowers/plans/2026-07-27-stepfun-credits-compact-insights-phase-5-documentation-release.md b/docs/superpowers/plans/2026-07-27-stepfun-credits-compact-insights-phase-5-documentation-release.md new file mode 100644 index 0000000..886b13e --- /dev/null +++ b/docs/superpowers/plans/2026-07-27-stepfun-credits-compact-insights-phase-5-documentation-release.md @@ -0,0 +1,275 @@ +# Phase 5: Documentation and Release Verification Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Parent plan:** `docs/superpowers/plans/2026-07-27-stepfun-credits-compact-insights-refactor.md` + +**Goal:** Document the finished StepFun and Insights behavior, refresh visual evidence, and prove the complete package is releasable. + +**Architecture:** This phase changes no runtime behavior. It updates public setup and keyboard guidance, adds an Unreleased changelog entry, captures the compact UI, then runs focused, full, package, and live verification. + +**Tech Stack:** Markdown, terminal screenshot tooling, tmux, pnpm, Vitest, Pi 0.82.0. + +**Phase dependency:** Phases 1–4 are committed and `pnpm check` passes. + +**Usable result:** Users can configure StepFun safely, understand all-time category navigation, see an accurate screenshot, and install a verified package artifact. + +**Out of scope:** Runtime refactors, additional providers, standard StepFun API balance, new Insight calculations, and release version bumping. + +--- + +### Task 1: Document StepFun browser-session setup + +**Files:** +- Modify: `README.md` + +- [ ] **Step 1: Replace the StepFun setup block** + +Replace the current `#### StepFun` section with: + +````markdown +#### StepFun + +Pi Usage reads Step Plan Credits from your logged-in StepFun Platform browser session. + +1. Sign in at [platform.stepfun.ai](https://platform.stepfun.ai/). +2. Open browser DevTools → **Application** → **Storage** → **Cookies** → `https://platform.stepfun.ai`. +3. Copy the `Oasis-Token` and `Oasis-WebId` cookie values. +4. Export them before starting Pi: + +```sh +export STEPFUN_TOKEN='your-oasis-token' +export STEPFUN_WEB_ID='your-oasis-web-id' +``` + +Both values are secrets. Do not commit or share them. When the browser session expires, copy and export fresh cookie values. +```` + +- [ ] **Step 2: Remove obsolete credential guidance** + +Run: + +```sh +git grep -n 'STEPFUN_USERNAME\|STEPFUN_PASSWORD\|platform.stepfun.com' -- README.md +``` + +Expected: no matches. + +- [ ] **Step 3: Check the rendered Markdown structure** + +Inspect the edited section and confirm the ordered list continues through step 4, the shell block is nested under step 4, and the secret warning renders as prose rather than code. + +--- + +### Task 2: Document compact all-time Insights + +**Files:** +- Modify: `README.md` + +- [ ] **Step 1: Replace the Insights description** + +Use: + +```markdown +### Insights + +![Insights tab](docs/assets/insights.png) + +Shows all-time breakdowns from local Pi session history. Left/Right switches between the available `Projects`, `Skills`, `MCP servers`, and `Cost patterns` categories. Only categories with data appear, and each category keeps its capped list plus overflow summary. +``` + +- [ ] **Step 2: Update the keyboard shortcut** + +Under `Insights tab`, use: + +```markdown +- `[Left/Right]` switch category. +``` + +- [ ] **Step 3: Remove period-selector claims** + +Run: + +```sh +git grep -n 'Insights period\|independent.*period' -- README.md +``` + +Expected: no matches. + +--- + +### Task 3: Record the release-facing changes + +**Files:** +- Modify: `CHANGELOG.md` + +- [ ] **Step 1: Add one Unreleased section above 0.6.0** + +Insert: + +```markdown +## [Unreleased] + +### Changed + +- Updated `@earendil-works/pi-coding-agent` and `@earendil-works/pi-tui` to 0.82.0. +- Migrated StepFun Step Plan tracking to `platform.stepfun.ai` browser-session credentials and monthly Credit usage. +- Replaced the unsupported Insights period selector with compact all-time category navigation. + +### Removed + +- StepFun username/password login and legacy `.com` dashboard requests. +``` + +- [ ] **Step 2: Verify no duplicate Unreleased section exists** + +Run: + +```sh +grep -n '^## \[Unreleased\]' CHANGELOG.md +``` + +Expected: exactly one matching line. + +--- + +### Task 4: Produce visual and live behavior evidence + +**Files:** +- Modify: `docs/assets/insights.png` + +- [ ] **Step 1: Verify Insights at 40×24** + +```sh +tmux new-session -d -s pi-usage-40 -x 40 -y 24 +tmux send-keys -t pi-usage-40 "cd $(pwd) && pi -e ." Enter +sleep 3 +tmux send-keys -t pi-usage-40 "/usage" Enter +sleep 2 +tmux capture-pane -t pi-usage-40 -p +tmux kill-session -t pi-usage-40 +``` + +Expected: category tabs, selected rows, contextual footer, and bottom frame are visible. + +- [ ] **Step 2: Verify Insights at 80×24** + +```sh +tmux new-session -d -s pi-usage-80 -x 80 -y 24 +tmux send-keys -t pi-usage-80 "cd $(pwd) && pi -e ." Enter +sleep 3 +tmux send-keys -t pi-usage-80 "/usage" Enter +sleep 2 +tmux capture-pane -t pi-usage-80 -p +tmux kill-session -t pi-usage-80 +``` + +Expected: all available category tabs fit, one category renders, and the footer and bottom frame remain visible. + +- [ ] **Step 3: Verify a real StepFun Credit response** + +Start Pi with local `STEPFUN_TOKEN` and `STEPFUN_WEB_ID` values, run `/usage:refresh`, and open Current Usage. + +Expected: + +- one `Credits` bar, +- correct plan name when `GetStepPlanStatus` succeeds, +- absolute used/total Credits when every bucket is valid, +- a subscription reset only when `subscription_credit_reset_time` is present, +- no token or Web ID in diagnostics, terminal capture, or logs. + +- [ ] **Step 4: Refresh the Insights screenshot** + +At a normal terminal size, open `/usage`, switch to Insights, and select a representative populated category. Replace `docs/assets/insights.png` with a screenshot that shows: + +- the main Insights tab selected, +- populated category tabs, +- one selected all-time category, +- the contextual Category footer, +- the complete bottom frame. + +Before saving, verify the image contains no credentials, usernames, private project names, shell prompts, or unrelated windows. + +--- + +### Task 5: Commit public documentation + +**Files:** +- Modify: `README.md` +- Modify: `CHANGELOG.md` +- Modify: `docs/assets/insights.png` + +- [ ] **Step 1: Review the documentation diff** + +```sh +git diff -- README.md CHANGELOG.md docs/assets/insights.png +git diff --check +``` + +Expected: setup instructions match the implemented environment variables, keyboard guidance says Category, the changelog has one Unreleased section, and no whitespace errors appear. + +- [ ] **Step 2: Commit documentation and visual evidence** + +```sh +git add README.md CHANGELOG.md docs/assets/insights.png +git commit -m "docs: update StepFun and Insights guidance" +``` + +--- + +### Task 6: Run release-level verification + +**Files:** +- Verify all files changed by Phases 1–5. + +- [ ] **Step 1: Run focused regression tests** + +```sh +pnpm test -- tests/provider-stepfun.test.ts tests/dashboard.test.ts tests/constants.test.ts +``` + +Expected: PASS with no failed tests. + +- [ ] **Step 2: Run the complete quality gate** + +```sh +pnpm check +``` + +Expected: Biome lint, TypeScript typecheck, and the complete Vitest suite all PASS. + +- [ ] **Step 3: Verify package contents** + +```sh +pnpm pack:dry-run +``` + +Expected: PASS. The package contains `src`, `docs/assets`, `README.md`, `CHANGELOG.md`, and `LICENSE`, with no environment files, cookies, or credentials. + +- [ ] **Step 4: Verify the final repository state** + +```sh +git status --short +git diff --check +git log -5 --oneline +``` + +Expected: + +- no whitespace errors, +- no uncommitted implementation or documentation files, +- separate commits for Pi 0.82.0, StepFun browser sessions, StepFun Credits, compact Insights, and documentation. + +- [ ] **Step 5: Record any unavailable manual evidence** + +If real StepFun credentials or screenshot tooling were unavailable, do not claim those checks passed. State exactly which manual check remains and keep release status blocked until the user supplies or explicitly waives that evidence. + +## Research sources + +- [Step Plan overview](https://platform.stepfun.ai/docs/en/step-plan/overview) +- [StepFun account API](https://platform.stepfun.ai/docs/en/api-reference/accounts/get) +- [CodexBar StepFun provider notes](https://github.com/steipete/CodexBar/blob/main/docs/stepfun.md) +- [StepFun `.ai` dashboard integration reference](https://github.com/pi-vault/notBlubbll-Stepfun2Opencode/blob/main/AGENTS.md) +- Pi 0.82.0 overlay clipping behavior: `/Users/lanh/Developer/pi-packages/pi/packages/tui/src/tui.ts` + +**Stop here.** The complete refactor is documented, visually checked, package-verified, and ready for branch integration. \ No newline at end of file diff --git a/docs/superpowers/plans/2026-07-27-stepfun-credits-compact-insights-refactor.md b/docs/superpowers/plans/2026-07-27-stepfun-credits-compact-insights-refactor.md new file mode 100644 index 0000000..6280111 --- /dev/null +++ b/docs/superpowers/plans/2026-07-27-stepfun-credits-compact-insights-refactor.md @@ -0,0 +1,1134 @@ +# StepFun Credits and Compact Insights Refactor Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Track StepFun Step Plan Credits through `platform.stepfun.ai` browser-session credentials and keep the all-time Insights overlay within Pi's visible height by showing one category at a time. + +**Architecture:** Keep both changes inside the existing provider and dashboard boundaries. The StepFun adapter will resolve one browser-session credential shape, classify Credit payloads with an explicit predicate, and normalize them into the existing `LiveUsageWindow` type. The dashboard will reuse its current tab renderer for available Insight categories and retain the existing overlay and frame components. + +**Tech Stack:** TypeScript 6, Node.js 24, Vitest 4, pnpm, `@earendil-works/pi-coding-agent` 0.82.0, and `@earendil-works/pi-tui` 0.82.0. + +**Execution prerequisite:** Do not execute this plan until `docs/superpowers/specs/2026-07-27-stepfun-insights-refactor-design.md` has been written, committed, and approved by the user. + +--- + +## File map + +- Modify `package.json` and `pnpm-lock.yaml` to update the Pi packages to 0.82.0. +- Modify `src/providers/stepfun.ts` to own StepFun browser-session resolution, `.ai` RPC requests, Credit-plan classification, and Credit-window normalization. +- Modify `tests/provider-stepfun.test.ts` to cover authentication, endpoint selection, Credit arithmetic, legacy windows, and provider errors. +- Modify `src/tui/dashboard.ts` to own available Insight categories, selected-category navigation, and compact category rendering. +- Modify `src/shared/constants.ts` to update the Insights footer shortcut. +- Modify `tests/dashboard.test.ts` and `tests/constants.test.ts` to cover category behavior and the overlay line budget. +- Modify `README.md`, `CHANGELOG.md`, and `docs/assets/insights.png` to document and show the new behavior. + +## Fixed behavioral decisions + +- A payload is a Credit plan when `plan_family` parses to `2`, or when `plan_credit_rate_limit` is an object and all four legacy rate/reset fields are absent or numerically zero. +- Absolute bucket totals are used only when every bucket is an object with finite `credit_total > 0` and finite `0 <= credit_residual <= credit_total`. One invalid bucket invalidates the entire bucket set. +- If bucket totals cannot be used, select the first valid fraction from `subscription_credit_left_rate`, then `topup_credit_left_rate`. A valid fraction is finite and within `[0, 1]`. Never add the two fractions. +- `subscription_credit_reset_time` is the only Credit reset shown. Bucket `expire_at` and `next_reset_at` are intentionally ignored because a combined bar can contain buckets with different lifecycle events. A top-up-only fallback has no reset unless the subscription reset field is present. +- The exact browser cookie names are `Oasis-Token` and `Oasis-WebId`. The request header remains lowercase `oasis-webid`, because HTTP header names are case-insensitive. +- The minimum supported dashboard terminal for this change is 40 columns by 24 rows. With `width: "92%"` and `maxHeight: "85%"`, Pi renders the component at 36 columns and clips after 20 rows. The compact Insights view must therefore render at most 20 rows at width 36, at most 17 rows at width 73, and at most 17 rows at width 100. + +--- + +### Task 1: Update Pi dependencies to 0.82.0 + +**Files:** + +- Modify: `package.json` +- Modify: `pnpm-lock.yaml` + +- [ ] **Step 1: Update both Pi development dependencies** + +Run: + +```sh +pnpm add -D '@earendil-works/pi-coding-agent@0.82.0' '@earendil-works/pi-tui@0.82.0' +``` + +Expected: `package.json` contains: + +```json +"@earendil-works/pi-coding-agent": "^0.82.0", +"@earendil-works/pi-tui": "^0.82.0" +``` + +Expected: the importer and package snapshots in `pnpm-lock.yaml` resolve both packages to `0.82.0`. + +- [ ] **Step 2: Verify the installed versions** + +Run: + +```sh +pnpm list @earendil-works/pi-coding-agent @earendil-works/pi-tui --depth 0 +``` + +Expected: both direct dependencies report `0.82.0`. + +- [ ] **Step 3: Verify existing code against the upgraded Pi types** + +Run: + +```sh +pnpm typecheck +``` + +Expected: PASS with no TypeScript diagnostics. Do not add compatibility shims unless this command exposes a concrete 0.82.0 API change. + +- [ ] **Step 4: Commit the dependency update** + +```sh +git add package.json pnpm-lock.yaml +git commit -m "chore: update Pi dependencies to 0.82.0" +``` + +--- + +### Task 2: Replace legacy StepFun login with `.ai` browser-session authentication + +**Files:** + +- Modify: `tests/provider-stepfun.test.ts` +- Modify: `src/providers/stepfun.ts` + +- [ ] **Step 1: Replace password-login tests with failing browser-session tests** + +Remove the tests that prefer token over username/password, log in with username/password, and diagnose invalid username/password. Add these tests inside the existing `describe("StepFun provider", ...)` block: + +```ts +it("requires both STEPFUN_TOKEN and STEPFUN_WEB_ID", async () => { + for (const env of [ + { STEPFUN_TOKEN: "token" }, + { STEPFUN_WEB_ID: "web-id" }, + ]) { + const root = mkTmp(); + const fetchImpl = vi.fn(); + const result = await stepfunProvider( + createLiveDeps(root, () => 1_000, fetchImpl, env), + ).fetch(); + + expect(result.snapshot.diagnostic).toBe( + "Missing StepFun browser session. Set STEPFUN_TOKEN and STEPFUN_WEB_ID.", + ); + expect(fetchImpl).not.toHaveBeenCalled(); + rmSync(root, { recursive: true, force: true }); + } +}); + +it("uses the .ai dashboard with the matching browser Web ID", async () => { + const root = mkTmp(); + const calls: string[] = []; + const fetchImpl = vi.fn(async (url, init) => { + const textUrl = String(url); + calls.push(textUrl); + const headers = new Headers(init?.headers); + expect(headers.get("oasis-webid")).toBe("browser-web-id"); + expect(headers.get("cookie")).toBe( + "Oasis-Token=test-token; Oasis-WebId=browser-web-id", + ); + + if (textUrl.includes("QueryStepPlanRateLimit")) { + return new Response( + JSON.stringify({ + status: 1, + five_hour_usage_left_rate: 0.8, + weekly_usage_left_rate: 0.5, + five_hour_usage_reset_time: "1777528800", + weekly_usage_reset_time: "1778000000", + }), + { status: 200 }, + ); + } + if (textUrl.includes("GetStepPlanStatus")) { + return new Response( + JSON.stringify({ status: 1, subscription: { name: "Plus" } }), + { status: 200 }, + ); + } + throw new Error(`unexpected url: ${textUrl}`); + }); + + const result = await stepfunProvider( + createLiveDeps(root, () => 1_000, fetchImpl, { + STEPFUN_TOKEN: "Oasis-Token=test-token; Path=/", + STEPFUN_WEB_ID: "browser-web-id", + }), + ).fetch(); + + expect(result.snapshot.status).toBe("live"); + expect(result.snapshot.planName).toBe("Plus"); + expect(calls).toHaveLength(2); + expect( + calls.every((url) => url.startsWith("https://platform.stepfun.ai")), + ).toBe(true); + rmSync(root, { recursive: true, force: true }); +}); +``` + +Update every retained StepFun test environment to include `STEPFUN_WEB_ID: "web-id"`. In the retained invalid-token test, replace the old diagnostic assertion with: + +```ts +expect(result.snapshot.diagnostic).toBe( + "Invalid StepFun browser session. Refresh STEPFUN_TOKEN and STEPFUN_WEB_ID.", +); +``` + +- [ ] **Step 2: Run the focused tests and verify they fail** + +Run: + +```sh +pnpm test -- tests/provider-stepfun.test.ts +``` + +Expected: FAIL because the provider still accepts password auth, does not require `STEPFUN_WEB_ID`, uses `.com`, and sends the hard-coded Web ID. + +- [ ] **Step 3: Replace the legacy auth helpers with the browser-session resolver** + +In `src/providers/stepfun.ts`, change the base URL and replace `resolveStepFunAuth`, `baseHeaders`, `StepFunCredentialError`, `tokenFromPayload`, `isStepFunCredentialError`, and `loginStepFun` with this credential boundary: + +```ts +const STEPFUN_BASE_URL = "https://platform.stepfun.ai"; +const STEPFUN_APP_ID = "10300"; + +interface StepFunBrowserSession { + token: string; + webId: string; +} + +function resolveStepFunSession( + env: NodeJS.ProcessEnv, +): StepFunBrowserSession | undefined { + const token = normalizeStepFunToken(env.STEPFUN_TOKEN); + const webId = cleanEnvValue(env.STEPFUN_WEB_ID); + return token && webId ? { token, webId } : undefined; +} + +function baseHeaders(webId: string): Record { + return { + "content-type": "application/json", + "oasis-appid": STEPFUN_APP_ID, + "oasis-platform": "web", + "oasis-webid": webId, + "user-agent": + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/147 Safari/537.36", + }; +} +``` + +Keep `cleanEnvValue` and `normalizeStepFunToken`; they continue to trim quoted and cookie-style environment values. + +Change `fetchStepFunUsage` to accept `session: StepFunBrowserSession` instead of a token and build its headers exactly as follows: + +```ts +const headers = { + ...baseHeaders(session.webId), + Cookie: `Oasis-Token=${session.token}; Oasis-WebId=${session.webId}`, +}; +``` + +Use the same `headers` object for the usage and plan-status requests. + +In `createStepFunProvider`, replace the login and retry branches with one session check and one usage call: + +```ts +const session = resolveStepFunSession(deps.env); +if (!session) { + return { + kind: "credentials" as const, + message: + "Missing StepFun browser session. Set STEPFUN_TOKEN and STEPFUN_WEB_ID.", + }; +} + +const usage = await fetchStepFunUsage(deps, session, signal); +``` + +For a `credentials` result, return: + +```ts +return { + kind: "credentials" as const, + message: + "Invalid StepFun browser session. Refresh STEPFUN_TOKEN and STEPFUN_WEB_ID.", +}; +``` + +Delete all username/password, ingress-cookie, registration, login, and password retry code. + +- [ ] **Step 4: Run the provider tests** + +Run: + +```sh +pnpm test -- tests/provider-stepfun.test.ts +``` + +Expected: PASS for browser-session auth, `.ai` URLs, legacy windows, plan-name failure, invalid sessions, and 429 backoff. + +- [ ] **Step 5: Commit the authentication migration** + +```sh +git add src/providers/stepfun.ts tests/provider-stepfun.test.ts +git commit -m "fix: migrate StepFun usage to browser sessions" +``` + +--- + +### Task 3: Normalize Step Plan Credit payloads without false exhaustion + +**Files:** + +- Modify: `tests/provider-stepfun.test.ts` +- Modify: `src/providers/stepfun.ts` + +- [ ] **Step 1: Add failing weighted-bucket and fallback tests** + +Add these tests to `tests/provider-stepfun.test.ts`: + +```ts +it("combines only a complete valid Credit bucket set", async () => { + const root = mkTmp(); + const provider = stepfunProvider( + createLiveDeps( + root, + () => 1_000, + vi.fn(async (url) => { + if (String(url).includes("QueryStepPlanRateLimit")) { + return new Response( + JSON.stringify({ + status: 1, + plan_family: "2", + plan_credit_rate_limit: { + subscription_credit_left_rate: "0.25", + subscription_credit_reset_time: "1778000000", + topup_credit_left_rate: 1, + credit_buckets: [ + { + credit_total: "400000000", + credit_residual: 100000000, + }, + { + credit_total: 100000000, + credit_residual: "100000000", + }, + ], + }, + }), + { status: 200 }, + ); + } + if (String(url).includes("GetStepPlanStatus")) { + return new Response("boom", { status: 500 }); + } + throw new Error(`unexpected url: ${String(url)}`); + }), + { STEPFUN_TOKEN: "token", STEPFUN_WEB_ID: "web-id" }, + ), + ); + + const snapshot = (await provider.fetch()).snapshot; + expect(snapshot.windows).toEqual([ + { + key: "credits", + label: "Credits", + used: 300_000_000, + limit: 500_000_000, + unit: "credits", + usedPercent: 60, + resetAt: 1778000000_000, + }, + ]); + rmSync(root, { recursive: true, force: true }); +}); + +it("falls back to subscription rate when any Credit bucket is invalid", async () => { + const root = mkTmp(); + const provider = stepfunProvider( + createLiveDeps( + root, + () => 1_000, + vi.fn(async (url) => { + if (String(url).includes("QueryStepPlanRateLimit")) { + return new Response( + JSON.stringify({ + status: 1, + plan_family: 2, + plan_credit_rate_limit: { + subscription_credit_left_rate: 0.8, + topup_credit_left_rate: 0.5, + credit_buckets: [ + { credit_total: 100, credit_residual: 50 }, + { credit_total: 0, credit_residual: 0 }, + ], + }, + }), + { status: 200 }, + ); + } + if (String(url).includes("GetStepPlanStatus")) { + return new Response("boom", { status: 500 }); + } + throw new Error(`unexpected url: ${String(url)}`); + }), + { STEPFUN_TOKEN: "token", STEPFUN_WEB_ID: "web-id" }, + ), + ); + + expect((await provider.fetch()).snapshot.windows).toEqual([ + { + key: "credits", + label: "Credits", + unit: "credits", + usedPercent: 20, + resetAt: undefined, + }, + ]); + rmSync(root, { recursive: true, force: true }); +}); + +it("uses top-up rate only when subscription rate is unavailable", async () => { + const root = mkTmp(); + const provider = stepfunProvider( + createLiveDeps( + root, + () => 1_000, + vi.fn(async (url) => { + if (String(url).includes("QueryStepPlanRateLimit")) { + return new Response( + JSON.stringify({ + status: 1, + plan_family: 2, + plan_credit_rate_limit: { + subscription_credit_left_rate: 2, + topup_credit_left_rate: "0.4", + }, + }), + { status: 200 }, + ); + } + if (String(url).includes("GetStepPlanStatus")) { + return new Response("boom", { status: 500 }); + } + throw new Error(`unexpected url: ${String(url)}`); + }), + { STEPFUN_TOKEN: "token", STEPFUN_WEB_ID: "web-id" }, + ), + ); + + expect((await provider.fetch()).snapshot.windows[0]).toEqual( + expect.objectContaining({ usedPercent: 60 }), + ); + rmSync(root, { recursive: true, force: true }); +}); +``` + +- [ ] **Step 2: Add failing classification and malformed-payload tests** + +Add two more tests: + +```ts +it("recognizes a Credit-only response without plan_family", async () => { + const root = mkTmp(); + const provider = stepfunProvider( + createLiveDeps( + root, + () => 1_000, + vi.fn(async (url) => { + if (String(url).includes("QueryStepPlanRateLimit")) { + return new Response( + JSON.stringify({ + status: 1, + five_hour_usage_left_rate: 0, + weekly_usage_left_rate: "0", + five_hour_usage_reset_time: "0", + weekly_usage_reset_time: 0, + plan_credit_rate_limit: { + subscription_credit_left_rate: "0.75", + subscription_credit_reset_time: 1778000000, + }, + }), + { status: 200 }, + ); + } + if (String(url).includes("GetStepPlanStatus")) { + return new Response("boom", { status: 500 }); + } + throw new Error(`unexpected url: ${String(url)}`); + }), + { STEPFUN_TOKEN: "token", STEPFUN_WEB_ID: "web-id" }, + ), + ); + + expect((await provider.fetch()).snapshot.windows[0]).toEqual( + expect.objectContaining({ key: "credits", usedPercent: 25 }), + ); + rmSync(root, { recursive: true, force: true }); +}); + +it("rejects malformed Credit-only responses instead of showing exhaustion", async () => { + const root = mkTmp(); + const provider = stepfunProvider( + createLiveDeps( + root, + () => 1_000, + vi.fn(async (url) => { + if (String(url).includes("QueryStepPlanRateLimit")) { + return new Response( + JSON.stringify({ + status: 1, + plan_family: 2, + plan_credit_rate_limit: {}, + }), + { status: 200 }, + ); + } + throw new Error(`unexpected url: ${String(url)}`); + }), + { STEPFUN_TOKEN: "token", STEPFUN_WEB_ID: "web-id" }, + ), + ); + + expect((await provider.fetch()).snapshot.diagnostic).toBe( + "StepFun response malformed.", + ); + rmSync(root, { recursive: true, force: true }); +}); +``` + +- [ ] **Step 3: Run the tests and verify Credit cases fail** + +Run: + +```sh +pnpm test -- tests/provider-stepfun.test.ts +``` + +Expected: FAIL because `src/providers/stepfun.ts` does not classify or normalize `plan_credit_rate_limit`. + +- [ ] **Step 4: Add exact Credit classification and normalization helpers** + +Add these helpers above `fetchStepFunUsage` in `src/providers/stepfun.ts`: + +```ts +function objectValue(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function zeroOrMissing(value: unknown): boolean { + const number = toFinite(value); + return number === undefined || number === 0; +} + +function isCreditPlanPayload(payload: Record): boolean { + if (toFinite(payload.plan_family) === 2) return true; + if (!objectValue(payload.plan_credit_rate_limit)) return false; + return ( + zeroOrMissing(payload.five_hour_usage_left_rate) && + zeroOrMissing(payload.weekly_usage_left_rate) && + zeroOrMissing(payload.five_hour_usage_reset_time) && + zeroOrMissing(payload.weekly_usage_reset_time) + ); +} + +function buildCreditWindow( + payload: Record, +): LiveUsageWindow | undefined { + const credit = objectValue(payload.plan_credit_rate_limit); + if (!credit) return undefined; + + const resetAt = parseEpochMs(credit.subscription_credit_reset_time); + const buckets = Array.isArray(credit.credit_buckets) + ? credit.credit_buckets + : undefined; + + if (buckets && buckets.length > 0) { + const parsed = buckets.map((bucket) => { + const row = objectValue(bucket); + const total = toFinite(row?.credit_total); + const residual = toFinite(row?.credit_residual); + if ( + total === undefined || + residual === undefined || + total <= 0 || + residual < 0 || + residual > total + ) { + return undefined; + } + return { total, residual }; + }); + const valid = parsed.filter( + (bucket): bucket is { total: number; residual: number } => + bucket !== undefined, + ); + if (valid.length === buckets.length) { + const limit = valid.reduce((sum, bucket) => sum + bucket.total, 0); + const remaining = valid.reduce((sum, bucket) => sum + bucket.residual, 0); + const used = limit - remaining; + return { + key: "credits", + label: "Credits", + used, + limit, + unit: "credits", + usedPercent: clampPercentRounded((used / limit) * 100), + resetAt, + }; + } + } + + const leftRate = [ + credit.subscription_credit_left_rate, + credit.topup_credit_left_rate, + ] + .map(toFinite) + .find((rate) => rate !== undefined && rate >= 0 && rate <= 1); + if (leftRate === undefined) return undefined; + + return { + key: "credits", + label: "Credits", + unit: "credits", + usedPercent: clampPercentRounded((1 - leftRate) * 100), + resetAt, + }; +} +``` + +Do not parse `expire_at` or `next_reset_at` into `resetAt`. + +- [ ] **Step 5: Select Credit or legacy normalization explicitly** + +In `fetchStepFunUsage`, replace the unconditional legacy `windows` construction with: + +```ts +const windows = isCreditPlanPayload(payload) + ? [buildCreditWindow(payload)].filter( + (window): window is LiveUsageWindow => window !== undefined, + ) + : [ + buildWindow( + "fiveHour", + "5h", + payload.five_hour_usage_left_rate, + payload.five_hour_usage_reset_time, + ), + buildWindow( + "weekly", + "Weekly", + payload.weekly_usage_left_rate, + payload.weekly_usage_reset_time, + ), + ].filter((window): window is LiveUsageWindow => window !== undefined); +``` + +Keep the existing `windows.length === 0` malformed-response branch and the non-fatal plan-status request. + +- [ ] **Step 6: Run focused and full provider checks** + +Run: + +```sh +pnpm test -- tests/provider-stepfun.test.ts +pnpm typecheck +``` + +Expected: both commands PASS. The Credit tests must show 60% used for weighted buckets, 20% used for invalid-bucket fallback, 60% used for top-up-only fallback, and a visible malformed-response diagnostic. + +- [ ] **Step 7: Commit Credit support** + +```sh +git add src/providers/stepfun.ts tests/provider-stepfun.test.ts +git commit -m "feat: track StepFun Step Plan Credits" +``` + +--- + +### Task 4: Replace the fake Insights period selector with category navigation + +**Files:** + +- Modify: `tests/dashboard.test.ts` +- Modify: `tests/constants.test.ts` +- Modify: `src/tui/dashboard.ts` +- Modify: `src/shared/constants.ts` + +- [ ] **Step 1: Replace period-selection tests with failing category tests** + +Remove the test named `has independent period selector for Insights tab`. Add a local helper near the dashboard tests: + +```ts +function switchToInsights(component: UsageDashboardComponent): void { + component.handleInput("\t"); + component.handleInput("\t"); +} +``` + +Add these tests: + +```ts +it("shows only populated Insight categories and defaults to the first", () => { + const state = mkState(); + state.insights = [ + { category: "project", label: "pi-usage", cost: 9, detail: "90.0%" }, + { category: "cost", label: "Large context", cost: 1, detail: "10.0%" }, + ]; + const c = new UsageDashboardComponent(state, () => undefined, { + theme: noTheme, + }); + switchToInsights(c); + + const out = c.render(100).join("\n"); + expect(out).toContain("[Projects]"); + expect(out).toContain("Cost patterns"); + expect(out).not.toContain("Skills"); + expect(out).not.toContain("MCP servers"); + expect(out).toContain("pi-usage"); + expect(out).not.toContain("Large context"); + expect(out).not.toContain("Today"); + expect(out).not.toContain("This Week"); + expect(out).not.toContain("Last Week"); + expect(out).not.toContain("All Time"); +}); + +it("cycles Insight categories without changing the Statistics period", () => { + const state = mkState(); + state.insights = [ + { category: "project", label: "pi-usage", cost: 9, detail: "90.0%" }, + { category: "cost", label: "Large context", cost: 1, detail: "10.0%" }, + ]; + const c = new UsageDashboardComponent(state, () => undefined, { + theme: noTheme, + }); + + c.handleInput("\u001b[D"); + switchToInsights(c); + c.handleInput("\u001b[C"); + let out = c.render(100).join("\n"); + expect(out).toContain("[Cost patterns]"); + expect(out).toContain("Large context"); + expect(out).not.toContain("pi-usage"); + + c.handleInput("\t"); + out = c.render(100).join("\n"); + expect(out).toContain("[Last Week]"); +}); + +it("falls back when the selected Insight category disappears", () => { + const state = mkState(); + state.insights = [ + { category: "project", label: "pi-usage", cost: 9, detail: "90.0%" }, + { category: "cost", label: "Large context", cost: 1, detail: "10.0%" }, + ]; + const c = new UsageDashboardComponent(state, () => undefined, { + theme: noTheme, + }); + switchToInsights(c); + c.handleInput("\u001b[C"); + expect(c.render(100).join("\n")).toContain("[Cost patterns]"); + + state.insights = [ + { category: "project", label: "pi-usage", cost: 9, detail: "100.0%" }, + ]; + expect(c.render(100).join("\n")).toContain("[Projects]"); +}); +``` + +- [ ] **Step 2: Add failing line-budget and footer tests** + +Add this dashboard test: + +```ts +it("keeps a maximum Insight category inside the supported overlay height", () => { + const state = mkState(); + state.insights = [ + ...Array.from({ length: 6 }, (_, index) => ({ + category: "project", + label: index === 5 ? "+20 more" : `project-${index + 1}`, + cost: 6 - index, + detail: `${30 - index * 4}.0%`, + })), + { category: "skill", label: "/brainstorming", cost: 1, detail: "5.0%" }, + { category: "mcp", label: "playwright", cost: 1, detail: "5.0%" }, + { category: "cost", label: "Large context", cost: 1, detail: "5.0%" }, + ]; + const c = new UsageDashboardComponent(state, () => undefined, { + theme: noTheme, + }); + switchToInsights(c); + + expect(c.render(36).length).toBeLessThanOrEqual(20); + expect(c.render(73).length).toBeLessThanOrEqual(17); + expect(c.render(100).length).toBeLessThanOrEqual(17); +}); +``` + +Change the Insights assertion in `renders context-aware footer per tab` to: + +```ts +expect(stripped).toContain("[Left/Right] Category"); +``` + +Change `tests/constants.test.ts` to expect: + +```ts +expect(UI_STRINGS.dashboardFooters.insights).toBe( + "[Tab/Shift-Tab] Switch tab • [Left/Right] Category • [q/Esc] Close", +); +``` + +- [ ] **Step 3: Run the dashboard tests and verify they fail** + +Run: + +```sh +pnpm test -- tests/dashboard.test.ts tests/constants.test.ts +``` + +Expected: FAIL because Insights still renders period tabs, all categories, and the `Period` footer. + +- [ ] **Step 4: Define the fixed category order and selected state** + +Add these definitions below `DASHBOARD_TABS` in `src/tui/dashboard.ts`: + +```ts +const INSIGHT_CATEGORIES = [ + { id: "project", label: "Projects" }, + { id: "skill", label: "Skills" }, + { id: "mcp", label: "MCP servers" }, + { id: "cost", label: "Cost patterns" }, +] as const; + +type InsightCategoryId = (typeof INSIGHT_CATEGORIES)[number]["id"]; + +type AvailableInsightCategory = { + id: InsightCategoryId; + label: string; + items: UsageCoreState["insights"]; +}; +``` + +Replace: + +```ts +private insightsPeriodIndex = DEFAULT_PERIOD_INDEX; +``` + +with: + +```ts +private insightsCategory: InsightCategoryId = "project"; +``` + +- [ ] **Step 5: Replace grouped all-category rendering with selected-category rendering** + +Replace `renderInsightsByCategory` with these complete methods: + +```ts +private availableInsightCategories(): AvailableInsightCategory[] { + return INSIGHT_CATEGORIES.map((category) => ({ + ...category, + items: this.state.insights.filter( + (item) => (item.category ?? "cost") === category.id, + ), + })).filter((category) => category.items.length > 0); +} + +private activeInsightCategory( + categories: AvailableInsightCategory[], +): AvailableInsightCategory | undefined { + const selected = categories.find( + (category) => category.id === this.insightsCategory, + ); + if (selected) return selected; + const fallback = categories[0]; + if (fallback) this.insightsCategory = fallback.id; + return fallback; +} + +private renderInsightCategory(category: AvailableInsightCategory): string[] { + const lines: string[] = []; + if (category.id === "cost") { + lines.push(this.theme.dim(category.label)); + for (const item of category.items) { + lines.push( + this.theme.dim( + ` - ${item.label}: ${formatCurrency(item.cost)} (${item.detail})`, + ), + ); + } + return lines; + } + + const maxLabelLen = Math.max( + ...category.items.map((item) => item.label.length), + category.label.length, + ); + lines.push( + ` ${padVisible(this.theme.dim(category.label), maxLabelLen + 2, "left")} ${this.theme.dim("% of usage")}`, + ); + for (const item of category.items) { + const label = padVisible( + this.theme.dim(item.label), + maxLabelLen + 2, + "left", + ); + lines.push(` ${label} ${this.theme.dim(item.detail)}`); + } + return lines; +} +``` + +Replace `renderInsightsTab` with: + +```ts +private renderInsightsTab(w: number, lines: string[]): void { + if (this.state.insights.length === 0) { + lines.push(this.theme.dim("No insights yet.")); + return; + } + + const categories = this.availableInsightCategories(); + const active = this.activeInsightCategory(categories); + if (!active) { + lines.push(this.theme.dim("No insights yet.")); + return; + } + + lines.push( + ...this.renderTabs( + categories.map((category) => category.label), + categories.findIndex((category) => category.id === active.id), + w, + ), + ); + lines.push(""); + lines.push(...this.renderInsightCategory(active)); +} +``` + +This intentionally removes the unused Insights period selector and its stale period-filter comment. + +- [ ] **Step 6: Replace Insights period input with category input** + +Replace `handleInsightsInput` with: + +```ts +private handleInsightsInput(data: string): void { + const delta = matchesKey(data, Key.left) + ? -1 + : matchesKey(data, Key.right) + ? 1 + : 0; + if (!delta) return; + + const categories = this.availableInsightCategories(); + const active = this.activeInsightCategory(categories); + if (!active) return; + const index = categories.findIndex( + (category) => category.id === active.id, + ); + this.insightsCategory = + categories[(index + delta + categories.length) % categories.length].id; +} +``` + +Change the Insights footer in `src/shared/constants.ts` to: + +```ts +insights: [ + "[Tab/Shift-Tab] Switch tab", + "[Left/Right] Category", + "[q/Esc] Close", +].join(" • "), +``` + +- [ ] **Step 7: Run the focused dashboard checks** + +Run: + +```sh +pnpm test -- tests/dashboard.test.ts tests/constants.test.ts +pnpm typecheck +``` + +Expected: PASS. At width 36 the rendered frame must be at most 20 lines; at widths 73 and 100 it must be at most 17 lines. + +- [ ] **Step 8: Commit the compact Insights UI** + +```sh +git add src/tui/dashboard.ts src/shared/constants.ts tests/dashboard.test.ts tests/constants.test.ts +git commit -m "fix: keep Insights within the dashboard height" +``` + +--- + +### Task 5: Update user documentation and visual evidence + +**Files:** + +- Modify: `README.md` +- Modify: `CHANGELOG.md` +- Modify: `docs/assets/insights.png` + +- [ ] **Step 1: Replace the StepFun setup instructions** + +Replace the current StepFun provider setup block in `README.md` with: + +````markdown +#### StepFun + +Pi Usage reads Step Plan Credits from your logged-in StepFun Platform browser session. + +1. Sign in at [platform.stepfun.ai](https://platform.stepfun.ai/). +2. Open browser DevTools → **Application** → **Storage** → **Cookies** → `https://platform.stepfun.ai`. +3. Copy the `Oasis-Token` and `Oasis-WebId` cookie values. +4. Export them before starting Pi: + +```sh +export STEPFUN_TOKEN='your-oasis-token' +export STEPFUN_WEB_ID='your-oasis-web-id' +``` + +Both values are secrets. Do not commit or share them. When the browser session expires, copy and export fresh cookie values. +```` + +- [ ] **Step 2: Update the Insights description and keyboard documentation** + +Replace the Insights description with text that states: + +```markdown +### Insights + +![Insights tab](docs/assets/insights.png) + +Shows all-time breakdowns from local Pi session history. Left/Right switches between the available `Projects`, `Skills`, `MCP servers`, and `Cost patterns` categories. Only categories with data appear, and each category keeps its capped list plus overflow summary. +``` + +Change the Insights keyboard shortcut entry to: + +```markdown +- `[Left/Right]` switch category. +``` + +Remove every README statement that describes an independent Insights period selector. + +- [ ] **Step 3: Add an Unreleased changelog entry** + +Insert this section above `## [0.6.0]` in `CHANGELOG.md`: + +```markdown +## [Unreleased] + +### Changed + +- Updated `@earendil-works/pi-coding-agent` and `@earendil-works/pi-tui` to 0.82.0. +- Migrated StepFun Step Plan tracking to `platform.stepfun.ai` browser-session credentials and monthly Credit usage. +- Replaced the unsupported Insights period selector with compact all-time category navigation. + +### Removed + +- StepFun username/password login and legacy `.com` dashboard requests. +``` + +- [ ] **Step 4: Verify the overlay in Pi at the minimum and normal terminal sizes** + +Run the extension in a controlled 40×24 terminal: + +```sh +tmux new-session -d -s pi-usage-40 -x 40 -y 24 +tmux send-keys -t pi-usage-40 "cd $(pwd) && pi -e ." Enter +sleep 3 +tmux send-keys -t pi-usage-40 "/usage" Enter +sleep 2 +tmux capture-pane -t pi-usage-40 -p +tmux kill-session -t pi-usage-40 +``` + +Expected: the Insights category tabs, selected category rows, and footer are all present without Pi slicing away the bottom frame. + +Repeat at 80×24: + +```sh +tmux new-session -d -s pi-usage-80 -x 80 -y 24 +tmux send-keys -t pi-usage-80 "cd $(pwd) && pi -e ." Enter +sleep 3 +tmux send-keys -t pi-usage-80 "/usage" Enter +sleep 2 +tmux capture-pane -t pi-usage-80 -p +tmux kill-session -t pi-usage-80 +``` + +Expected: all available category tabs fit, one category renders, and the footer and bottom border remain visible. + +- [ ] **Step 5: Verify StepFun with a real redacted browser session** + +Start Pi with `STEPFUN_TOKEN` and `STEPFUN_WEB_ID` set locally, run `/usage:refresh`, and inspect Current Usage. + +Expected: StepFun shows one `Credits` bar, the correct plan name when available, absolute used/total Credits when all buckets are valid, and no token or Web ID in diagnostics or captured output. + +- [ ] **Step 6: Refresh the Insights screenshot** + +At a normal terminal size, open `/usage`, switch to Insights, select a representative populated category, and replace `docs/assets/insights.png` with a screenshot showing the category tabs, selected all-time category, footer, and complete frame. Confirm the screenshot contains no credentials, usernames, private project names, or terminal prompts. + +- [ ] **Step 7: Commit documentation and visual evidence** + +```sh +git add README.md CHANGELOG.md docs/assets/insights.png +git commit -m "docs: update StepFun and Insights guidance" +``` + +--- + +### Task 6: Final verification + +**Files:** + +- Verify all modified files from Tasks 1–5. + +- [ ] **Step 1: Run focused regression tests** + +```sh +pnpm test -- tests/provider-stepfun.test.ts tests/dashboard.test.ts tests/constants.test.ts +``` + +Expected: PASS with no failed tests. + +- [ ] **Step 2: Run the complete project check** + +```sh +pnpm check +``` + +Expected: Biome lint, TypeScript typecheck, and the complete Vitest suite all PASS. + +- [ ] **Step 3: Verify the published package contents** + +```sh +pnpm pack:dry-run +``` + +Expected: PASS; package contents include `src`, `docs/assets`, `README.md`, `CHANGELOG.md`, and `LICENSE`, with no environment files or credentials. + +- [ ] **Step 4: Review the final diff** + +```sh +git status --short +git diff --check +git log -5 --oneline +``` + +Expected: no whitespace errors, no uncommitted implementation files, and separate commits for the dependency update, StepFun auth, StepFun Credits, compact Insights UI, and documentation. + +## Research sources + +- [Step Plan overview](https://platform.stepfun.ai/docs/en/step-plan/overview) +- [StepFun account API](https://platform.stepfun.ai/docs/en/api-reference/accounts/get) +- [CodexBar StepFun provider notes](https://github.com/steipete/CodexBar/blob/main/docs/stepfun.md) +- [StepFun `.ai` dashboard integration reference](https://github.com/pi-vault/notBlubbll-Stepfun2Opencode/blob/main/AGENTS.md) +- Pi 0.82.0 overlay clipping behavior: `/Users/lanh/Developer/pi-packages/pi/packages/tui/src/tui.ts`