Shows each turn's duration and finish time below the reply

Claude Code Mod 合集,以插件市场 wobok-mods 的形式发布。
要求:Claude Code v2.1.287 或更高版本。
首次使用前执行一次:
claude plugin marketplace add WoBok/claude-mods
| Mod | 说明 | 安装 |
|---|---|---|
| usage-band | 在底栏显示上下文与用量限额 | claude plugin install usage-band@wobok-mods |
| reply-timer | 在每轮回复末尾显示耗时与完成时间 | claude plugin install reply-timer@wobok-mods |
安装后新开会话即可生效。
在输入框下方的底栏中显示:
⛁ 16% | ⏱ 15% 1.5h | ▦ 90%

| 字段 | 含义 |
|---|---|
⛁ | 上下文已用比例 |
⏱ | 5 小时限额已用比例及距重置的剩余小时数 |
▦ | 每周限额已用比例 |
限额数据仅在使用 Pro / Max 订阅登录时可用。
在每轮回复的末尾显示:
1m 23s · 14:05

| 字段 | 含义 |
|---|---|
1m 23s | 本轮耗时 |
14:05 | 本轮完成时间 |
claude plugin marketplace update wobok-mods # 获取最新版本
claude plugin update <mod>@wobok-mods # 更新
claude plugin uninstall <mod>@wobok-mods # 卸载hooks/register.tsx 204 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { TurnTiming } from '../types'
5
6// Bumped once per finished turn: the only value a drawing subscribes to, so a write redraws just the replies still waiting
7const seq = atom({ plugin: 'reply-timer', key: 'seq' } as const, 0)
8
9// Every finished turn's timing under its reply's message id, in $.store so it outlives a restart.
10// One entry is a few dozen bytes: the cap keeps the file far below the store's 4 MiB
11const STORE_KEY = 'byMessage'
12const MAX_TURNS = 5000
13type StoredTiming = [messageId: string, ms: number, endedAt: number]
14
15// djb2 and FNV-1a over the trimmed text: matches the answer to one of this turn's own blocks
16const keyOf = (text: string) => {
17 let djb = 5381
18 let fnv = 0x811c9dc5
19 const s = text.trim()
20 for (let i = 0; i < s.length; i++) {
21 const c = s.charCodeAt(i)
22 djb = ((djb << 5) + djb + c) | 0
23 fnv = Math.imul(fnv ^ c, 0x01000193)
24 }
25 return `${s.length}:${(djb >>> 0).toString(36)}${(fnv >>> 0).toString(36)}`
26}
27
28// 3s, 1m 4s, 1h 2m
29const formatDuration = (ms: number) => {
30 const total = Math.max(0, Math.round(ms / 1000))
31 const h = Math.floor(total / 3600)
32 const m = Math.floor((total % 3600) / 60)
33 const s = total % 60
34 if (h > 0) return `${h}h ${m}m`
35 if (m > 0) return `${m}m ${s}s`
36 return `${s}s`
37}
38
39const pad = (n: number) => String(n).padStart(2, '0')
40const formatClock = (at: number) => {
41 const d = new Date(at)
42 return `${pad(d.getHours())}:${pad(d.getMinutes())}`
43}
44
45const labelOf = (timing: TurnTiming) => `${formatDuration(timing.ms)} · ${formatClock(timing.endedAt)}`
46
47// The desktop footer's own look ("2 minutes ago"): 13px system font, muted gray.
48// Inset to the reply's text column, and set a paragraph's gap below the last line
49const FOOTER_INSET = 4
50const FOOTER_HEIGHT = 34
51const FOOTER_BASELINE = 26
52const footerWidth = (label: string) => FOOTER_INSET + 4 + label.length * 8
53const footerSvg = (label: string) => {
54 const width = footerWidth(label)
55 return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${FOOTER_HEIGHT}" viewBox="0 0 ${width} ${FOOTER_HEIGHT}">
56<style>
57text{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;font-size:13px;fill:#8F8D87}
58@media (prefers-color-scheme: dark){text{fill:#9C9A94}}
59</style>
60<text x="${FOOTER_INSET}" y="${FOOTER_BASELINE}" xml:space="preserve">${label}</text>
61</svg>`
62}
63
64// The stored timings, in this module's memory; drawing reads them here, never through $.store
65const byId = new Map<string, TurnTiming>()
66// Timings matched to a reply but not yet written: a render may not write, the next turn's end or the session's does
67const unsaved = new Set<string>()
68let loading: Promise<void> | undefined
69
70async function readStored($: EngineInterface): Promise<StoredTiming[]> {
71 const stored = await $.store.get(STORE_KEY)
72 return Array.isArray(stored) ? (stored as StoredTiming[]) : []
73}
74
75// Loaded on first need, so a reply drawn before session.start still waits for it
76function ensureLoaded($: EngineInterface): Promise<void> {
77 loading ??= (async () => {
78 for (const [id, ms, endedAt] of await readStored($)) {
79 byId.set(id, { ms, endedAt })
80 }
81 })()
82 return loading
83}
84
85// Re-reads before writing so other open sessions' turns are kept, then appends ours last
86async function persist($: EngineInterface): Promise<void> {
87 if (unsaved.size === 0) {
88 return
89 }
90 const merged = (await readStored($)).filter(([id]) => !unsaved.has(id))
91 for (const id of unsaved) {
92 const timing = byId.get(id)
93 if (timing) {
94 merged.push([id, timing.ms, timing.endedAt])
95 }
96 }
97 unsaved.clear()
98 await $.store.set(STORE_KEY, merged.slice(-MAX_TURNS))
99}
100
101export const register: Register = on => {
102 // Each reply block bound once: message id -> its text length when decided, and its label or null
103 const bound = new Map<string, { length: number; label: string | null }>()
104 // The blocks first drawn while the running turn streamed: message id -> latest text
105 const turnBlocks = new Map<string, string>()
106 // A finished turn whose block was not drawn while it ran: the first new block with its text claims it
107 let pending: { key: string; timing: TurnTiming } | null = null
108 let isTurnRunning = false
109
110 on('turn.start', async ($, e, next) => {
111 isTurnRunning = true
112 turnBlocks.clear()
113 return next(e)
114 })
115
116 on('turn.complete', async ($, e, next) => {
117 // Main loop only; a subagent's turns are not the person's
118 if (e.agentId) {
119 return next(e)
120 }
121 isTurnRunning = false
122 let id: string | undefined
123 if (e.answer.trim()) {
124 await ensureLoaded($)
125 const key = keyOf(e.answer)
126 const timing: TurnTiming = { ms: e.durationMs, endedAt: await $.clock.now() }
127 // Only this turn's own blocks are compared, so an identical older reply is never mistaken for it
128 id = [...turnBlocks].reverse().find(([, text]) => keyOf(text) === key)?.[0]
129 if (id) {
130 byId.set(id, timing)
131 unsaved.add(id)
132 } else {
133 pending = { key, timing }
134 }
135 }
136 // This turn's blocks are decided now, so a late redraw is never taken for the next turn's own
137 for (const [blockId, text] of turnBlocks) {
138 const timing = byId.get(blockId)
139 bound.set(blockId, { length: text.length, label: blockId === id && timing ? labelOf(timing) : null })
140 }
141 turnBlocks.clear()
142 await persist($)
143 // Redraws only the blocks drawn while the turn ran, the ones that read seq
144 await update($, seq, n => n + 1)
145 return next(e)
146 })
147
148 on('session.end', async ($, e, next) => {
149 await persist($)
150 return next(e)
151 })
152
153 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
154 const id = e.requestId
155 const { text } = e.props
156 let entry = bound.get(id)
157
158 if (!entry || entry.length !== text.length) {
159 if (isTurnRunning) {
160 // A block already decided before this turn is an older reply; anything else is this turn's own
161 if (!entry) {
162 turnBlocks.set(id, text)
163 }
164 // Still streaming: nothing can match before the turn ends; wait on seq, no hashing per chunk
165 await read($, seq)
166 return next(e)
167 }
168 await ensureLoaded($)
169 let timing = byId.get(id)
170 if (!timing && pending && !entry && keyOf(text) === pending.key) {
171 timing = pending.timing
172 pending = null
173 byId.set(id, timing)
174 unsaved.add(id)
175 }
176 entry = { length: text.length, label: timing ? labelOf(timing) : null }
177 bound.set(id, entry)
178 }
179
180 if (!entry.label) {
181 return next(e)
182 }
183 const reply = await next(e)
184
185 if (e.surface === 'desktop') {
186 const { Box, Svg } = $.ui.resolve(e)
187 return (
188 <Box flexDirection="column">
189 {reply}
190 <Svg source={footerSvg(entry.label)} alt={entry.label} width={footerWidth(entry.label)} height={FOOTER_HEIGHT} />
191 </Box>
192 )
193 }
194
195 const { Box, Text } = $.ui.resolve(e)
196 return (
197 <Box flexDirection="column">
198 {reply}
199 <Text dimColor>{entry.label}</Text>
200 </Box>
201 )
202 })
203}
204types/index.d.ts 8 lines1export type TurnTiming = { ms: number; endedAt: number }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'reply-timer': { seq: number }
6 }
7}
8