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

A pane listing the Bash commands the main conversation ran, with outcome and duration.
Bash call of the main conversation (subagent calls are ignored).<status> <command> · <duration>, newest first, first line of the command only, cut to the pane width.✓ success, ✗ N non-zero exit code (read from the Exit code N error text), ✗ error without a code, denied, interrupted.340ms or 2.4s. Only the last 50 commands are kept.| Command | Effect |
|---|---|
/command-history | Open the pane. |
/command-history close | Close it (off also works). |
/command-history clear | Empty the history. |
claude --plugin-dir /path/to/ModsTools/mods/command-history
✗.claude plugin validate mods/command-history
claude plugin test mods/command-history # 6 testshooks/register.tsx 67 lines1import { 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}
67hooks/history.ts 33 lines1import 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}
33types/index.d.ts 8 lines1export type HistoryEntry = { command: string; status: string; ms: number }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'command-history': { entries: HistoryEntry[] }
6 }
7}
8