# Status Pill「喜豆」cost 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:** 把对话页 status pill 末尾的 token 显示替换成后端 `usage.cost` 累加得到的「N 喜豆」，cost 缺失或为 0 时隐藏。

**Architecture:** 纯前端改动。后端约定在助手消息 `usage` 中加入 `cost: number`。在 `accumulateUsage` 里按轮次累加；通过 `groupTurnTokens` map 和 `statusPillTokens` prop 透传给 `renderMessageStatusPill`；pill 内部替换 token 文案为「N 喜豆」。

**Tech Stack:** TypeScript, Lit (`html`), Vitest (jsdom 环境)。

Spec: [docs/superpowers/specs/2026-05-23-status-pill-cost-design.md](../specs/2026-05-23-status-pill-cost-design.md)

## File Structure

- Modify: `ui/src/ui/chat/status-pill.ts` — 类型增加 `cost`、新增 `formatCost`、替换 token 渲染段。
- Create: `ui/src/ui/chat/status-pill.test.ts` — 新增 pill 单测。
- Modify: `ui/src/ui/chat/grouped-render.ts` — `statusPillTokens` 类型增加 `cost`，传给 pill。
- Modify: `ui/src/ui/views/chat.ts` — `groupTurnTokens` value 增加 `cost`，`accumulateUsage` 读 `usage.cost`，user/divider 处清零。

---

### Task 1: status-pill 增加 cost 字段与 formatCost

**Files:**
- Modify: `ui/src/ui/chat/status-pill.ts`
- Create: `ui/src/ui/chat/status-pill.test.ts`

- [ ] **Step 1: Write the failing tests**

Create `ui/src/ui/chat/status-pill.test.ts`:

```ts
/* @vitest-environment jsdom */

import { html, render } from "lit";
import { describe, expect, it } from "vitest";
import { renderMessageStatusPill } from "./status-pill.ts";

function renderToText(input: Parameters<typeof renderMessageStatusPill>[0]): string {
  const host = document.createElement("div");
  render(renderMessageStatusPill(input), host);
  return host.textContent ?? "";
}

describe("renderMessageStatusPill cost segment", () => {
  it("shows 喜豆 when cost > 0 and hides tokens", () => {
    const text = renderToText({
      state: "complete",
      startedAt: null,
      durationSec: 12,
      inputTokens: 1200,
      outputTokens: 800,
      model: "gpt-5",
      cost: 3.5,
    });
    expect(text).toContain("3.5 喜豆");
    expect(text).not.toContain("tokens");
  });

  it("hides the cost segment when cost is null", () => {
    const text = renderToText({
      state: "complete",
      startedAt: null,
      durationSec: 12,
      inputTokens: 1200,
      outputTokens: 800,
      model: "gpt-5",
      cost: null,
    });
    expect(text).not.toContain("喜豆");
    expect(text).not.toContain("tokens");
  });

  it("hides the cost segment when cost is 0", () => {
    const text = renderToText({
      state: "complete",
      startedAt: null,
      durationSec: 12,
      inputTokens: 1200,
      outputTokens: 800,
      model: "gpt-5",
      cost: 0,
    });
    expect(text).not.toContain("喜豆");
  });

  it("does not show cost while active", () => {
    const text = renderToText({
      state: "active",
      startedAt: Date.now(),
      durationSec: null,
      inputTokens: 0,
      outputTokens: 0,
      model: null,
      cost: 7,
    });
    expect(text).not.toContain("喜豆");
  });

  it("formats cost: <1k keeps up to 2 decimals trimmed", () => {
    expect(renderToText({
      state: "complete",
      startedAt: null,
      durationSec: 1,
      inputTokens: 0,
      outputTokens: 0,
      model: null,
      cost: 12,
    })).toContain("12 喜豆");

    expect(renderToText({
      state: "complete",
      startedAt: null,
      durationSec: 1,
      inputTokens: 0,
      outputTokens: 0,
      model: null,
      cost: 3.456,
    })).toContain("3.46 喜豆");

    expect(renderToText({
      state: "complete",
      startedAt: null,
      durationSec: 1,
      inputTokens: 0,
      outputTokens: 0,
      model: null,
      cost: 0.05,
    })).toContain("0.05 喜豆");
  });

  it("formats cost: k / M suffix for large values", () => {
    expect(renderToText({
      state: "complete",
      startedAt: null,
      durationSec: 1,
      inputTokens: 0,
      outputTokens: 0,
      model: null,
      cost: 3500,
    })).toContain("3.5k 喜豆");

    expect(renderToText({
      state: "complete",
      startedAt: null,
      durationSec: 1,
      inputTokens: 0,
      outputTokens: 0,
      model: null,
      cost: 1_200_000,
    })).toContain("1.2M 喜豆");
  });
});
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `pnpm test ui/src/ui/chat/status-pill.test.ts`
Expected: FAIL — type error on `cost` field of `MessageStatusPillInput`, and string assertions miss because pill still renders tokens.

- [ ] **Step 3: Update status-pill.ts type and renderer**

Edit `ui/src/ui/chat/status-pill.ts`:

Replace `MessageStatusPillInput` (lines 5-12):

```ts
export type MessageStatusPillInput = {
  state: MessageStatusPillState;
  startedAt: number | null;
  durationSec: number | null;
  inputTokens: number;
  outputTokens: number;
  model?: string | null;
  cost?: number | null;
};
```

Add a `formatCost` helper after `formatTokens` (around line 29):

```ts
function formatCost(n: number): string {
  if (n >= 1_000_000) {
    return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
  }
  if (n >= 1_000) {
    return `${(n / 1_000).toFixed(1).replace(/\.0$/, "")}k`;
  }
  const rounded = Math.round(n * 100) / 100;
  return Number.isInteger(rounded) ? String(rounded) : String(rounded);
}
```

In `renderMessageStatusPill`, after destructuring `cost`, drop the tokens segment and add a cost segment. Replace the block at lines 32-103:

```ts
export function renderMessageStatusPill(input: MessageStatusPillInput) {
  const { state, startedAt, durationSec, model, cost } = input;
  const isActive = state === "active";
  const elapsed = isActive
    ? startedAt != null
      ? Math.max(0, Math.floor((Date.now() - startedAt) / 1000))
      : null
    : durationSec;
  const shortModel =
    !isActive && model ? (model.includes("/") ? (model.split("/").pop() ?? model) : model) : null;
  const showCost = !isActive && typeof cost === "number" && cost > 0;

  return html`
    <div
      class="chat-status-pill ${isActive
        ? "chat-status-pill--active"
        : "chat-status-pill--complete"}"
      role="status"
      aria-live="polite"
    >
      <div class="chat-status-pill__inner">
        ${isActive
          ? html`<svg
              class="chat-status-pill__loop"
              width="22"
              height="12"
              viewBox="0 0 36 20"
              fill="none"
              aria-hidden="true"
            >
              <defs>
                <path
                  id="csp-inf"
                  d="M18 10C18 10 22 2 28 2C31.3 2 34 4.7 34 8V12C34 15.3 31.3 18 28 18C22 18 18 10 18 10C18 10 14 2 8 2C4.7 2 2 4.7 2 8V12C2 15.3 4.7 18 8 18C14 18 18 10 18 10Z"
                />
              </defs>
              <use
                href="#csp-inf"
                stroke="currentColor"
                stroke-width="2.5"
                fill="none"
                opacity="0.15"
              />
              <use
                href="#csp-inf"
                stroke="currentColor"
                stroke-width="2.5"
                stroke-linecap="round"
                fill="none"
                stroke-dasharray="40 60"
                stroke-dashoffset="0"
              >
                <animate
                  attributeName="stroke-dashoffset"
                  values="0;-100"
                  dur="2s"
                  repeatCount="indefinite"
                />
              </use>
            </svg>`
          : html`<span class="chat-status-pill__dot" aria-hidden="true"></span>`}
        <span class="chat-status-pill__label">${isActive ? "执行中" : "已完成"}</span>
        ${elapsed != null
          ? html`<span class="chat-status-pill__time">${formatTime(elapsed)}</span>`
          : nothing}
        ${showCost
          ? html`<span class="chat-status-pill__sep" aria-hidden="true">·</span>
              <span class="chat-status-pill__cost">${formatCost(cost as number)} 喜豆</span>`
          : nothing}
        ${shortModel
          ? html`<span class="chat-status-pill__sep" aria-hidden="true">·</span>
              <span class="chat-status-pill__model" title=${model ?? ""}>${shortModel}</span>`
          : nothing}
      </div>
    </div>
  `;
}
```

Note: `inputTokens` and `outputTokens` remain in the input type so existing call sites compile; the renderer simply no longer reads them. (We'll keep them in the prop shape end-to-end so other code that depends on the type doesn't break, but the pill UI ignores them now.)

- [ ] **Step 4: Run tests to verify they pass**

Run: `pnpm test ui/src/ui/chat/status-pill.test.ts`
Expected: PASS, all 6 test cases green.

- [ ] **Step 5: Commit**

```bash
git add ui/src/ui/chat/status-pill.ts ui/src/ui/chat/status-pill.test.ts
scripts/committer "feat(ui): status pill 用 cost 显示喜豆替换 token" ui/src/ui/chat/status-pill.ts ui/src/ui/chat/status-pill.test.ts
```

If `scripts/committer` isn't available in this worktree, fall back to:

```bash
git commit -m "feat(ui): status pill 用 cost 显示喜豆替换 token"
```

---

### Task 2: grouped-render 透传 cost

**Files:**
- Modify: `ui/src/ui/chat/grouped-render.ts:480`
- Modify: `ui/src/ui/chat/grouped-render.ts:585-594`

- [ ] **Step 1: Extend statusPillTokens prop type**

Edit `ui/src/ui/chat/grouped-render.ts` line 480:

```ts
    statusPillTokens?: { input: number; output: number; model: string | null; cost: number } | null;
```

- [ ] **Step 2: Pass cost to renderMessageStatusPill**

Edit `ui/src/ui/chat/grouped-render.ts` around lines 585-594, replace the `renderMessageStatusPill({...})` call with:

```ts
        ${normalizedRole === "assistant" && !opts.suppressStatusPill
          ? renderMessageStatusPill({
              state: "complete",
              startedAt: null,
              durationSec: opts.statusPillDurationSec ?? null,
              inputTokens: opts.statusPillTokens?.input ?? 0,
              outputTokens: opts.statusPillTokens?.output ?? 0,
              model: opts.statusPillTokens?.model ?? null,
              cost: opts.statusPillTokens?.cost ?? null,
            })
          : nothing}
```

- [ ] **Step 3: Typecheck the file**

Run: `pnpm tsgo --noEmit -p ui/tsconfig.json 2>&1 | head -40` (or whatever tsgo lane covers `ui/`; if unsure, run `pnpm check:changed` after staging).

Expected: no new type errors related to `statusPillTokens` or `cost`. Errors about callers not yet supplying `cost` are expected — Task 3 fixes that.

- [ ] **Step 4: Commit**

```bash
git add ui/src/ui/chat/grouped-render.ts
git commit -m "feat(ui): grouped-render 透传 statusPillTokens.cost"
```

---

### Task 3: chat 视图按轮次累加 cost

**Files:**
- Modify: `ui/src/ui/views/chat.ts:1331-1334` (`groupTurnTokens` value type)
- Modify: `ui/src/ui/views/chat.ts:1359-1408` (`accumulateUsage` + reset logic + set call)

- [ ] **Step 1: Extend groupTurnTokens value type**

Edit `ui/src/ui/views/chat.ts` lines 1331-1334:

```ts
  const groupTurnTokens = new Map<
    string,
    { input: number; output: number; model: string | null; cost: number }
  >();
```

- [ ] **Step 2: Add turnCost + accumulate + reset + populate**

Edit `ui/src/ui/views/chat.ts` lines 1358-1408. Replace the existing `let turnInput = 0; let turnOutput = 0;` block + `accumulateUsage` + reset branches with:

```ts
    let lastNonAssistantTs: number | null = null;
    let turnInput = 0;
    let turnOutput = 0;
    let turnCost = 0;
    let turnModel: string | null = null;
    const accumulateUsage = (message: unknown) => {
      const m = message as Record<string, unknown>;
      const usage = m.usage as Record<string, number> | undefined;
      if (usage) {
        turnInput += usage.input ?? usage.inputTokens ?? 0;
        turnOutput += usage.output ?? usage.outputTokens ?? 0;
        turnCost += usage.cost ?? 0;
      }
      if (typeof m.model === "string" && m.model !== "gateway-injected") {
        turnModel = m.model;
      }
    };
```

Then in the assistant branch where `groupTurnTokens.set(...)` is called (around line 1389), include `cost: turnCost`:

```ts
          groupTurnTokens.set(item.key, {
            input: turnInput,
            output: turnOutput,
            model: turnModel,
            cost: turnCost,
          });
```

In the user-message reset branch (around lines 1396-1399) and the divider branch (around lines 1404-1408), zero `turnCost` too:

```ts
          if (item.role.toLowerCase() === "user") {
            turnInput = 0;
            turnOutput = 0;
            turnCost = 0;
            turnModel = null;
          } else {
```

```ts
      } else if (item.kind === "divider") {
        lastNonAssistantTs = item.timestamp;
        turnInput = 0;
        turnOutput = 0;
        turnCost = 0;
        turnModel = null;
```

- [ ] **Step 3: Confirm the call site at L1580 still compiles**

The existing `statusPillTokens: groupTurnTokens.get(item.key) ?? null` at line 1580 already passes the whole map value through; with `cost` added to the shape it propagates automatically. No edit needed.

- [ ] **Step 4: Typecheck**

Run: `pnpm tsgo --noEmit -p ui/tsconfig.json 2>&1 | tail -30` (or repo-standard ui lane).

Expected: no new type errors. If the `ui` project doesn't have a dedicated tsgo lane, run `pnpm check:changed` instead and confirm the green output mentions `ui`.

- [ ] **Step 5: Run existing chat controller/view tests**

Run: `pnpm test ui/src/ui/controllers/chat.test.ts ui/src/ui/chat/grouped-render.test.ts ui/src/ui/chat/status-pill.test.ts`

Expected: PASS. Existing tests should not regress because the new `cost` field defaults to 0 when `usage.cost` is absent.

- [ ] **Step 6: Commit**

```bash
git add ui/src/ui/views/chat.ts
git commit -m "feat(ui): 按轮累加 usage.cost 并透传给 status pill"
```

---

### Task 4: 整链验证 + 收尾

- [ ] **Step 1: Run focused changed gate**

Run: `pnpm check:changed`

Expected: green. If sparse-missing typecheck projects show as skipped, that's acceptable per repo rules.

- [ ] **Step 2: Run full ui chat lane tests**

Run: `pnpm test ui/src/ui/chat ui/src/ui/controllers`

Expected: all green.

- [ ] **Step 3: Manual smoke (if a dev server is available)**

Start the UI dev server per repo conventions, open the chat page, send a message to an assistant whose backend already returns `usage.cost`. Confirm:
- During streaming, the pill shows loop animation (no 喜豆 segment).
- After completion, the pill shows `已完成 · {time} · {N} 喜豆 · {model}`.
- Open an older session whose history predates the `cost` field. Confirm pills show `已完成 · {time} · {model}` with no 喜豆 segment and no `tokens` text.

If no dev server is available in this worktree, report this as "manual smoke not run — needs UI dev server" rather than claiming success.

- [ ] **Step 4: No extra commit needed**

This task is verification only — no code changes.

---

## Self-Review

**Spec coverage:**
- ✅ Type extension (status-pill.ts:5-12) — Task 1 Step 3
- ✅ Render replacement (status-pill.ts:96-99) — Task 1 Step 3
- ✅ `accumulateUsage` cost summing (chat.ts:1362-1376) — Task 3 Step 2
- ✅ Reset on user/divider — Task 3 Step 2
- ✅ `groupTurnTokens` shape — Task 3 Step 1
- ✅ `statusPillTokens` shape — Task 2 Step 1
- ✅ Pass-through to pill — Task 2 Step 2
- ✅ formatCost helper — Task 1 Step 3
- ✅ Display rules (hide on null/0) — Task 1 Step 3 (`showCost` guard)
- ✅ Tests — Task 1 Step 1

**Placeholder scan:** none.

**Type consistency:** `cost` typed as `number` (required) inside `groupTurnTokens` and `statusPillTokens.cost`; `cost?: number | null` (optional) on `MessageStatusPillInput`. Propagation: `groupTurnTokens.cost = turnCost` (number) → `statusPillTokens.cost` (number) → `renderMessageStatusPill({ cost: ... })` (number | null acceptable). Consistent.
