From 5379cfecb084e513a4ec6fbbb9a31632aa6a9690 Mon Sep 17 00:00:00 2001 From: kkito Date: Mon, 8 Jun 2026 08:56:43 +0800 Subject: [PATCH 01/10] docs: add TPS/TTFT display design spec --- .../2026-06-08-tps-ttft-display-design.md | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-08-tps-ttft-display-design.md diff --git a/docs/superpowers/specs/2026-06-08-tps-ttft-display-design.md b/docs/superpowers/specs/2026-06-08-tps-ttft-display-design.md new file mode 100644 index 00000000..633fa7c8 --- /dev/null +++ b/docs/superpowers/specs/2026-06-08-tps-ttft-display-design.md @@ -0,0 +1,94 @@ +# TPS & TTFT Display for Step-Finish Info + +## Overview + +Add tokens-per-second (TPS) and time-to-first-token (TTFT) display to the step-finish info bar in assistant messages. Both metrics are computed from existing API timestamp fields (`AssistantMessage.time`, `TextPart.time.start`, `ReasoningPart.time.start`). + +## Background + +All timestamps come from the opencode server's `Date.now()`: + +| Field | Source | When set | +|---|---|---| +| `AssistantMessage.time.created` | opencode server | When LLM call is initiated | +| `AssistantMessage.time.completed` | opencode server | When LLM stream finishes | +| `TextPart.time.start` | opencode server | When first text chunk arrives | +| `ReasoningPart.time.start` | opencode server | When first reasoning chunk arrives | + +These are server wall-clock times, meaning TTFT includes network latency to the LLM provider plus the provider's internal processing time. TPS reflects the effective generation throughput as observed by the opencode server. + +## Formulas + +``` +TTFT = firstPartStart - created +genTime = completed - firstPartStart +TPS = (tokens.output + tokens.reasoning) / (genTime / 1000) +``` + +Where `firstPartStart` is the earliest `time.start` across all `text` and `reasoning` parts in the message. + +## Display + +``` +· 42 T/s · TTFT 1.2s +``` + +- Placed after `tokens` in the step-finish line +- TPS shown as integer (e.g., `42 T/s`) +- TTFT uses existing `formatDuration()` (e.g., `1.2s`, `342ms`) +- Only shown on the **last step-finish** in a message (same condition as `completedAt`/`duration`) +- Only shown when data is available (parts exist with `time.start`, `completed` is present) + +## Configuration + +Two new independent toggles in `StepFinishDisplay` (both default `true`): + +``` +ttft: boolean +tps: boolean +``` + +Settings page (Chat → Step Finish Info) adds two new rows: + +| Toggle | Label | Description | +|---|---|---| +| ttft | "TTFT" | "Show time to first token (computed from opencode API timestamps, includes LLM network overhead)" | +| tps | "Throughput" | "Show tokens per second (computed from opencode API timestamps, includes LLM network overhead)" | + +## Data Flow + +``` +AssistantMessageView (MessageRenderer.tsx) + │ + ├─ parts から 最早の time.start を抽出 → firstPartStart + ├─ info.time.created を抽出 → created + ├─ info.time.completed を抽出 → completed + │ + └─ StepFinishPartView + props: { part, duration?, firstPartStart?, created?, completed?, ... } +``` + +## Files Changed + +| File | Change | +|---|---| +| `src/store/themeStore.ts` | Add `ttft`, `tps` to `StepFinishDisplay` interface and defaults | +| `src/features/message/parts/StepFinishPartView.tsx` | Add `firstPartStart`, `created` props; compute and render TTFT/TPS | +| `src/features/message/MessageRenderer.tsx` | Extract `firstPartStart` from parts; pass `created`, `firstPartStart` to StepFinishPartView | +| `src/features/settings/components/ChatSettings.tsx` | Add TTFT/TPS toggle rows | +| `src/locales/en/settings.json` | Add `ttft`/`showTtft`, `tps`/`showTps` keys | +| `src/locales/zh-CN/settings.json` | Same in Chinese | +| `src/features/message/MessageRenderer.test.tsx` | Update mock to include new props | +| `src/features/message/parts/StepFinishPartView.test.tsx` | Add tests for TTFT/TPS display (if exists) | + +## Tests + +- `StepFinishPartView`: Test TTFT/TPS display when data present; verify hidden when data missing +- `MessageRenderer`: Ensure `firstPartStart` is correctly extracted from parts + +## Not in Scope + +- Session-level TPS aggregation +- Real-time TPS during streaming +- Tool-call-only steps (no `text`/`reasoning` parts) +- `formatUtils.ts` changes (TTFT reuses `formatDuration`) From 64c6270952485a6548371eba7b75f86e16fe20ea Mon Sep 17 00:00:00 2001 From: kkito Date: Mon, 8 Jun 2026 09:01:34 +0800 Subject: [PATCH 02/10] feat: add ttft and tps to StepFinishDisplay --- src/store/themeStore.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/store/themeStore.ts b/src/store/themeStore.ts index 483dbf71..c1cbcfbb 100644 --- a/src/store/themeStore.ts +++ b/src/store/themeStore.ts @@ -65,6 +65,7 @@ export interface CustomCSSSnippet { /** step-finish 信息栏各项显示开关 */ export interface StepFinishDisplay { tokens: boolean + tps: boolean cache: boolean cost: boolean duration: boolean @@ -72,6 +73,7 @@ export interface StepFinishDisplay { agent: boolean model: boolean completedAt: boolean + ttft: boolean } export type CompletedAtFormat = 'time' | 'dateTime' @@ -97,6 +99,7 @@ export type DiffStyle = 'markers' | 'changeBars' const DEFAULT_STEP_FINISH_DISPLAY: StepFinishDisplay = { tokens: true, + tps: true, cache: true, cost: true, duration: true, @@ -104,6 +107,7 @@ const DEFAULT_STEP_FINISH_DISPLAY: StepFinishDisplay = { agent: false, model: false, completedAt: false, + ttft: true, } const DEFAULT_COMPLETED_AT_FORMAT: CompletedAtFormat = 'time' From eaaa379c1f0dfcfbf3135df4030ca9f93de6afa2 Mon Sep 17 00:00:00 2001 From: kkito Date: Mon, 8 Jun 2026 09:03:46 +0800 Subject: [PATCH 03/10] feat: add i18n entries for TPS and TTFT settings --- src/locales/en/settings.json | 4 ++++ src/locales/zh-CN/settings.json | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/src/locales/en/settings.json b/src/locales/en/settings.json index 7b779046..64782f4b 100644 --- a/src/locales/en/settings.json +++ b/src/locales/en/settings.json @@ -114,6 +114,8 @@ "showModel": "Show model name", "tokens": "Tokens", "showTokenUsage": "Show token usage", + "tps": "Throughput", + "showTps": "Show tokens per second (computed from opencode API timestamps, includes LLM network overhead)", "cache": "Cache", "showCacheHit": "Show cache hit info", "cost": "Cost", @@ -124,6 +126,8 @@ "showTurnElapsed": "Show full turn elapsed time", "completedAt": "Completed At", "showCompletedAt": "Show message completion time", + "ttft": "TTFT", + "showTtft": "Show time to first token (computed from opencode API timestamps, includes LLM network overhead)", "completedAtFormat": "Completion Time Format", "completedAtFormatDesc": "Choose whether to show only time or full date and time", "completedAtTimeOnly": "Time Only", diff --git a/src/locales/zh-CN/settings.json b/src/locales/zh-CN/settings.json index f35af62b..30be560a 100644 --- a/src/locales/zh-CN/settings.json +++ b/src/locales/zh-CN/settings.json @@ -114,6 +114,8 @@ "showModel": "显示模型名称", "tokens": "Token", "showTokenUsage": "显示 Token 用量", + "tps": "吞吐量", + "showTps": "显示每秒 Token 数(基于 opencode API 时间戳计算,包含 LLM 网络消耗)", "cache": "Cache", "showCacheHit": "显示 Cache 命中信息", "cost": "费用", @@ -124,6 +126,8 @@ "showTurnElapsed": "显示完整轮次耗时", "completedAt": "完成时刻", "showCompletedAt": "显示消息完成时间", + "ttft": "TTFT", + "showTtft": "显示首 Token 延迟(基于 opencode API 时间戳计算,包含 LLM 网络消耗)", "completedAtFormat": "完成时刻格式", "completedAtFormatDesc": "选择只显示时间,或显示完整日期和时间", "completedAtTimeOnly": "仅时间", From 4a1b8f1521b1a50b2736ad624d6ca3158234ebed Mon Sep 17 00:00:00 2001 From: kkito Date: Mon, 8 Jun 2026 09:05:02 +0800 Subject: [PATCH 04/10] feat: add TPS and TTFT toggle rows to settings --- src/features/settings/components/ChatSettings.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/features/settings/components/ChatSettings.tsx b/src/features/settings/components/ChatSettings.tsx index c2baf288..ce4899b7 100644 --- a/src/features/settings/components/ChatSettings.tsx +++ b/src/features/settings/components/ChatSettings.tsx @@ -99,10 +99,12 @@ export function ChatSettings() { { key: 'agent', label: t('chat.agent'), desc: t('chat.showAgent') }, { key: 'model', label: t('chat.model'), desc: t('chat.showModel') }, { key: 'tokens', label: t('chat.tokens'), desc: t('chat.showTokenUsage') }, + { key: 'tps', label: t('chat.tps'), desc: t('chat.showTps') }, { key: 'cache', label: t('chat.cache'), desc: t('chat.showCacheHit') }, { key: 'cost', label: t('chat.cost'), desc: t('chat.showApiCost') }, { key: 'duration', label: t('chat.duration'), desc: t('chat.showResponseTime') }, { key: 'turnDuration', label: t('chat.totalDuration'), desc: t('chat.showTurnElapsed') }, + { key: 'ttft', label: t('chat.ttft'), desc: t('chat.showTtft') }, { key: 'completedAt', label: t('chat.completedAt'), desc: t('chat.showCompletedAt') }, ] as const ).map(({ key, label, desc }) => ( From e1e303bf3632e312ac618af26ff4b06c45b85753 Mon Sep 17 00:00:00 2001 From: kkito Date: Mon, 8 Jun 2026 09:09:20 +0800 Subject: [PATCH 05/10] feat: add TTFT and TPS display to StepFinishPartView --- .../message/parts/StepFinishPartView.test.tsx | 136 ++++++++++++++++++ .../message/parts/StepFinishPartView.tsx | 20 +++ 2 files changed, 156 insertions(+) create mode 100644 src/features/message/parts/StepFinishPartView.test.tsx diff --git a/src/features/message/parts/StepFinishPartView.test.tsx b/src/features/message/parts/StepFinishPartView.test.tsx new file mode 100644 index 00000000..ac115d19 --- /dev/null +++ b/src/features/message/parts/StepFinishPartView.test.tsx @@ -0,0 +1,136 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { StepFinishPartView } from './StepFinishPartView' +import type { StepFinishPart } from '../../../types/message' + +let mockStepFinishDisplay = { + tokens: true, + tps: true, + cache: true, + cost: true, + duration: true, + turnDuration: true, + agent: true, + model: true, + completedAt: true, + ttft: true, +} + +vi.mock('../../../hooks/useTheme', () => ({ + useTheme: () => ({ + stepFinishDisplay: mockStepFinishDisplay, + completedAtFormat: 'time' as const, + }), +})) + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, opts?: Record) => { + if (key === 'stepFinish.inputTokens') return `Input: ${opts?.input}` + if (key === 'stepFinish.outputTokens') return `Output: ${opts?.output}` + if (key === 'stepFinish.reasoningTokens') return `Reasoning: ${opts?.reasoning}` + if (key === 'stepFinish.cacheRead') return `Cache read: ${opts?.read}` + if (key === 'stepFinish.cacheWrite') return `Cache write: ${opts?.write}` + if (key === 'stepFinish.cached') return `${opts?.count} cached` + if (key === 'stepFinish.totalDuration') return `${opts?.duration} total` + if (key === 'tokens') return 'tokens' + return key + }, + }), +})) + +function createStepFinishPart(overrides?: Partial): StepFinishPart { + return { + id: 'step-finish-1', + sessionID: 'session-1', + messageID: 'msg-1', + type: 'step-finish', + reason: 'stop', + cost: 0, + tokens: { + input: 100, + output: 200, + reasoning: 50, + cache: { read: 0, write: 0 }, + }, + ...overrides, + } +} + +describe('StepFinishPartView', () => { + it('renders TPS when data is available', () => { + render( + , + ) + // created=5000, completedAt=20000, firstPartStart=6000 + // genTime = 20000 - 6000 = 14000ms = 14s + // genTokens = 200 + 50 = 250 + // tps = Math.round(250 / 14) = Math.round(17.857) = 18 + expect(screen.getByText('18 T/s')).toBeInTheDocument() + }) + + it('renders TTFT when data is available', () => { + render( + , + ) + // ttft = 6000 - 5000 = 1000ms + expect(screen.getByText(/TTFT/)).toBeInTheDocument() + expect(screen.getByText(/1\.0s/)).toBeInTheDocument() + }) + + it('does not render TPS when no firstPartStart', () => { + render( + , + ) + expect(screen.queryByText(/T\/s/)).toBeNull() + }) + + it('does not render TTFT when no firstPartStart', () => { + render( + , + ) + expect(screen.queryByText(/TTFT/)).toBeNull() + }) + + it('does not render TTFT/TPS when toggled off in settings', () => { + mockStepFinishDisplay = { + tokens: true, + tps: false, + cache: true, + cost: true, + duration: true, + turnDuration: true, + agent: true, + model: true, + completedAt: false, + ttft: false, + } + + render( + , + ) + expect(screen.queryByText(/T\/s/)).toBeNull() + expect(screen.queryByText(/TTFT/)).toBeNull() + }) +}) diff --git a/src/features/message/parts/StepFinishPartView.tsx b/src/features/message/parts/StepFinishPartView.tsx index 642937ec..011be784 100644 --- a/src/features/message/parts/StepFinishPartView.tsx +++ b/src/features/message/parts/StepFinishPartView.tsx @@ -22,6 +22,10 @@ interface StepFinishPartViewProps { modelLabel?: string /** 消息完成时间戳(毫秒),用于显示完成时刻 */ completedAt?: number + /** 消息创建时间戳(毫秒),来自 AssistantMessage.time.created */ + created?: number + /** 消息中最早 part.time.start(毫秒时间戳),用于计算 TTFT/TPS */ + firstPartStart?: number } export const StepFinishPartView = memo(function StepFinishPartView({ @@ -31,6 +35,8 @@ export const StepFinishPartView = memo(function StepFinishPartView({ agent, modelLabel, completedAt, + created, + firstPartStart, }: StepFinishPartViewProps) { const { t } = useTranslation('message') const { stepFinishDisplay: show, completedAtFormat } = useTheme() @@ -38,15 +44,23 @@ export const StepFinishPartView = memo(function StepFinishPartView({ const totalTokens = tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write const cacheHit = tokens.cache.read + // TTFT & TPS + const ttft = created != null && firstPartStart != null ? firstPartStart - created : undefined + const genTime = completedAt != null && firstPartStart != null ? completedAt - firstPartStart : undefined + const genTokens = tokens.output + tokens.reasoning + const tps = genTime != null && genTime > 0 && genTokens > 0 ? Math.round(genTokens / (genTime / 1000)) : undefined + // 所有项都关闭时不渲染 const hasAny = (show.agent && !!agent) || (show.model && !!modelLabel) || (show.tokens && totalTokens > 0) || + (show.tps && tps != null) || (show.cache && cacheHit > 0) || (show.cost && cost > 0) || (show.duration && duration != null && duration > 0) || (show.turnDuration && turnDuration != null && turnDuration > 0) || + (show.ttft && ttft != null) || (show.completedAt && completedAt != null) if (!hasAny) return null @@ -61,6 +75,9 @@ export const StepFinishPartView = memo(function StepFinishPartView({ {formatNumber(totalTokens)} {t('tokens')} )} + {show.tps && tps != null && ( + {tps} T/s + )} {show.cache && cacheHit > 0 && ( 0 && ( {t('stepFinish.totalDuration', { duration: formatDuration(turnDuration) })} )} + {show.ttft && ttft != null && ( + TTFT {formatDuration(ttft)} + )} {show.completedAt && completedAt != null && ( {formatCompletedAt(completedAt, completedAtFormat)} )} From d0e7ec923144223ce2d02b4d1edb85d52e265627 Mon Sep 17 00:00:00 2001 From: kkito Date: Mon, 8 Jun 2026 09:10:27 +0800 Subject: [PATCH 06/10] feat: extract firstPartStart and pass to StepFinishPartView --- src/features/message/MessageRenderer.tsx | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/features/message/MessageRenderer.tsx b/src/features/message/MessageRenderer.tsx index 0db992f9..9133aeff 100644 --- a/src/features/message/MessageRenderer.tsx +++ b/src/features/message/MessageRenderer.tsx @@ -424,6 +424,16 @@ const AssistantMessageView = memo(function AssistantMessageView({ const { created, completed } = info.time const duration = completed != null ? completed - created : undefined + // 最早 part.time.start,用于 TTFT/TPS 计算 + const firstPartStart = useMemo(() => { + for (const p of parts) { + if ((p.type === 'text' || p.type === 'reasoning') && p.time?.start) { + return p.time.start + } + } + return undefined + }, [parts]) + // agent / model(仅 assistant 消息) const assistantInfo = info.role === 'assistant' ? (info as AssistantMessageInfo) : null const agent = assistantInfo?.agent || undefined @@ -473,6 +483,8 @@ const AssistantMessageView = memo(function AssistantMessageView({ agent={agent} modelLabel={modelLabel} completedAt={isLastStepFinish ? completed : undefined} + created={isLastStepFinish ? created : undefined} + firstPartStart={isLastStepFinish ? firstPartStart : undefined} /> ) } @@ -495,6 +507,8 @@ const AssistantMessageView = memo(function AssistantMessageView({ agent={agent} modelLabel={modelLabel} completedAt={isLastStepFinish ? completed : undefined} + created={isLastStepFinish ? created : undefined} + firstPartStart={isLastStepFinish ? firstPartStart : undefined} /> ) case 'subtask': @@ -547,6 +561,8 @@ interface ToolGroupProps { agent?: string modelLabel?: string completedAt?: number + created?: number + firstPartStart?: number } /** 用户需要阅读/交互的工具:沉浸模式下这些工具完成后保持展开 */ @@ -565,6 +581,8 @@ const ToolGroup = memo(function ToolGroup({ agent, modelLabel, completedAt, + created, + firstPartStart, }: ToolGroupProps) { const { t } = useTranslation('message') const { descriptiveToolSteps, inlineToolRequests, immersiveMode } = useTheme() @@ -741,6 +759,8 @@ const ToolGroup = memo(function ToolGroup({ agent={agent} modelLabel={modelLabel} completedAt={completedAt} + created={created} + firstPartStart={firstPartStart} /> )} From 4d624eb52f4144cd9fc41b760dd0e46cd09d9375 Mon Sep 17 00:00:00 2001 From: kkito Date: Mon, 8 Jun 2026 09:11:59 +0800 Subject: [PATCH 07/10] docs: add TPS/TTFT implementation plan --- .../plans/2026-06-08-tps-ttft-display.md | 580 ++++++++++++++++++ 1 file changed, 580 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-08-tps-ttft-display.md diff --git a/docs/superpowers/plans/2026-06-08-tps-ttft-display.md b/docs/superpowers/plans/2026-06-08-tps-ttft-display.md new file mode 100644 index 00000000..6ffb1ddd --- /dev/null +++ b/docs/superpowers/plans/2026-06-08-tps-ttft-display.md @@ -0,0 +1,580 @@ +# TPS & TTFT Display 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:** Add tokens-per-second (TPS) and time-to-first-token (TTFT) display to step-finish info bar. + +**Architecture:** New props flow from `MessageRenderer.tsx` → `StepFinishPartView.tsx`. Two new toggles added to `StepFinishDisplay` in `themeStore.ts`. TTFT reuses existing `formatDuration()`, TPS formatted inline as integer. + +**Tech Stack:** React 19, TypeScript, Tailwind CSS, i18next, Vitest + Testing Library + +--- + +### Task 1: Add `ttft` and `tps` to StepFinishDisplay + +**Files:** +- Modify: `src/store/themeStore.ts` (interface + defaults) + +- [ ] **Step 1: Add fields to interface** + +Edit `src/store/themeStore.ts`, add `ttft` and `tps` to `StepFinishDisplay`: + +```typescript +export interface StepFinishDisplay { + tokens: boolean + tps: boolean + cache: boolean + cost: boolean + duration: boolean + turnDuration: boolean + agent: boolean + model: boolean + completedAt: boolean + ttft: boolean +} +``` + +- [ ] **Step 2: Set defaults to true** + +Edit `DEFAULT_STEP_FINISH_DISPLAY`: + +```typescript +const DEFAULT_STEP_FINISH_DISPLAY: StepFinishDisplay = { + tokens: true, + tps: true, + cache: true, + cost: true, + duration: true, + turnDuration: true, + agent: false, + model: false, + completedAt: false, + ttft: true, +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/store/themeStore.ts +git commit -m "feat: add ttft and tps to StepFinishDisplay" +``` + +--- + +### Task 2: Add i18n entries for TTFT/TPS settings + +**Files:** +- Modify: `src/locales/en/settings.json` +- Modify: `src/locales/zh-CN/settings.json` + +- [ ] **Step 1: Add English entries** + +Insert after the `showTokenUsage` entry in `src/locales/en/settings.json`: + +```json + "tps": "Throughput", + "showTps": "Show tokens per second (computed from opencode API timestamps, includes LLM network overhead)", +``` + +Insert after the `showCompletedAt` entry (near the end of the step-finish section): + +```json + "ttft": "TTFT", + "showTtft": "Show time to first token (computed from opencode API timestamps, includes LLM network overhead)", +``` + +- [ ] **Step 2: Add Chinese entries** + +Insert after the `showTokenUsage` entry in `src/locales/zh-CN/settings.json`: + +```json + "tps": "吞吐量", + "showTps": "显示每秒 Token 数(基于 opencode API 时间戳计算,包含 LLM 网络消耗)", +``` + +Insert after the `showCompletedAt` entry: + +```json + "ttft": "TTFT", + "showTtft": "显示首 Token 延迟(基于 opencode API 时间戳计算,包含 LLM 网络消耗)", +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/locales/en/settings.json src/locales/zh-CN/settings.json +git commit -m "feat: add i18n entries for TPS and TTFT settings" +``` + +--- + +### Task 3: Add TTFT/TPS toggle rows to ChatSettings + +**Files:** +- Modify: `src/features/settings/components/ChatSettings.tsx` + +- [ ] **Step 1: Add toggle rows** + +In `src/features/settings/components/ChatSettings.tsx`, find the step-finish section (around line 96-128). Add two new entries after the `tokens` entry: + +```tsx + { key: 'tps', label: t('chat.tps'), desc: t('chat.showTps') }, +``` + +After the `completedAt` entry: + +```tsx + { key: 'ttft', label: t('chat.ttft'), desc: t('chat.showTtft') }, +``` + +The full array should look like: + +```tsx + {( + [ + { key: 'agent', label: t('chat.agent'), desc: t('chat.showAgent') }, + { key: 'model', label: t('chat.model'), desc: t('chat.showModel') }, + { key: 'tokens', label: t('chat.tokens'), desc: t('chat.showTokenUsage') }, + { key: 'tps', label: t('chat.tps'), desc: t('chat.showTps') }, + { key: 'cache', label: t('chat.cache'), desc: t('chat.showCacheHit') }, + { key: 'cost', label: t('chat.cost'), desc: t('chat.showApiCost') }, + { key: 'duration', label: t('chat.duration'), desc: t('chat.showResponseTime') }, + { key: 'turnDuration', label: t('chat.totalDuration'), desc: t('chat.showTurnElapsed') }, + { key: 'ttft', label: t('chat.ttft'), desc: t('chat.showTtft') }, + { key: 'completedAt', label: t('chat.completedAt'), desc: t('chat.showCompletedAt') }, + ] as const + ).map(...) +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/features/settings/components/ChatSettings.tsx +git commit -m "feat: add TPS and TTFT toggle rows to settings" +``` + +--- + +### Task 4: Add `firstPartStart` and `created` props to StepFinishPartView, compute and render TTFT/TPS + +**Files:** +- Modify: `src/features/message/parts/StepFinishPartView.tsx` +- Create: `src/features/message/parts/StepFinishPartView.test.tsx` + +- [ ] **Step 1: Add new props and compute TTFT/TPS** + +Edit `src/features/message/parts/StepFinishPartView.tsx`: + +Add `firstPartStart` and `created` to the interface: + +```typescript +interface StepFinishPartViewProps { + part: StepFinishPart + /** 单条消息耗时(毫秒) */ + duration?: number + /** 整个回合总耗时(毫秒),从用户发送到最后一条 assistant 完成 */ + turnDuration?: number + /** agent 名称(来自消息 info) */ + agent?: string + /** model 显示名(来自消息 info) */ + modelLabel?: string + /** 消息完成时间戳(毫秒),用于显示完成时刻 */ + completedAt?: number + /** 消息创建时间戳(毫秒),来自 AssistantMessage.time.created */ + created?: number + /** 消息中最早 part.time.start(毫秒时间戳),用于计算 TTFT/TPS */ + firstPartStart?: number +} +``` + +Update the destructuring in the component: + +```typescript +export const StepFinishPartView = memo(function StepFinishPartView({ + part, + duration, + turnDuration, + agent, + modelLabel, + completedAt, + created, + firstPartStart, +}: StepFinishPartViewProps) { +``` + +Compute TTFT and TPS after the existing `cacheHit` line: + +```typescript + const totalTokens = tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write + const cacheHit = tokens.cache.read + + // TTFT & TPS — 只有最后一条 step-finish 有 completedAt 和 firstPartStart + const ttft = created != null && firstPartStart != null ? firstPartStart - created : undefined + const genTime = completedAt != null && firstPartStart != null ? completedAt - firstPartStart : undefined + const genTokens = tokens.output + tokens.reasoning + const tps = genTime != null && genTime > 0 && genTokens > 0 ? Math.round(genTokens / (genTime / 1000)) : undefined +``` + +Update `hasAny` to include the new fields: + +```typescript + const hasAny = + (show.agent && !!agent) || + (show.model && !!modelLabel) || + (show.tokens && totalTokens > 0) || + (show.tps && tps != null) || + (show.cache && cacheHit > 0) || + (show.cost && cost > 0) || + (show.duration && duration != null && duration > 0) || + (show.turnDuration && turnDuration != null && turnDuration > 0) || + (show.ttft && ttft != null) || + (show.completedAt && completedAt != null) + if (!hasAny) return null +``` + +Add TTFT and TPS display after the tokens span (after line 63): + +```tsx + {show.tokens && totalTokens > 0 && ( + + {formatNumber(totalTokens)} {t('tokens')} + + )} + {show.tps && tps != null && ( + {tps} T/s + )} +``` + +Add TTFT after turnDuration and before completedAt (around line 76-78): + +```tsx + {show.ttft && ttft != null && ( + TTFT {formatDuration(ttft)} + )} +``` + +The full render area should look like: + +```tsx + {show.agent && agent && {agent}} + {show.model && modelLabel && {modelLabel}} + {show.tokens && totalTokens > 0 && ( + + {formatNumber(totalTokens)} {t('tokens')} + + )} + {show.tps && tps != null && ( + {tps} T/s + )} + {show.cache && cacheHit > 0 && ( + + ({t('stepFinish.cached', { count: formatNumber(cacheHit) })}) + + )} + {show.cost && cost > 0 && {formatCost(cost)}} + {show.duration && duration != null && duration > 0 && {formatDuration(duration)}} + {show.turnDuration && turnDuration != null && turnDuration > 0 && ( + {t('stepFinish.totalDuration', { duration: formatDuration(turnDuration) })} + )} + {show.ttft && ttft != null && ( + TTFT {formatDuration(ttft)} + )} + {show.completedAt && completedAt != null && ( + {formatCompletedAt(completedAt, completedAtFormat)} + )} +``` + +- [ ] **Step 2: Write the failing test** + +Create `src/features/message/parts/StepFinishPartView.test.tsx`: + +```tsx +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { StepFinishPartView } from './StepFinishPartView' +import type { StepFinishPart } from '../../../types/message' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, opts?: Record) => { + if (key === 'stepFinish.inputTokens') return `Input: ${opts?.input}` + if (key === 'stepFinish.outputTokens') return `Output: ${opts?.output}` + if (key === 'stepFinish.reasoningTokens') return `Reasoning: ${opts?.reasoning}` + if (key === 'stepFinish.cacheRead') return `Cache read: ${opts?.read}` + if (key === 'stepFinish.cacheWrite') return `Cache write: ${opts?.write}` + if (key === 'stepFinish.cached') return `${opts?.count} cached` + if (key === 'stepFinish.totalDuration') return `${opts?.duration} total` + if (key === 'tokens') return 'tokens' + return key + }, + }), +})) + +vi.mock('../../../hooks/useTheme', () => ({ + useTheme: () => ({ + stepFinishDisplay: { + tokens: true, + tps: true, + cache: true, + cost: true, + duration: true, + turnDuration: true, + agent: true, + model: true, + completedAt: true, + ttft: true, + }, + completedAtFormat: 'time' as const, + }), +})) + +function createStepFinishPart(overrides?: Partial): StepFinishPart { + return { + id: 'step-finish-1', + sessionID: 'session-1', + messageID: 'msg-1', + type: 'step-finish', + reason: 'stop', + cost: 0, + tokens: { + input: 100, + output: 200, + reasoning: 50, + cache: { read: 0, write: 0 }, + }, + ...overrides, + } +} + +describe('StepFinishPartView', () => { + it('renders TPS when tps and firstPartStart are provided', () => { + render( + , + ) + // created=5000, firstPartStart=6000 → TTFT=1000ms + // completedAt=20000, firstPartStart=6000 → genTime=14000ms + // genTokens=250 (output 200 + reasoning 50) + // tps = Math.round(250 / (14000/1000)) = Math.round(17.857) = 18 + expect(screen.getByText('18 T/s')).toBeInTheDocument() + }) + + it('renders TTFT when ttft and firstPartStart are provided', () => { + render( + , + ) + expect(screen.getByText(/TTFT/)).toBeInTheDocument() + expect(screen.getByText(/1s/)).toBeInTheDocument() + }) + + it('does not render TPS when no firstPartStart', () => { + render( + , + ) + expect(screen.queryByText(/T\/s/)).toBeNull() + }) + + it('does not render TTFT when no firstPartStart', () => { + render( + , + ) + expect(screen.queryByText(/TTFT/)).toBeNull() + }) + + it('does not render TTFT/TPS when toggled off in settings', () => { + vi.mocked(require('../../../hooks/useTheme').useTheme).mockReturnValue({ + stepFinishDisplay: { + tokens: true, + tps: false, + cache: true, + cost: true, + duration: true, + turnDuration: true, + agent: true, + model: true, + completedAt: false, + ttft: false, + }, + completedAtFormat: 'time' as const, + }) + + render( + , + ) + expect(screen.queryByText(/T\/s/)).toBeNull() + expect(screen.queryByText(/TTFT/)).toBeNull() + }) +}) +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `npx vitest run src/features/message/parts/StepFinishPartView.test.tsx` +Expected: FAIL with type errors (new props not in component yet) + +- [ ] **Step 4: Implement the component changes** (already done in Step 1) + +- [ ] **Step 5: Run test to verify it passes** + +Run: `npx vitest run src/features/message/parts/StepFinishPartView.test.tsx` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add src/features/message/parts/StepFinishPartView.tsx src/features/message/parts/StepFinishPartView.test.tsx +git commit -m "feat: add TTFT and TPS display to StepFinishPartView" +``` + +--- + +### Task 5: Extract `firstPartStart` from parts and pass new props in MessageRenderer + +**Files:** +- Modify: `src/features/message/MessageRenderer.tsx` + +- [ ] **Step 1: Extract firstPartStart from parts** + +In `src/features/message/MessageRenderer.tsx`, inside `AssistantMessageView`, add after the `hasCopyableText` line (around line 417): + +```tsx + // 最早 part.time.start,用于 TTFT/TPS 计算 + const firstPartStart = useMemo(() => { + for (const p of parts) { + if ((p.type === 'text' || p.type === 'reasoning') && p.time?.start) { + return p.time.start + } + } + return undefined + }, [parts]) +``` + +Make sure to add `useMemo` to the imports. + +- [ ] **Step 2: Pass new props to StepFinishPartView** + +Find the two ` +``` + +Second usage (ToolGroup, line 737-744): +```tsx + +``` + +Also add `created` and `firstPartStart` to the `ToolGroupProps` interface and destructuring: + +```tsx +interface ToolGroupProps { + parts: ToolPart[] + stepFinish?: StepFinishPart + duration?: number + turnDuration?: number + isStreaming?: boolean + agent?: string + modelLabel?: string + completedAt?: number + created?: number + firstPartStart?: number +} +``` + +And in the destructuring (line 555-563 area): +```tsx + duration, + turnDuration, + isStreaming, + agent, + modelLabel, + completedAt, + created, + firstPartStart, +``` + + + +- [ ] **Step 3: Run existing tests to verify nothing broke** + +Run: `npx vitest run src/features/message/MessageRenderer.test.tsx` +Expected: PASS + +- [ ] **Step 4: Commit** + +```bash +git add src/features/message/MessageRenderer.tsx +git commit -m "feat: extract firstPartStart and pass created/firstPartStart to StepFinishPartView" +``` + +--- + +### Task 6: Type check and verify build + +- [ ] **Step 1: Run type check** + +Run: `npx tsc --noEmit` +Expected: No type errors + +- [ ] **Step 2: Run full test suite** + +Run: `npx vitest run` +Expected: All tests pass + +- [ ] **Step 3: Final commit if needed** + +```bash +git add -A +git commit -m "chore: fix type and test issues" +``` From 3228bd7d127b287a41bace1f4bd6dddb0f460cba Mon Sep 17 00:00:00 2001 From: kkito Date: Mon, 8 Jun 2026 09:31:37 +0800 Subject: [PATCH 08/10] feat: rename T/s to tps, update i18n labels --- src/features/message/parts/StepFinishPartView.test.tsx | 2 +- src/features/message/parts/StepFinishPartView.tsx | 2 +- src/locales/en/settings.json | 4 ++-- src/locales/zh-CN/settings.json | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/features/message/parts/StepFinishPartView.test.tsx b/src/features/message/parts/StepFinishPartView.test.tsx index ac115d19..eb47bd0f 100644 --- a/src/features/message/parts/StepFinishPartView.test.tsx +++ b/src/features/message/parts/StepFinishPartView.test.tsx @@ -71,7 +71,7 @@ describe('StepFinishPartView', () => { // genTime = 20000 - 6000 = 14000ms = 14s // genTokens = 200 + 50 = 250 // tps = Math.round(250 / 14) = Math.round(17.857) = 18 - expect(screen.getByText('18 T/s')).toBeInTheDocument() + expect(screen.getByText('18 tps')).toBeInTheDocument() }) it('renders TTFT when data is available', () => { diff --git a/src/features/message/parts/StepFinishPartView.tsx b/src/features/message/parts/StepFinishPartView.tsx index 011be784..3b808c75 100644 --- a/src/features/message/parts/StepFinishPartView.tsx +++ b/src/features/message/parts/StepFinishPartView.tsx @@ -76,7 +76,7 @@ export const StepFinishPartView = memo(function StepFinishPartView({ )} {show.tps && tps != null && ( - {tps} T/s + {tps} tps )} {show.cache && cacheHit > 0 && ( Date: Mon, 8 Jun 2026 09:58:28 +0800 Subject: [PATCH 09/10] chore: remove docs from git tracking, add to .gitignore.local --- .../plans/2026-06-08-tps-ttft-display.md | 580 ------------------ .../2026-06-08-tps-ttft-display-design.md | 94 --- 2 files changed, 674 deletions(-) delete mode 100644 docs/superpowers/plans/2026-06-08-tps-ttft-display.md delete mode 100644 docs/superpowers/specs/2026-06-08-tps-ttft-display-design.md diff --git a/docs/superpowers/plans/2026-06-08-tps-ttft-display.md b/docs/superpowers/plans/2026-06-08-tps-ttft-display.md deleted file mode 100644 index 6ffb1ddd..00000000 --- a/docs/superpowers/plans/2026-06-08-tps-ttft-display.md +++ /dev/null @@ -1,580 +0,0 @@ -# TPS & TTFT Display 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:** Add tokens-per-second (TPS) and time-to-first-token (TTFT) display to step-finish info bar. - -**Architecture:** New props flow from `MessageRenderer.tsx` → `StepFinishPartView.tsx`. Two new toggles added to `StepFinishDisplay` in `themeStore.ts`. TTFT reuses existing `formatDuration()`, TPS formatted inline as integer. - -**Tech Stack:** React 19, TypeScript, Tailwind CSS, i18next, Vitest + Testing Library - ---- - -### Task 1: Add `ttft` and `tps` to StepFinishDisplay - -**Files:** -- Modify: `src/store/themeStore.ts` (interface + defaults) - -- [ ] **Step 1: Add fields to interface** - -Edit `src/store/themeStore.ts`, add `ttft` and `tps` to `StepFinishDisplay`: - -```typescript -export interface StepFinishDisplay { - tokens: boolean - tps: boolean - cache: boolean - cost: boolean - duration: boolean - turnDuration: boolean - agent: boolean - model: boolean - completedAt: boolean - ttft: boolean -} -``` - -- [ ] **Step 2: Set defaults to true** - -Edit `DEFAULT_STEP_FINISH_DISPLAY`: - -```typescript -const DEFAULT_STEP_FINISH_DISPLAY: StepFinishDisplay = { - tokens: true, - tps: true, - cache: true, - cost: true, - duration: true, - turnDuration: true, - agent: false, - model: false, - completedAt: false, - ttft: true, -} -``` - -- [ ] **Step 3: Commit** - -```bash -git add src/store/themeStore.ts -git commit -m "feat: add ttft and tps to StepFinishDisplay" -``` - ---- - -### Task 2: Add i18n entries for TTFT/TPS settings - -**Files:** -- Modify: `src/locales/en/settings.json` -- Modify: `src/locales/zh-CN/settings.json` - -- [ ] **Step 1: Add English entries** - -Insert after the `showTokenUsage` entry in `src/locales/en/settings.json`: - -```json - "tps": "Throughput", - "showTps": "Show tokens per second (computed from opencode API timestamps, includes LLM network overhead)", -``` - -Insert after the `showCompletedAt` entry (near the end of the step-finish section): - -```json - "ttft": "TTFT", - "showTtft": "Show time to first token (computed from opencode API timestamps, includes LLM network overhead)", -``` - -- [ ] **Step 2: Add Chinese entries** - -Insert after the `showTokenUsage` entry in `src/locales/zh-CN/settings.json`: - -```json - "tps": "吞吐量", - "showTps": "显示每秒 Token 数(基于 opencode API 时间戳计算,包含 LLM 网络消耗)", -``` - -Insert after the `showCompletedAt` entry: - -```json - "ttft": "TTFT", - "showTtft": "显示首 Token 延迟(基于 opencode API 时间戳计算,包含 LLM 网络消耗)", -``` - -- [ ] **Step 3: Commit** - -```bash -git add src/locales/en/settings.json src/locales/zh-CN/settings.json -git commit -m "feat: add i18n entries for TPS and TTFT settings" -``` - ---- - -### Task 3: Add TTFT/TPS toggle rows to ChatSettings - -**Files:** -- Modify: `src/features/settings/components/ChatSettings.tsx` - -- [ ] **Step 1: Add toggle rows** - -In `src/features/settings/components/ChatSettings.tsx`, find the step-finish section (around line 96-128). Add two new entries after the `tokens` entry: - -```tsx - { key: 'tps', label: t('chat.tps'), desc: t('chat.showTps') }, -``` - -After the `completedAt` entry: - -```tsx - { key: 'ttft', label: t('chat.ttft'), desc: t('chat.showTtft') }, -``` - -The full array should look like: - -```tsx - {( - [ - { key: 'agent', label: t('chat.agent'), desc: t('chat.showAgent') }, - { key: 'model', label: t('chat.model'), desc: t('chat.showModel') }, - { key: 'tokens', label: t('chat.tokens'), desc: t('chat.showTokenUsage') }, - { key: 'tps', label: t('chat.tps'), desc: t('chat.showTps') }, - { key: 'cache', label: t('chat.cache'), desc: t('chat.showCacheHit') }, - { key: 'cost', label: t('chat.cost'), desc: t('chat.showApiCost') }, - { key: 'duration', label: t('chat.duration'), desc: t('chat.showResponseTime') }, - { key: 'turnDuration', label: t('chat.totalDuration'), desc: t('chat.showTurnElapsed') }, - { key: 'ttft', label: t('chat.ttft'), desc: t('chat.showTtft') }, - { key: 'completedAt', label: t('chat.completedAt'), desc: t('chat.showCompletedAt') }, - ] as const - ).map(...) -``` - -- [ ] **Step 2: Commit** - -```bash -git add src/features/settings/components/ChatSettings.tsx -git commit -m "feat: add TPS and TTFT toggle rows to settings" -``` - ---- - -### Task 4: Add `firstPartStart` and `created` props to StepFinishPartView, compute and render TTFT/TPS - -**Files:** -- Modify: `src/features/message/parts/StepFinishPartView.tsx` -- Create: `src/features/message/parts/StepFinishPartView.test.tsx` - -- [ ] **Step 1: Add new props and compute TTFT/TPS** - -Edit `src/features/message/parts/StepFinishPartView.tsx`: - -Add `firstPartStart` and `created` to the interface: - -```typescript -interface StepFinishPartViewProps { - part: StepFinishPart - /** 单条消息耗时(毫秒) */ - duration?: number - /** 整个回合总耗时(毫秒),从用户发送到最后一条 assistant 完成 */ - turnDuration?: number - /** agent 名称(来自消息 info) */ - agent?: string - /** model 显示名(来自消息 info) */ - modelLabel?: string - /** 消息完成时间戳(毫秒),用于显示完成时刻 */ - completedAt?: number - /** 消息创建时间戳(毫秒),来自 AssistantMessage.time.created */ - created?: number - /** 消息中最早 part.time.start(毫秒时间戳),用于计算 TTFT/TPS */ - firstPartStart?: number -} -``` - -Update the destructuring in the component: - -```typescript -export const StepFinishPartView = memo(function StepFinishPartView({ - part, - duration, - turnDuration, - agent, - modelLabel, - completedAt, - created, - firstPartStart, -}: StepFinishPartViewProps) { -``` - -Compute TTFT and TPS after the existing `cacheHit` line: - -```typescript - const totalTokens = tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write - const cacheHit = tokens.cache.read - - // TTFT & TPS — 只有最后一条 step-finish 有 completedAt 和 firstPartStart - const ttft = created != null && firstPartStart != null ? firstPartStart - created : undefined - const genTime = completedAt != null && firstPartStart != null ? completedAt - firstPartStart : undefined - const genTokens = tokens.output + tokens.reasoning - const tps = genTime != null && genTime > 0 && genTokens > 0 ? Math.round(genTokens / (genTime / 1000)) : undefined -``` - -Update `hasAny` to include the new fields: - -```typescript - const hasAny = - (show.agent && !!agent) || - (show.model && !!modelLabel) || - (show.tokens && totalTokens > 0) || - (show.tps && tps != null) || - (show.cache && cacheHit > 0) || - (show.cost && cost > 0) || - (show.duration && duration != null && duration > 0) || - (show.turnDuration && turnDuration != null && turnDuration > 0) || - (show.ttft && ttft != null) || - (show.completedAt && completedAt != null) - if (!hasAny) return null -``` - -Add TTFT and TPS display after the tokens span (after line 63): - -```tsx - {show.tokens && totalTokens > 0 && ( - - {formatNumber(totalTokens)} {t('tokens')} - - )} - {show.tps && tps != null && ( - {tps} T/s - )} -``` - -Add TTFT after turnDuration and before completedAt (around line 76-78): - -```tsx - {show.ttft && ttft != null && ( - TTFT {formatDuration(ttft)} - )} -``` - -The full render area should look like: - -```tsx - {show.agent && agent && {agent}} - {show.model && modelLabel && {modelLabel}} - {show.tokens && totalTokens > 0 && ( - - {formatNumber(totalTokens)} {t('tokens')} - - )} - {show.tps && tps != null && ( - {tps} T/s - )} - {show.cache && cacheHit > 0 && ( - - ({t('stepFinish.cached', { count: formatNumber(cacheHit) })}) - - )} - {show.cost && cost > 0 && {formatCost(cost)}} - {show.duration && duration != null && duration > 0 && {formatDuration(duration)}} - {show.turnDuration && turnDuration != null && turnDuration > 0 && ( - {t('stepFinish.totalDuration', { duration: formatDuration(turnDuration) })} - )} - {show.ttft && ttft != null && ( - TTFT {formatDuration(ttft)} - )} - {show.completedAt && completedAt != null && ( - {formatCompletedAt(completedAt, completedAtFormat)} - )} -``` - -- [ ] **Step 2: Write the failing test** - -Create `src/features/message/parts/StepFinishPartView.test.tsx`: - -```tsx -import { render, screen } from '@testing-library/react' -import { describe, expect, it, vi } from 'vitest' -import { StepFinishPartView } from './StepFinishPartView' -import type { StepFinishPart } from '../../../types/message' - -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string, opts?: Record) => { - if (key === 'stepFinish.inputTokens') return `Input: ${opts?.input}` - if (key === 'stepFinish.outputTokens') return `Output: ${opts?.output}` - if (key === 'stepFinish.reasoningTokens') return `Reasoning: ${opts?.reasoning}` - if (key === 'stepFinish.cacheRead') return `Cache read: ${opts?.read}` - if (key === 'stepFinish.cacheWrite') return `Cache write: ${opts?.write}` - if (key === 'stepFinish.cached') return `${opts?.count} cached` - if (key === 'stepFinish.totalDuration') return `${opts?.duration} total` - if (key === 'tokens') return 'tokens' - return key - }, - }), -})) - -vi.mock('../../../hooks/useTheme', () => ({ - useTheme: () => ({ - stepFinishDisplay: { - tokens: true, - tps: true, - cache: true, - cost: true, - duration: true, - turnDuration: true, - agent: true, - model: true, - completedAt: true, - ttft: true, - }, - completedAtFormat: 'time' as const, - }), -})) - -function createStepFinishPart(overrides?: Partial): StepFinishPart { - return { - id: 'step-finish-1', - sessionID: 'session-1', - messageID: 'msg-1', - type: 'step-finish', - reason: 'stop', - cost: 0, - tokens: { - input: 100, - output: 200, - reasoning: 50, - cache: { read: 0, write: 0 }, - }, - ...overrides, - } -} - -describe('StepFinishPartView', () => { - it('renders TPS when tps and firstPartStart are provided', () => { - render( - , - ) - // created=5000, firstPartStart=6000 → TTFT=1000ms - // completedAt=20000, firstPartStart=6000 → genTime=14000ms - // genTokens=250 (output 200 + reasoning 50) - // tps = Math.round(250 / (14000/1000)) = Math.round(17.857) = 18 - expect(screen.getByText('18 T/s')).toBeInTheDocument() - }) - - it('renders TTFT when ttft and firstPartStart are provided', () => { - render( - , - ) - expect(screen.getByText(/TTFT/)).toBeInTheDocument() - expect(screen.getByText(/1s/)).toBeInTheDocument() - }) - - it('does not render TPS when no firstPartStart', () => { - render( - , - ) - expect(screen.queryByText(/T\/s/)).toBeNull() - }) - - it('does not render TTFT when no firstPartStart', () => { - render( - , - ) - expect(screen.queryByText(/TTFT/)).toBeNull() - }) - - it('does not render TTFT/TPS when toggled off in settings', () => { - vi.mocked(require('../../../hooks/useTheme').useTheme).mockReturnValue({ - stepFinishDisplay: { - tokens: true, - tps: false, - cache: true, - cost: true, - duration: true, - turnDuration: true, - agent: true, - model: true, - completedAt: false, - ttft: false, - }, - completedAtFormat: 'time' as const, - }) - - render( - , - ) - expect(screen.queryByText(/T\/s/)).toBeNull() - expect(screen.queryByText(/TTFT/)).toBeNull() - }) -}) -``` - -- [ ] **Step 3: Run test to verify it fails** - -Run: `npx vitest run src/features/message/parts/StepFinishPartView.test.tsx` -Expected: FAIL with type errors (new props not in component yet) - -- [ ] **Step 4: Implement the component changes** (already done in Step 1) - -- [ ] **Step 5: Run test to verify it passes** - -Run: `npx vitest run src/features/message/parts/StepFinishPartView.test.tsx` -Expected: PASS - -- [ ] **Step 6: Commit** - -```bash -git add src/features/message/parts/StepFinishPartView.tsx src/features/message/parts/StepFinishPartView.test.tsx -git commit -m "feat: add TTFT and TPS display to StepFinishPartView" -``` - ---- - -### Task 5: Extract `firstPartStart` from parts and pass new props in MessageRenderer - -**Files:** -- Modify: `src/features/message/MessageRenderer.tsx` - -- [ ] **Step 1: Extract firstPartStart from parts** - -In `src/features/message/MessageRenderer.tsx`, inside `AssistantMessageView`, add after the `hasCopyableText` line (around line 417): - -```tsx - // 最早 part.time.start,用于 TTFT/TPS 计算 - const firstPartStart = useMemo(() => { - for (const p of parts) { - if ((p.type === 'text' || p.type === 'reasoning') && p.time?.start) { - return p.time.start - } - } - return undefined - }, [parts]) -``` - -Make sure to add `useMemo` to the imports. - -- [ ] **Step 2: Pass new props to StepFinishPartView** - -Find the two ` -``` - -Second usage (ToolGroup, line 737-744): -```tsx - -``` - -Also add `created` and `firstPartStart` to the `ToolGroupProps` interface and destructuring: - -```tsx -interface ToolGroupProps { - parts: ToolPart[] - stepFinish?: StepFinishPart - duration?: number - turnDuration?: number - isStreaming?: boolean - agent?: string - modelLabel?: string - completedAt?: number - created?: number - firstPartStart?: number -} -``` - -And in the destructuring (line 555-563 area): -```tsx - duration, - turnDuration, - isStreaming, - agent, - modelLabel, - completedAt, - created, - firstPartStart, -``` - - - -- [ ] **Step 3: Run existing tests to verify nothing broke** - -Run: `npx vitest run src/features/message/MessageRenderer.test.tsx` -Expected: PASS - -- [ ] **Step 4: Commit** - -```bash -git add src/features/message/MessageRenderer.tsx -git commit -m "feat: extract firstPartStart and pass created/firstPartStart to StepFinishPartView" -``` - ---- - -### Task 6: Type check and verify build - -- [ ] **Step 1: Run type check** - -Run: `npx tsc --noEmit` -Expected: No type errors - -- [ ] **Step 2: Run full test suite** - -Run: `npx vitest run` -Expected: All tests pass - -- [ ] **Step 3: Final commit if needed** - -```bash -git add -A -git commit -m "chore: fix type and test issues" -``` diff --git a/docs/superpowers/specs/2026-06-08-tps-ttft-display-design.md b/docs/superpowers/specs/2026-06-08-tps-ttft-display-design.md deleted file mode 100644 index 633fa7c8..00000000 --- a/docs/superpowers/specs/2026-06-08-tps-ttft-display-design.md +++ /dev/null @@ -1,94 +0,0 @@ -# TPS & TTFT Display for Step-Finish Info - -## Overview - -Add tokens-per-second (TPS) and time-to-first-token (TTFT) display to the step-finish info bar in assistant messages. Both metrics are computed from existing API timestamp fields (`AssistantMessage.time`, `TextPart.time.start`, `ReasoningPart.time.start`). - -## Background - -All timestamps come from the opencode server's `Date.now()`: - -| Field | Source | When set | -|---|---|---| -| `AssistantMessage.time.created` | opencode server | When LLM call is initiated | -| `AssistantMessage.time.completed` | opencode server | When LLM stream finishes | -| `TextPart.time.start` | opencode server | When first text chunk arrives | -| `ReasoningPart.time.start` | opencode server | When first reasoning chunk arrives | - -These are server wall-clock times, meaning TTFT includes network latency to the LLM provider plus the provider's internal processing time. TPS reflects the effective generation throughput as observed by the opencode server. - -## Formulas - -``` -TTFT = firstPartStart - created -genTime = completed - firstPartStart -TPS = (tokens.output + tokens.reasoning) / (genTime / 1000) -``` - -Where `firstPartStart` is the earliest `time.start` across all `text` and `reasoning` parts in the message. - -## Display - -``` -· 42 T/s · TTFT 1.2s -``` - -- Placed after `tokens` in the step-finish line -- TPS shown as integer (e.g., `42 T/s`) -- TTFT uses existing `formatDuration()` (e.g., `1.2s`, `342ms`) -- Only shown on the **last step-finish** in a message (same condition as `completedAt`/`duration`) -- Only shown when data is available (parts exist with `time.start`, `completed` is present) - -## Configuration - -Two new independent toggles in `StepFinishDisplay` (both default `true`): - -``` -ttft: boolean -tps: boolean -``` - -Settings page (Chat → Step Finish Info) adds two new rows: - -| Toggle | Label | Description | -|---|---|---| -| ttft | "TTFT" | "Show time to first token (computed from opencode API timestamps, includes LLM network overhead)" | -| tps | "Throughput" | "Show tokens per second (computed from opencode API timestamps, includes LLM network overhead)" | - -## Data Flow - -``` -AssistantMessageView (MessageRenderer.tsx) - │ - ├─ parts から 最早の time.start を抽出 → firstPartStart - ├─ info.time.created を抽出 → created - ├─ info.time.completed を抽出 → completed - │ - └─ StepFinishPartView - props: { part, duration?, firstPartStart?, created?, completed?, ... } -``` - -## Files Changed - -| File | Change | -|---|---| -| `src/store/themeStore.ts` | Add `ttft`, `tps` to `StepFinishDisplay` interface and defaults | -| `src/features/message/parts/StepFinishPartView.tsx` | Add `firstPartStart`, `created` props; compute and render TTFT/TPS | -| `src/features/message/MessageRenderer.tsx` | Extract `firstPartStart` from parts; pass `created`, `firstPartStart` to StepFinishPartView | -| `src/features/settings/components/ChatSettings.tsx` | Add TTFT/TPS toggle rows | -| `src/locales/en/settings.json` | Add `ttft`/`showTtft`, `tps`/`showTps` keys | -| `src/locales/zh-CN/settings.json` | Same in Chinese | -| `src/features/message/MessageRenderer.test.tsx` | Update mock to include new props | -| `src/features/message/parts/StepFinishPartView.test.tsx` | Add tests for TTFT/TPS display (if exists) | - -## Tests - -- `StepFinishPartView`: Test TTFT/TPS display when data present; verify hidden when data missing -- `MessageRenderer`: Ensure `firstPartStart` is correctly extracted from parts - -## Not in Scope - -- Session-level TPS aggregation -- Real-time TPS during streaming -- Tool-call-only steps (no `text`/`reasoning` parts) -- `formatUtils.ts` changes (TTFT reuses `formatDuration`) From a4ec93d4c12b7fad58b8254dbf45ac0e62dc2ad2 Mon Sep 17 00:00:00 2001 From: kkito Date: Mon, 8 Jun 2026 10:23:18 +0800 Subject: [PATCH 10/10] feat: set tps/ttft defaults to false, reorder display and settings --- .../message/parts/StepFinishPartView.test.tsx | 4 ++-- .../message/parts/StepFinishPartView.tsx | 16 ++++++++-------- .../settings/components/ChatSettings.tsx | 4 ++-- src/store/themeStore.ts | 6 +++--- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/features/message/parts/StepFinishPartView.test.tsx b/src/features/message/parts/StepFinishPartView.test.tsx index eb47bd0f..827fac5b 100644 --- a/src/features/message/parts/StepFinishPartView.test.tsx +++ b/src/features/message/parts/StepFinishPartView.test.tsx @@ -95,7 +95,7 @@ describe('StepFinishPartView', () => { completedAt={20000} />, ) - expect(screen.queryByText(/T\/s/)).toBeNull() + expect(screen.queryByText(/tps/)).toBeNull() }) it('does not render TTFT when no firstPartStart', () => { @@ -130,7 +130,7 @@ describe('StepFinishPartView', () => { firstPartStart={6000} />, ) - expect(screen.queryByText(/T\/s/)).toBeNull() + expect(screen.queryByText(/tps/)).toBeNull() expect(screen.queryByText(/TTFT/)).toBeNull() }) }) diff --git a/src/features/message/parts/StepFinishPartView.tsx b/src/features/message/parts/StepFinishPartView.tsx index 3b808c75..3f576833 100644 --- a/src/features/message/parts/StepFinishPartView.tsx +++ b/src/features/message/parts/StepFinishPartView.tsx @@ -55,12 +55,12 @@ export const StepFinishPartView = memo(function StepFinishPartView({ (show.agent && !!agent) || (show.model && !!modelLabel) || (show.tokens && totalTokens > 0) || - (show.tps && tps != null) || (show.cache && cacheHit > 0) || (show.cost && cost > 0) || + (show.tps && tps != null) || + (show.ttft && ttft != null) || (show.duration && duration != null && duration > 0) || (show.turnDuration && turnDuration != null && turnDuration > 0) || - (show.ttft && ttft != null) || (show.completedAt && completedAt != null) if (!hasAny) return null @@ -75,9 +75,6 @@ export const StepFinishPartView = memo(function StepFinishPartView({ {formatNumber(totalTokens)} {t('tokens')} )} - {show.tps && tps != null && ( - {tps} tps - )} {show.cache && cacheHit > 0 && ( )} {show.cost && cost > 0 && {formatCost(cost)}} - {show.duration && duration != null && duration > 0 && {formatDuration(duration)}} - {show.turnDuration && turnDuration != null && turnDuration > 0 && ( - {t('stepFinish.totalDuration', { duration: formatDuration(turnDuration) })} + {show.tps && tps != null && ( + {tps} tps )} {show.ttft && ttft != null && ( TTFT {formatDuration(ttft)} )} + {show.duration && duration != null && duration > 0 && {formatDuration(duration)}} + {show.turnDuration && turnDuration != null && turnDuration > 0 && ( + {t('stepFinish.totalDuration', { duration: formatDuration(turnDuration) })} + )} {show.completedAt && completedAt != null && ( {formatCompletedAt(completedAt, completedAtFormat)} )} diff --git a/src/features/settings/components/ChatSettings.tsx b/src/features/settings/components/ChatSettings.tsx index ce4899b7..0f595cf1 100644 --- a/src/features/settings/components/ChatSettings.tsx +++ b/src/features/settings/components/ChatSettings.tsx @@ -99,12 +99,12 @@ export function ChatSettings() { { key: 'agent', label: t('chat.agent'), desc: t('chat.showAgent') }, { key: 'model', label: t('chat.model'), desc: t('chat.showModel') }, { key: 'tokens', label: t('chat.tokens'), desc: t('chat.showTokenUsage') }, - { key: 'tps', label: t('chat.tps'), desc: t('chat.showTps') }, { key: 'cache', label: t('chat.cache'), desc: t('chat.showCacheHit') }, { key: 'cost', label: t('chat.cost'), desc: t('chat.showApiCost') }, + { key: 'tps', label: t('chat.tps'), desc: t('chat.showTps') }, + { key: 'ttft', label: t('chat.ttft'), desc: t('chat.showTtft') }, { key: 'duration', label: t('chat.duration'), desc: t('chat.showResponseTime') }, { key: 'turnDuration', label: t('chat.totalDuration'), desc: t('chat.showTurnElapsed') }, - { key: 'ttft', label: t('chat.ttft'), desc: t('chat.showTtft') }, { key: 'completedAt', label: t('chat.completedAt'), desc: t('chat.showCompletedAt') }, ] as const ).map(({ key, label, desc }) => ( diff --git a/src/store/themeStore.ts b/src/store/themeStore.ts index c1cbcfbb..61227296 100644 --- a/src/store/themeStore.ts +++ b/src/store/themeStore.ts @@ -65,7 +65,6 @@ export interface CustomCSSSnippet { /** step-finish 信息栏各项显示开关 */ export interface StepFinishDisplay { tokens: boolean - tps: boolean cache: boolean cost: boolean duration: boolean @@ -73,6 +72,7 @@ export interface StepFinishDisplay { agent: boolean model: boolean completedAt: boolean + tps: boolean ttft: boolean } @@ -99,7 +99,6 @@ export type DiffStyle = 'markers' | 'changeBars' const DEFAULT_STEP_FINISH_DISPLAY: StepFinishDisplay = { tokens: true, - tps: true, cache: true, cost: true, duration: true, @@ -107,7 +106,8 @@ const DEFAULT_STEP_FINISH_DISPLAY: StepFinishDisplay = { agent: false, model: false, completedAt: false, - ttft: true, + tps: false, + ttft: false, } const DEFAULT_COMPLETED_AT_FORMAT: CompletedAtFormat = 'time'