SLOPSHOPPER

Gutter

A time column at the right of the transcript: when you sent each prompt, how long each tool ran, and each folded group's total.

newrowsprompt
★ 1v0.1.1MITupdated 2026-10-08astrosteveo/claude-plugins/plugins/gutter
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · gutter
⟨Claude Code's own drawing⟩ 08:53 ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Your message
⟨Claude Code's own drawing⟩ 08:53
README

Gutter

Gutter adds a time column at the right edge of the transcript. It shows when you sent each prompt and how long each tool ran, so you can see where a session's time went without leaving it.

> Fix the band                                                    14:05
● Fetch(https://example.com)                                       3.2s
● Read 6 files, ran 2 shell commands                               12s
● mcp__github__search_issues(…)                                    48s

What it shows

  • Your prompts show the local time you sent them, as a 24-hour clock. The expanded view (ctrl+o) leaves the time out.
  • Tool calls show how long the tool itself ran. Time spent waiting on a permission prompt is not counted. A call shows its time once it has finished.
  • Quiet calls get no mark. A call that ran under a second, like most reads and searches, shows nothing.
  • Bash, PowerShell and Agent rows already say how long they ran, so Gutter gives them no second time.
  • Colors. A time is dim gray. From 30 seconds it turns yellow. A call that failed is red.
  • A folded group of calls shows their total once every call in it has finished. The total is red if any call failed. Unfold the group and each call shows its own time instead.

Times read short: 3.2s under ten seconds, then 48s, 2m 5s and 1h 10m.

Only what happened while the plugin was loaded has a time. Prompts and calls from before it loaded, or from a resumed session, keep their rows as they were.

Gutter works the same in the terminal and in the desktop app. It has no commands and no settings.

Requirements

Claude Code v2.1.293 or later. Mods are an early access part of Claude Code, so a Claude Code release can break the plugin until it is updated.

Install

/plugin marketplace add astrosteveo/claude-plugins
/plugin install gutter@astrosteveo-plugins
/reload-plugins
Source 3 files
hooks/register.tsx 105 lines
1import { atom, memberOf, read, update } from 'claude-code'
2import type { Caught, EngineInterface, HookFailure, Register, RenderElement } from 'claude-code'
3
4import type { Badge } from './format'
5import { TIMED_BY_ENGINE, WIDTH, clock, colorOf, groupBadge, keyOf, timingBadge } from './format'
6
7const timings = atom({ plugin: 'gutter', key: 'timings' } as const, null)
8const sent = atom({ plugin: 'gutter', key: 'sent' } as const, null)
9
10const failureOf = (error: HookFailure): string => (error.kind === 'timeout' ? 'ran out of time' : (error.message ?? 'threw'))
11
12// A fault in this mod never stands in the way: the site does what it would
13// without it, and the debug log says why.
14const fallBack = <E, R>($: EngineInterface, e: E, next: ((e: E) => R) & Caught, site: string): R => {
15  $.ui.log(`gutter: ${site} failed and was left to the default: ${failureOf(next.error)}`, { to: 'debug' })
16  return next(e)
17}
18
19const record = async ($: EngineInterface, id: string, ms: number | undefined, isErrored: boolean) => {
20  if (ms === undefined) return
21  await update($, memberOf(timings, { requestId: id }), () => ({ ms, isErrored }))
22}
23
24// The engine's own row, narrowed by the gutter, with the badge right-aligned
25// in the gutter. It sits on the row's last line: the engine opens most rows
26// with a blank margin line, and a one-line row's text is its last line.
27const withGutter = ($: EngineInterface, e: Parameters<EngineInterface['ui']['resolve']>[0], row: RenderElement, badge: Badge) => {
28  const { Box, Text } = $.ui.resolve(e)
29
30  return (
31    <Box flexDirection="row" alignItems="flex-end">
32      <Box flexGrow={1} flexShrink={1}>
33        {row}
34      </Box>
35      <Box flexShrink={0} width={WIDTH} justifyContent="flex-end">
36        <Text color={colorOf(badge)} dimColor={badge.tone === 'quiet'}>
37          {badge.text}
38        </Text>
39      </Box>
40    </Box>
41  )
42}
43
44export const register: Register = on => {
45  // The classic post-tool events carry how long the tool itself ran, without
46  // the permission prompt or the hooks around it.
47  on('classic.PostToolUse', async ($, e, next) => {
48    await record($, e.tool_use_id, e.duration_ms, false)
49    return next(e)
50  }).catch(($, e, next) => fallBack($, e, next, 'PostToolUse'))
51
52  on('classic.PostToolUseFailure', async ($, e, next) => {
53    await record($, e.tool_use_id, e.duration_ms, true)
54    return next(e)
55  }).catch(($, e, next) => fallBack($, e, next, 'PostToolUseFailure'))
56
57  // A row the person sees as typed is stored under the id its UserMessage
58  // row is drawn with, so the time is found by that id once it is kept.
59  on('session.append', async ($, e, next) => {
60    if (e.message.type === 'user' && e.message.isMeta !== true && e.agentId === undefined) {
61      const at = await $.clock.now()
62      await update($, memberOf(sent, { requestId: e.uuid }), held => held ?? at)
63    }
64    return next(e)
65  }).catch(($, e, next) => fallBack($, e, next, 'session.append'))
66
67  // The text is the fallback key, for a row whose id the append did not see.
68  on('prompt.submit', async ($, e, next) => {
69    const at = await $.clock.now()
70    await update($, memberOf(sent, { requestId: keyOf(e.text) }), held => held ?? at)
71    return next(e)
72  }).catch(($, e, next) => fallBack($, e, next, 'prompt.submit'))
73
74  on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
75    const at = e.props.isExpanded
76      ? null
77      : ((await read($, memberOf(sent, e))) ?? (await read($, memberOf(sent, { requestId: keyOf(e.props.text) }))))
78    const row = await next(e)
79    if (at === null) return row
80    return withGutter($, e, row, { text: clock(at), tone: 'quiet' })
81  })
82
83  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
84    const isQuiet = e.props.isRunning || TIMED_BY_ENGINE.has(e.props.tool)
85    const badge = isQuiet ? null : timingBadge(await read($, memberOf(timings, e)))
86    const row = await next(e)
87    if (badge === null) return row
88    return withGutter($, e, row, badge)
89  })
90
91  // An unfolded group draws each call as its own ToolUse row, which carries
92  // its own badge, so only the folded line gets the total.
93  on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
94    const { calls, isActive, isExpanded } = e.props
95    const isFolded = !isExpanded && !isActive
96    const ids = isFolded ? calls.map(call => call.tool_use_id) : []
97    const known = ids.every(id => id !== undefined)
98    const held = known ? await Promise.all(ids.map(id => read($, memberOf(timings, { requestId: id! })))) : []
99    const badge = known ? groupBadge(held) : null
100    const row = await next(e)
101    if (badge === null) return row
102    return withGutter($, e, row, badge)
103  })
104}
105
hooks/format.ts 63 lines
1import type { Timing } from '../types'
2
3// The gutter's width in cells. Every badge is right-aligned in it, so the
4// figures line up down the transcript like a column.
5export const WIDTH = 8
6
7// A call under this ran too fast to be worth a mark: most reads and searches.
8export const QUIET_MS = 1000
9
10// From here a call is slow enough to stand out.
11export const SLOW_MS = 30_000
12
13// Tools whose own row already says how long they ran (`(6s · 7 lines)`).
14// Their rows get no badge, so no time is shown twice.
15export const TIMED_BY_ENGINE: ReadonlySet<string> = new Set(['Bash', 'PowerShell', 'Agent', 'Task'])
16
17export function duration(ms: number): string {
18  if (ms < 10_000) return `${(Math.floor(ms / 100) / 10).toFixed(1)}s`
19  const seconds = Math.round(ms / 1000)
20  if (seconds < 60) return `${seconds}s`
21  const minutes = Math.floor(seconds / 60)
22  if (minutes < 60) return seconds % 60 === 0 ? `${minutes}m` : `${minutes}m ${seconds % 60}s`
23  const hours = Math.floor(minutes / 60)
24  return minutes % 60 === 0 ? `${hours}h` : `${hours}h ${minutes % 60}m`
25}
26
27// A local clock time, 24-hour, as `14:05`.
28export function clock(at: number): string {
29  const date = new Date(at)
30  return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
31}
32
33export type Badge = { text: string; tone: 'quiet' | 'slow' | 'failed' }
34
35export function timingBadge(timing: Timing | null): Badge | null {
36  if (timing === null || timing.ms < QUIET_MS) return null
37  const tone = timing.isErrored ? 'failed' : timing.ms >= SLOW_MS ? 'slow' : 'quiet'
38  return { text: duration(timing.ms), tone }
39}
40
41// A folded group's badge: its calls' run times summed, once every call has
42// one. A call still running, or not timed, leaves the group without a mark
43// rather than showing a total that is too small.
44export function groupBadge(timings: readonly (Timing | null)[]): Badge | null {
45  if (timings.length === 0 || timings.some(timing => timing === null)) return null
46  const known = timings as Timing[]
47  return timingBadge({ ms: known.reduce((sum, timing) => sum + timing.ms, 0), isErrored: known.some(timing => timing.isErrored) })
48}
49
50export const colorOf = (badge: Badge): 'subtle' | 'warning' | 'error' =>
51  badge.tone === 'failed' ? 'error' : badge.tone === 'slow' ? 'warning' : 'subtle'
52
53// A short, stable key for a prompt's text, so the row that draws it later
54// finds when it was sent. FNV-1a, as hex.
55export function keyOf(text: string): string {
56  let hash = 0x811c9dc5
57  for (let i = 0; i < text.length; i += 1) {
58    hash ^= text.charCodeAt(i)
59    hash = Math.imul(hash, 0x01000193) >>> 0
60  }
61  return hash.toString(16).padStart(8, '0')
62}
63
types/index.d.ts 14 lines
1// How long one tool call ran, without the permission wait, and whether it failed.
2export type Timing = { ms: number; isErrored: boolean }
3
4declare module 'claude-code' {
5  interface PluginState {
6    gutter: {
7      // Each tool call's run, by its tool_use_id.
8      timings: StateFamily<Timing | null>
9      // When each prompt was sent, by its message id and by a hash of its text.
10      sent: StateFamily<number | null>
11    }
12  }
13}
14