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.

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.
[● 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
feat commits, the main deliveries. All commits also shows fix, chore, refactor, free-text messages, etc./features does the same).git (commit, checkout, pull, ...). ↻ refresh forces it.LANG starts with pt, English otherwise.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.
git log --since, merges excluded).feat(scope): title, fix!: breaking change. Messages that do not follow it are listed as other.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 asotherand the Features filter will be empty.
HEAD). A commit made on another branch is picked up when you switch to it with a git command through Claude.git command through Claude or on ↻ refresh.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.
hooks/register.tsx 162 lines1import { 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}
162hooks/lib/commits.ts 32 lines1import 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}
32types/index.d.ts 26 lines1export 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