Shows the branch, uncommitted files and unpushed commits in the footer.

Small Claude Code mods that add live info to the footer, next to the mode labels.
| Mod | Shows | Example |
|---|---|---|
| cwd-status | The folder Claude is working in, including where a Bash command cds to | ๐ ~/prj/app |
| git-status | Branch, uncommitted files and unpushed commits of that folder's repo | โ main โ3 โ2 |
Together they read like ๐ ~/prj/app โ main โ3 โ2.
In Claude Code, add this marketplace once:
/plugin marketplace add choas/claude-mods
Then install one or both mods:
/plugin install cwd-status@my-mods
/plugin install git-status@my-mods
Run /reload-plugins (or restart Claude Code) and the labels show up in the footer.
You can also browse and install them interactively with /plugin โ Marketplaces โ my-mods.
/plugin marketplace update my-mods
/plugin uninstall cwd-status@my-mods
/plugin uninstall git-status@my-mods
Shows ๐ <folder> with your home folder shortened to ~.
cds or pushdes somewhere (cd ../other && make), it shows that folder until the next tool runs.Shows the git state of the current folder:
| Part | Meaning |
|---|---|
โ main | Current branch (short commit hash on a detached HEAD) |
โ3 | Uncommitted entries: modified, staged, renamed, conflicted and untracked |
โ2 | Commits not pushed yet: ahead of the upstream, or, without an upstream, commits on no remote |
โ and โ only appear when non-zero; outside a git repo nothing is shown.cd inside Bash commands on its own, so it works without cwd-status.git status --porcelain=v2 --branch with optional locks off and a 5 s timeout, so it never blocks your repo.Requires git on your PATH.
Each mod is a plugin folder with a hooks module (hooks/register.ts), a state contract (types/index.d.ts) and tests:
claude plugin validate git-status
claude plugin test git-status
To try local changes, point Claude Code at the folder: claude --plugin-dir ./git-status.
MIT
hooks/register.ts 130 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4const label = atom({ plugin: 'git-status', key: 'label' } as const, null)
5
6export type GitSummary = { branch: string; changed: number; ahead: number | undefined }
7
8// Reads `git status --porcelain=v2 --branch`; `ahead` is undefined when there is no upstream.
9export function parseStatus(out: string): GitSummary {
10 let branch = '?'
11 let oid = ''
12 let changed = 0
13 let ahead: number | undefined
14 for (const line of out.split('\n')) {
15 if (line.startsWith('# branch.oid ')) oid = line.slice(13)
16 else if (line.startsWith('# branch.head ')) branch = line.slice(14)
17 else if (line.startsWith('# branch.ab ')) ahead = Number(line.match(/\+(\d+)/)?.[1] ?? 0)
18 else if (/^[12u?] /.test(line)) changed++
19 }
20 if (branch === '(detached)') branch = oid.slice(0, 7) || 'detached'
21 return { branch, changed, ahead }
22}
23
24export function format({ branch, changed, ahead }: GitSummary): string {
25 const parts = [`โ ${branch}`]
26 if (changed) parts.push(`โ${changed}`)
27 if (ahead) parts.push(`โ${ahead}`)
28 return parts.join(' ')
29}
30
31// Joins `path` onto `base` the way `cd` would: `~` is home, `.` and `..` fold away.
32export function resolveDir(base: string, path: string, home: string | undefined): string {
33 const expanded = home && (path === '~' || path.startsWith('~/')) ? home + path.slice(1) : path
34 const parts: string[] = []
35 for (const part of (expanded.startsWith('/') ? expanded : `${base}/${expanded}`).split('/')) {
36 if (part === '..') parts.pop()
37 else if (part !== '' && part !== '.') parts.push(part)
38 }
39 return `/${parts.join('/')}`
40}
41
42// The folder the last top-level `cd`/`pushd` in a Bash command leaves it in, if any.
43export function cdTarget(command: string, base: string, home: string | undefined): string | undefined {
44 let dir: string | undefined
45 for (const step of command.split(/&&|\|\||;|\n/)) {
46 const m = step.trim().match(/^(?:cd|pushd)(?:\s+(?:--\s+)?("[^"]*"|'[^']*'|\S+))?\s*$/)
47 if (!m) continue
48 const arg = m[1]?.replace(/^(["'])(.*)\1$/, '$2')
49 if (arg === '-') continue
50 dir = arg === undefined ? home : resolveDir(dir ?? base, arg, home)
51 }
52 return dir
53}
54
55async function git($: EngineInterface, cwd: string, ...args: string[]) {
56 return $.process.run(['git', ...args], { cwd, timeoutMs: 5000, env: { GIT_OPTIONAL_LOCKS: '0' } })
57}
58
59// The folder the label describes: the last Bash command's `cd` target, else the session's.
60let followed: string | undefined
61
62async function refresh($: EngineInterface) {
63 let next: string | null = null
64 try {
65 const cwd = followed ?? (await $.session.cwd())
66 const status = await git($, cwd, 'status', '--porcelain=v2', '--branch')
67 if (status.exitCode === 0) {
68 const summary = parseStatus(status.stdout)
69 // No upstream: count the commits that are on no remote at all.
70 if (summary.ahead === undefined) {
71 const local = await git($, cwd, 'rev-list', '--count', 'HEAD', '--not', '--remotes')
72 summary.ahead = local.exitCode === 0 ? Number(local.stdout.trim()) || 0 : 0
73 }
74 next = format(summary)
75 }
76 } catch {
77 // git missing or too slow: show nothing
78 }
79 if ((await read($, label)) !== next) await update($, label, () => next)
80}
81
82export const register: Register = on => {
83 on('session.start', async ($, e, next) => {
84 const result = await next(e)
85 await refresh($)
86 return result
87 })
88
89 // A commit made outside Claude shows up with the next prompt.
90 on('prompt.submit', async ($, e, next) => {
91 const result = await next(e)
92 await refresh($)
93 return result
94 })
95
96 // A `cd` inside a Bash command moves that command only, so follow where it ran
97 // and keep showing that repo until another tool or a real folder change.
98 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
99 const before = await $.session.cwd()
100 const result = await next(e)
101 const target = cdTarget(e.command, before, await $.env.get('HOME'))
102 const after = await $.session.cwd()
103 followed = after !== before || !target ? undefined : target
104 await refresh($)
105 return result
106 })
107
108 on('tool.call', async ($, e, next) => {
109 const result = await next(e)
110 if (e.tool === 'Edit' || e.tool === 'Write' || e.tool === 'NotebookEdit') {
111 followed = undefined
112 await refresh($)
113 }
114 return result
115 })
116
117 on('classic.CwdChanged', async ($, e, next) => {
118 const result = await next(e)
119 followed = undefined
120 await refresh($)
121 return result
122 })
123
124 // Shown with the footer's mode labels, next to cwd-status.
125 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
126 const text = await read($, label)
127 return text === null ? next(e) : next({ ...e, props: { ...e.props, modes: [...e.props.modes, text] } })
128 })
129}
130types/index.d.ts 8 lines1export type GitStatusLabel = string | null
2
3declare module 'claude-code' {
4 interface PluginState {
5 'git-status': { label: GitStatusLabel }
6 }
7}
8