SLOPSHOPPER

feature-tracker

A /features pane that follows the work of the current session: commits grouped by type (feat, fix...), with hash and the branch each one was made on.

panebandguardcommandprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · feature-tracker
│ ┃ Features ✕ › fix the failing auth test and add an audit log call │ ┃ Features All commits refresh │ ┃ on ⎇ feat/auth-refresh · 0 ⏺ Read(src/auth.ts) │ ┃ No feat commits yet. Switch to "All ⎿ Read 6 lines │ ┃ commits" to see the rest. ⏺ 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 │ ┃ │ ┃ › /features │ ┃ ⎿ feature-tracker: Features pane opened. │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ◆ Features · 0 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
◆ Features · 0
Pane · Features
Features All commits refresh on ⎇ feat/auth-refresh · 0 No feat commits yet. Switch to "All commits" to see the rest.
README

Claude Mods · Feature Tracker

A mod for Claude Code that lets you follow what is being built while the session is running: every commit made in the session, grouped by type (feat, fix, ...), with its hash and the branch it was made on.

Long sessions with Claude produce a lot of changes. Instead of scrolling the transcript or running git log by hand, type /features and a pane shows the session's work at a glance.

What you see

[● Features]  [○ All commits]                         ↻ refresh
on ⎇ feat/patient-app · 3

a4ea5ed  feat (patient-app)  login by phone and birth date
  ⎇ feat/patient-app · 16/10 14:32

039fc61  feat (patient-app)  tabs with real data
  ⎇ feat/patient-app · 16/10 13:05
  • Features (default) lists only feat commits, the main deliveries. All commits also shows fix, chore, refactor, free-text messages, etc.
  • Each commit shows its short hash, type, scope, title, branch and time.
  • A ◆ Features · N button sits above the prompt, on the right. Click it to open the pane (/features does the same).
  • The pane updates by itself after every Bash command that runs git (commit, checkout, pull, ...). ↻ refresh forces it.
  • It follows the session's language: Portuguese when LANG starts with pt, English otherwise.

Install

Needs a Claude Code build with mods (hooks modules) support.

claude plugin marketplace add allanvigiani/claude-mods
claude plugin install feature-tracker@allan-mods --scope user

Run /reload-plugins (or start a new session), then type /features.

To update: claude plugin marketplace update allan-mods && claude plugin update feature-tracker@allan-mods

Use --scope project or --scope local instead of user to enable it only in one repository.

How it works

  • At session start it stores the current time. From then on, "the session's work" means the commits since that moment (git log --since, merges excluded).
  • It reads the commit type and scope from the message, following Conventional Commits: feat(scope): title, fix!: breaking change. Messages that do not follow it are listed as other.
  • The branch is the one checked out the first time the commit was seen, so a commit keeps the branch it was developed on even after you merge or switch branches.
  • It only runs read-only git commands (git log, git rev-parse). It never writes to your repository and never touches the network.

For the best result, write commit messages as feat(...) / fix(...). With free-text messages everything shows up as other and the Features filter will be empty.

Limits

  • It tracks the session it runs in. Commits from before the session started are not listed.
  • Only the branch checked out is read (HEAD). A commit made on another branch is picked up when you switch to it with a git command through Claude.
  • An amended or rebased commit gets a new hash; the old entry stays in the list until the session ends. The list keeps the 200 most recent.
  • Commits made in another terminal are picked up on the next git command through Claude or on ↻ refresh.

Develop

claude --plugin-dir ./plugins/feature-tracker
claude plugin validate ./plugins/feature-tracker
claude plugin test ./plugins/feature-tracker

The commit parsing lives in plugins/feature-tracker/hooks/lib/commits.ts (covered by tests); the pane and hooks are in hooks/register.tsx.

Source 3 files
hooks/register.tsx 162 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Commit } from '../types'
5import { LOG_FORMAT, mergeCommits, parseLog } from './lib/commits'
6
7const PANE = 'feature-tracker'
8
9const since = atom({ plugin: 'feature-tracker', key: 'since' } as const, null)
10const commits = atom({ plugin: 'feature-tracker', key: 'commits' } as const, [])
11const branch = atom({ plugin: 'feature-tracker', key: 'branch' } as const, '')
12const onlyFeatures = atom({ plugin: 'feature-tracker', key: 'onlyFeatures' } as const, true)
13const error = atom({ plugin: 'feature-tracker', key: 'error' } as const, false)
14
15const TEXT = {
16  en: {
17    title: 'Features',
18    features: 'Features',
19    all: 'All commits',
20    refresh: 'refresh',
21    on: 'on',
22    empty: 'No commits in this session yet.',
23    emptyFeatures: 'No feat commits yet. Switch to "All commits" to see the rest.',
24    noRepo: 'This folder is not a git repository.',
25    opened: 'Features pane opened.',
26  },
27  pt: {
28    title: 'Features',
29    features: 'Features',
30    all: 'Todos os commits',
31    refresh: 'atualizar',
32    on: 'em',
33    empty: 'Nenhum commit nesta sessão ainda.',
34    emptyFeatures: 'Nenhum commit feat ainda. Troque para "Todos os commits" para ver o resto.',
35    noRepo: 'Esta pasta não é um repositório git.',
36    opened: 'Painel de features aberto.',
37  },
38}
39
40// Cor de cada tipo de commit (tokens do tema); o resto fica apagado.
41const TYPE_COLOR: Record<string, string> = { feat: 'success', fix: 'warning', perf: 'permission', refactor: 'permission' }
42
43// Painel em cinza quase preto (como o fundo do terminal do Claude), texto claro e cinza neutro legível para o secundário.
44const BG = '#171717'
45const FG = '#e8e8e8'
46const MUTED = '#9b9b9b'
47
48const two = (n: number) => String(n).padStart(2, '0')
49const clock = (ms: number) => {
50  const d = new Date(ms)
51  return `${two(d.getDate())}/${two(d.getMonth() + 1)} ${two(d.getHours())}:${two(d.getMinutes())}`
52}
53
54// Relê o git: commits desde o começo da sessão, na branch atual.
55async function sync($: EngineInterface) {
56  const startedAt = await read($, since)
57  if (startedAt === null) return
58  try {
59    const [log, head] = await Promise.all([
60      $.process.run(['git', 'log', '--no-merges', `--since=${new Date(startedAt).toISOString()}`, `--format=${LOG_FORMAT}`]),
61      $.process.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD']),
62    ])
63    if (log.exitCode !== 0 || head.exitCode !== 0) throw new Error('git')
64    const current = head.stdout.trim()
65    await update($, branch, () => current)
66    await update($, commits, known => mergeCommits(known, parseLog(log.stdout, current)))
67    await update($, error, () => false)
68  } catch {
69    await update($, error, () => true)
70  }
71}
72
73export const register: Register = on => {
74  on('session.start', async ($, e, next) => {
75    await $.command.register({ name: 'features', description: 'Show the commits of this session: features, fixes, hashes and branches' })
76    if ((await read($, since)) === null) await update($, since, () => Date.now())
77    await sync($)
78    return next(e)
79  })
80
81  on('command.run', { command: 'features' }, async $ => {
82    await sync($)
83    await $.ui.open({ id: PANE, title: 'Features' })
84    const lng = (await $.env.get('LANG'))?.toLowerCase().startsWith('pt') ? 'pt' : 'en'
85    return { text: TEXT[lng].opened }
86  })
87
88  // Depois de qualquer Bash que mexa com git, relê (commit, checkout, pull...).
89  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
90    const ran = await next(e)
91    if (/\bgit\b/.test(e.command)) await sync($)
92    return ran
93  })
94
95  // Botão clicável acima do prompt, à direita: abre o painel e mostra quantas features já saíram.
96  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
97    if (e.props.hasSurvey) return next(e)
98    const { Box, Text, Button } = $.ui.resolve(e)
99    const count = (await read($, commits)).filter(c => c.type === 'feat').length
100    const label = `◆ Features · ${count}`
101    return (
102      <Box>
103        <Box flexGrow={1} />
104        <Button key="open-features" plain label={label} onPress={() => $.ui.open({ id: PANE, title: 'Features' })}>
105          <Text color="claude" bold>{label}</Text>
106          <Text dimColor>{' ▲'}</Text>
107        </Button>
108      </Box>
109    )
110  })
111
112  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
113    const { Box, Text, Button } = $.ui.resolve(e)
114    const t = TEXT[(await $.env.get('LANG'))?.toLowerCase().startsWith('pt') ? 'pt' : 'en']
115    const [list, current, isFeatures, hasFailed] = await Promise.all([
116      read($, commits),
117      read($, branch),
118      read($, onlyFeatures),
119      read($, error),
120    ])
121    const shown = isFeatures ? list.filter(c => c.type === 'feat') : list
122    // Cada commit ocupa 3 linhas (com a margem); o resto é cabeçalho e rodapé.
123    const room = Math.max(1, Math.floor(((e.viewport?.rows ?? 24) - 6) / 3))
124
125    const pill = (key: string, label: string, isOn: boolean, onPress: () => unknown) => (
126      <Button key={key} plain label={label} onPress={onPress}>
127        <Text bold={isOn} color={isOn ? 'claude' : MUTED}>{`${isOn ? '●' : '○'} ${label}`}</Text>
128      </Button>
129    )
130    const row = (c: Commit) => (
131      <Box key={c.hash} flexDirection="column" marginTop={1}>
132        <Text>
133          <Text bold color="claude">{c.short}</Text>
134          {'  '}
135          <Text color={TYPE_COLOR[c.type] ?? MUTED}>{c.type + (c.isBreaking ? '!' : '')}</Text>
136          {c.scope !== '' && <Text color={MUTED}>{` (${c.scope})`}</Text>}
137          {'  '}
138          <Text bold color={FG}>{c.title}</Text>
139        </Text>
140        <Text color={MUTED}>{`  ⎇ ${c.branch} · ${clock(c.at)}`}</Text>
141      </Box>
142    )
143
144    return (
145      <Box flexDirection="column" backgroundColor={BG} paddingX={1} minHeight={Math.max(1, (e.viewport?.rows ?? 24) - 2)}>
146        <Box columnGap={3}>
147          {pill('only-features', t.features, isFeatures, () => update($, onlyFeatures, () => true))}
148          {pill('all-commits', t.all, !isFeatures, () => update($, onlyFeatures, () => false))}
149          <Box flexGrow={1} />
150          <Button key="refresh" plain label={t.refresh} onPress={() => sync($)}>
151            <Text color={MUTED}>{`↻ ${t.refresh}`}</Text>
152          </Button>
153        </Box>
154        {current !== '' && <Text color={MUTED}>{`${t.on} ⎇ ${current} · ${shown.length}`}</Text>}
155        {hasFailed && <Text color="warning">{t.noRepo}</Text>}
156        {!hasFailed && shown.length === 0 && <Text color={MUTED}>{isFeatures && list.length > 0 ? t.emptyFeatures : t.empty}</Text>}
157        {shown.slice(0, room).map(row)}
158      </Box>
159    )
160  })
161}
162
hooks/lib/commits.ts 32 lines
1import type { Commit } from '../../types'
2
3// Um commit por registro: hash, data (epoch s) e assunto, separados por \x1f.
4export const LOG_FORMAT = '%H%x1f%ct%x1f%s%x1e'
5
6const CONVENTIONAL = /^(\w+)(?:\(([^)]*)\))?(!)?:\s*(.+)$/
7
8export function parseSubject(subject: string) {
9  const m = CONVENTIONAL.exec(subject)
10  if (m === null) return { type: 'other', scope: '', title: subject, isBreaking: false }
11  return { type: m[1]!.toLowerCase(), scope: m[2] ?? '', title: m[4]!, isBreaking: m[3] === '!' }
12}
13
14export function parseLog(stdout: string, branch: string): Commit[] {
15  return stdout
16    .split('\x1e')
17    .map(record => record.trim())
18    .filter(record => record !== '')
19    .map(record => {
20      const [hash = '', seconds = '0', ...rest] = record.split('\x1f')
21      return { hash, short: hash.slice(0, 7), at: Number(seconds) * 1000, branch, ...parseSubject(rest.join('\x1f')) }
22    })
23}
24
25// Junta o log novo ao que já se conhece: um commit já visto mantém a branch
26// em que foi visto pela primeira vez; os novos ganham a branch atual.
27// ponytail: um amend/rebase gera outro hash e o antigo fica na lista; limite de 200.
28export function mergeCommits(known: Commit[], fresh: Commit[]): Commit[] {
29  const seen = new Set(known.map(c => c.hash))
30  return [...fresh.filter(c => !seen.has(c.hash)), ...known].sort((a, b) => b.at - a.at).slice(0, 200)
31}
32
types/index.d.ts 26 lines
1export type Commit = {
2  hash: string
3  short: string
4  /** Conventional-commit type (feat, fix, chore...), or `other`. */
5  type: string
6  scope: string
7  title: string
8  isBreaking: boolean
9  /** Commit time, epoch milliseconds. */
10  at: number
11  /** Branch checked out when the commit was first seen. */
12  branch: string
13}
14
15declare module 'claude-code' {
16  interface PluginState {
17    'feature-tracker': {
18      since: number | null
19      commits: Commit[]
20      branch: string
21      onlyFeatures: boolean
22      error: boolean
23    }
24  }
25}
26