SLOPSHOPPER

reply-timer

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

newrows
v0.1.1no licenseupdated 2026-10-03WoBok/claude-mods/reply-timer
A shopper browsing a rack in a slop shop
README

claude-mods

Claude Code Mod 合集,以插件市场 wobok-mods 的形式发布。

要求:Claude Code v2.1.287 或更高版本。

添加市场

首次使用前执行一次:

claude plugin marketplace add WoBok/claude-mods

Mod 列表

Mod说明安装
usage-band在底栏显示上下文与用量限额claude plugin install usage-band@wobok-mods
reply-timer在每轮回复末尾显示耗时与完成时间claude plugin install reply-timer@wobok-mods

安装后新开会话即可生效。

usage-band

在输入框下方的底栏中显示:

⛁ 16% | ⏱ 15% 1.5h | ▦ 90%

usage-band

字段含义
⛁上下文已用比例
⏱5 小时限额已用比例及距重置的剩余小时数
▦每周限额已用比例

限额数据仅在使用 Pro / Max 订阅登录时可用。

reply-timer

在每轮回复的末尾显示:

1m 23s · 14:05

reply-timer

字段含义
1m 23s本轮耗时
14:05本轮完成时间

更新与卸载

claude plugin marketplace update wobok-mods        # 获取最新版本
claude plugin update <mod>@wobok-mods              # 更新
claude plugin uninstall <mod>@wobok-mods           # 卸载
Source 2 files
hooks/register.tsx 204 lines
1import { 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}
204
types/index.d.ts 8 lines
1export type TurnTiming = { ms: number; endedAt: number }
2
3declare module 'claude-code' {
4  interface PluginState {
5    'reply-timer': { seq: number }
6  }
7}
8