SLOPSHOPPER

command-history

A pane listing the Bash commands run this session with exit status and duration

newpaneguardcommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · command-history
│ ┃ Commands ✕ › fix the failing auth test and add an audit log call │ ┃ 4 commands │ ┃ ✓ cat .env · 20ms ⏺ Read(src/auth.ts) │ ┃ ✓ rm -rf build && git push --force origin ⎿ Read 6 lines │ ┃ main · 20ms ⏺ Update(src/auth.ts) │ ┃ ✓ git status --porcelain · 20ms ⎿ Added 2 lines, removed 1 line │ ┃ ✗ bun test · 20ms ⏺ 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 │ │ › /command-history │ ⎿ command-history: Command history pane opened (4 commands). │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Commands
4 commands ✓ cat .env · 20ms ✓ rm -rf build && git push --force origin main · 20ms ✓ git status --porcelain · 20ms ✗ bun test · 20ms
README

command-history

A pane listing the Bash commands the main conversation ran, with outcome and duration.

What it does

  • Records each Bash call of the main conversation (subagent calls are ignored).
  • Each row: <status> <command> · <duration>, newest first, first line of the command only, cut to the pane width.
  • Status: ✓ success, ✗ N non-zero exit code (read from the Exit code N error text), ✗ error without a code, denied, interrupted.
  • Durations show as 340ms or 2.4s. Only the last 50 commands are kept.
  • The pane is not opened automatically: run the command.

Commands

CommandEffect
/command-historyOpen the pane.
/command-history closeClose it (off also works).
/command-history clearEmpty the history.

Install

claude --plugin-dir /path/to/ModsTools/mods/command-history

Limits

  • Exit codes come from Bash's error text; a failure without that text shows plain ✗.
  • History is per session and capped at 50 entries.
  • Multi-line commands show only their first line.

Develop

claude plugin validate mods/command-history
claude plugin test mods/command-history   # 6 tests
Source 3 files
hooks/register.tsx 67 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { HistoryEntry } from '../types'
5import { push, row, statusOf } from './history'
6
7const PANE = 'command-history'
8const TITLE = 'Commands'
9const entries = atom({ plugin: 'command-history', key: 'entries' } as const, [])
10
11export const register: Register = on => {
12  on('session.start', async ($, e, next) => {
13    await $.command.register({
14      name: 'command-history',
15      description: 'Show the Bash commands run this session (open | close | clear)',
16    })
17
18    return next(e)
19  })
20
21  on('command.run', { command: 'command-history' }, async ($, e) => {
22    const arg = e.args.trim()
23    if (arg === 'close' || arg === 'off') {
24      await $.ui.close({ id: PANE })
25      return { text: 'Command history pane closed.' }
26    }
27    if (arg === 'clear') {
28      await update($, entries, () => [])
29      return { text: 'Command history cleared.' }
30    }
31    await $.ui.open({ id: PANE, title: TITLE })
32    const count = (await read($, entries)).length
33
34    return { text: `Command history pane opened (${count} command${count === 1 ? '' : 's'}).` }
35  })
36
37  // Subagent Bash calls are ignored: the pane is the main conversation's history.
38  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
39    if ((e as { agentId?: string }).agentId !== undefined) return next(e)
40    const started = await $.clock.now()
41    const ran = await next(e)
42    const ms = (await $.clock.now()) - started
43    const entry: HistoryEntry = { command: e.command, status: statusOf(ran), ms }
44    await update($, entries, list => push(list, entry))
45
46    return ran
47  })
48
49  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
50    const { Box, Text } = $.ui.resolve(e)
51    const list: HistoryEntry[] = await read($, entries)
52    const room = Math.max(1, (e.viewport?.rows ?? 24) - 4)
53
54    return (
55      <Box flexDirection="column">
56        <Text bold>
57          {list.length} command{list.length === 1 ? '' : 's'}
58        </Text>
59        {list.length === 0 && <Text dimColor>No Bash commands yet.</Text>}
60        {list.slice(0, room).map(entry => (
61          <Text>{row(entry, e.props.bodyColumns)}</Text>
62        ))}
63      </Box>
64    )
65  })
66}
67
hooks/history.ts 33 lines
1import type { HistoryEntry } from '../types'
2
3export const CAP = 50
4
5// Bash's result carries no exit code: a non-zero exit comes back as an error whose
6// text starts "Exit code N", so that is where the code is read from.
7export function statusOf(ran: { deny?: string; isError?: boolean; text?: string; result?: unknown }): string {
8  if (ran.deny !== undefined) return 'denied'
9  if (ran.isError === true) {
10    const code = /^Exit code (\d+)/.exec(ran.text ?? '')?.[1]
11    return code === undefined ? '✗' : `✗ ${code}`
12  }
13  const out = ran.result as { interrupted?: boolean } | undefined
14  return out?.interrupted === true ? 'interrupted' : '✓'
15}
16
17export const push = (list: HistoryEntry[], entry: HistoryEntry) => [entry, ...list].slice(0, CAP)
18
19export const duration = (ms: number) => (ms < 1000 ? `${Math.max(0, Math.round(ms))}ms` : `${(ms / 1000).toFixed(1)}s`)
20
21const oneLine = (command: string) => command.trim().split('\n')[0]?.replace(/\s+/g, ' ') ?? ''
22
23// "<status> <command> · <duration>", the command cut so the row fits `columns`.
24export function row(entry: HistoryEntry, columns: number): string {
25  const head = `${entry.status} `
26  const tail = ` · ${duration(entry.ms)}`
27  const room = Math.max(8, columns - head.length - tail.length)
28  const line = oneLine(entry.command)
29  const shown = line.length > room ? line.slice(0, room - 1) + '…' : line
30
31  return head + shown + tail
32}
33
types/index.d.ts 8 lines
1export type HistoryEntry = { command: string; status: string; ms: number }
2
3declare module 'claude-code' {
4  interface PluginState {
5    'command-history': { entries: HistoryEntry[] }
6  }
7}
8