SLOPSHOPPER

cccc

Shows how long each foreground Bash command has been running, and how long it took

newrowsguardtimer
v0.1.0MITupdated 2026-10-25xchacha20-poly1305/cccc
A shopper browsing a rack in a slop shop
README

cccc

Claude Code Command Clock

A Claude Code mod that shows how long each foreground Bash command has been running, on a dim line under the command's row:

⏺ Bash(sleep 5; echo done)
  ⏱ Running 3s

The line counts up once a second while the command runs, and turns into the time it took once it ends:

⏺ Bash(sleep 5; echo done)
  ⏱ Took 5s
  • Whole seconds: <1s, 45s, 2m 5s, 1h 3m.
  • A command moved to the background with ctrl+b shows ⏱ Backgrounded after 8s.
  • Commands started with run_in_background are not timed.
  • A command that waited on a permission dialog says (incl. approval wait): no event marks the moment the dialog is answered, so its time includes that wait.

Install

From agent-plugins:

claude plugin marketplace add https://github.com/xchacha20-poly1305/agent-plugins.git
claude plugin install cccc@anrong-plugins

License

MIT

Source 2 files
hooks/register.tsx 113 lines
1import { atom, memberOf, read, update } from 'claude-code'
2import type { Register, Timer } from 'claude-code'
3
4import type { Run } from '../types'
5
6const runs = atom({ plugin: 'cccc', key: 'runs' } as const, null)
7const now = atom({ plugin: 'cccc', key: 'now' } as const, 0)
8
9// Whole seconds: the drawing ticks once a second, so a fraction says nothing.
10const formatDuration = (ms: number): string => {
11  const seconds = Math.floor(ms / 1000)
12  if (seconds < 1) {
13    return '<1s'
14  }
15  if (seconds < 60) {
16    return `${seconds}s`
17  }
18  const minutes = Math.floor(seconds / 60)
19  if (minutes < 60) {
20    return `${minutes}m ${seconds % 60}s`
21  }
22  return `${Math.floor(minutes / 60)}h ${minutes % 60}m`
23}
24
25export const register: Register = on => {
26  // Calls that are running now, and the one-second tick that redraws them.
27  const active = new Set<string>()
28  let ticker: Timer | undefined
29
30  // tool.check runs inside tool.call's next(e). No event marks the person's
31  // answer to a permission dialog, so an asked call's time includes that wait.
32  on('tool.check', { tool: 'Bash' }, async ($, e, next) => {
33    const verdict = await next(e)
34    const { tool_use_id } = e
35    if (verdict.decision === 'ask' && tool_use_id !== undefined) {
36      await update($, memberOf(runs, { requestId: tool_use_id }), r => r && { ...r, includesApproval: true })
37    }
38
39    return verdict
40  }).catch(($, e, next) => next(e))
41
42  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
43    // A plugin's own $.tool.call carries no id and draws no row.
44    const { tool_use_id } = e
45    if (e.run_in_background === true || tool_use_id === undefined) {
46      return next(e)
47    }
48
49    // The call's rows draw with `requestId` = its tool_use_id.
50    const run = memberOf(runs, { requestId: tool_use_id })
51    const startedAt = await $.clock.now()
52    await update($, run, () => ({ startedAt, endedAt: null, isBackgrounded: false, includesApproval: false }))
53    await update($, now, () => startedAt)
54
55    active.add(tool_use_id)
56    ticker ??= $.clock.every(1000, () => {
57      void $.clock.now().then(t => update($, now, () => t))
58    })
59
60    try {
61      const ran = await next(e)
62      const endedAt = await $.clock.now()
63      const isBackgrounded =
64        ran.deny === undefined && ran.isError !== true && ran.result.backgroundTaskId !== undefined
65
66      await update($, run, (r): Run | null =>
67        r !== null && ran.deny === undefined ? { ...r, endedAt, isBackgrounded } : null,
68      )
69
70      return ran
71    } finally {
72      active.delete(tool_use_id)
73      if (active.size === 0) {
74        ticker?.cancel()
75        ticker = undefined
76      }
77    }
78  }).catch(($, e, next) => next(e))
79
80  // One dim line under the call's row: the time so far while it runs, the
81  // time it took once it ends. The row is drawn from the call's start, unlike
82  // the ctrl+b pill, which the engine shows late, and the result block, which
83  // a short output does not get.
84  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
85    if (e.props.tool !== 'Bash') {
86      return next(e)
87    }
88
89    const run = await read($, memberOf(runs, e))
90    if (run === null) {
91      return next(e)
92    }
93
94    const label =
95      run.endedAt === null
96        ? `⏱ Running ${formatDuration(Math.max(0, (await read($, now)) - run.startedAt))}`
97        : [
98            run.isBackgrounded
99              ? `⏱ Backgrounded after ${formatDuration(run.endedAt - run.startedAt)}`
100              : `⏱ Took ${formatDuration(run.endedAt - run.startedAt)}`,
101            run.includesApproval ? ' (incl. approval wait)' : '',
102          ].join('')
103    const { Box, Text } = $.ui.resolve(e)
104
105    return (
106      <Box flexDirection="column">
107        {await next(e)}
108        <Text dimColor>  {label}</Text>
109      </Box>
110    )
111  })
112}
113
types/index.d.ts 17 lines
1/**
2 * One foreground Bash call. `endedAt` is null while it runs; `includesApproval`
3 * says its time includes waiting on a permission dialog.
4 */
5export type Run = {
6  startedAt: number
7  endedAt: number | null
8  isBackgrounded: boolean
9  includesApproval: boolean
10}
11
12declare module 'claude-code' {
13  interface PluginState {
14    'cccc': { runs: StateFamily<Run | null>; now: number }
15  }
16}
17