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

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
<1s, 45s, 2m 5s, 1h 3m.⏱ Backgrounded after 8s.run_in_background are not timed. (incl. approval wait): no event marks the moment the dialog is answered, so its time includes that wait.From agent-plugins:
claude plugin marketplace add https://github.com/xchacha20-poly1305/agent-plugins.git
claude plugin install cccc@anrong-plugins
MIT
hooks/register.tsx 113 lines1import { 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}
113types/index.d.ts 17 lines1/**
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