Shows the branch, worktree and name of the file the session last edited on the hint line under the prompt.

A Claude Code mod that shows where the session last edited, on the dim hint line under the prompt:
⏵⏵ bypass permissions on (shift+tab to cycle) · ⎇ my/feature-branch · ~/repos/my-worktree · Button.tsx
cd-able path, ~/… under home. Left out when it's the session's own worktree.Before the first edit it shows the session folder's branch. It follows Claude's Edit, Write and NotebookEdit calls, and edits made through Bash (sed, heredocs, scripts). For a Bash edit it takes the file Claude Code's own diff names; failing that, the most recently written file git status shows dirty in the session's worktree, the last edit's, or one the command names by path. When the label won't fit beside the hint, it takes a line of its own and is cut with … past that line.
In a terminal Claude Code session:
/plugin install where-did-claude-edit --marketplace Squidsword/where-did-claude-edit
Answer y to add the marketplace, then pick the user scope so it loads in every session.
claude plugin validate .
claude plugin test .
Run a session against your working copy with claude --plugin-dir .; saves hot-reload.
MIT. See LICENSE.
hooks/register.ts 180 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface as Engine, Register, ToolCallResult } from 'claude-code'
3
4import type { Label } from '../types'
5
6const label = atom({ plugin: 'where-did-claude-edit', key: 'label' } as const, null as Label)
7
8const SEPARATOR = ' · '
9/**
10 * Cells drawn before the hint that its text leaves out: the indent and the permission-mode pill,
11 * which no hook can read. Sized for the widest (`⏵⏵ bypass permissions on `) plus slack.
12 */
13const PILL_RESERVE = 30
14
15/** The worktree the session runs in; an edit there leaves it out of the label. */
16let sessionTop: string | undefined
17/** The worktree of the last pinned edit, where a Bash edit most likely lands next. */
18let lastTop: string | undefined
19
20/** Most paths in one command looked up as a possible worktree. */
21const MAX_COMMAND_PATHS = 5
22/** Absolute or `~/` paths in a command, the way a `cd`, `-C` or file argument names them. */
23const COMMAND_PATH = /(?:^|[\s"'=])((?:~\/|\/)[^\s"';|&<>()]+)/g
24
25const dirname = (path: string): string => path.slice(0, path.lastIndexOf('/')) || '/'
26const basename = (path: string): string => path.slice(path.lastIndexOf('/') + 1)
27
28/** `~/…` under home, so it stays short and still `cd`-able; absolute otherwise. */
29const tildify = (path: string, home: string | undefined): string =>
30 home !== undefined && (path === home || path.startsWith(`${home}/`))
31 ? `~${path.slice(home.length)}`
32 : path
33
34async function git($: Engine, dir: string | undefined, ...args: string[]): Promise<string | undefined> {
35 const ran = await $.process.run(['git', ...(dir === undefined ? [] : ['-C', dir]), ...args])
36 return ran.exitCode === 0 ? ran.stdout.trim() : undefined
37}
38
39async function pin($: Engine, filePath?: string): Promise<void> {
40 const dir = filePath === undefined ? undefined : dirname(filePath)
41 const [toplevel, current] = await Promise.all([
42 git($, dir, 'rev-parse', '--show-toplevel'),
43 git($, dir, 'branch', '--show-current'),
44 ])
45 if (toplevel === undefined) {
46 return
47 }
48 if (filePath !== undefined) {
49 lastTop = toplevel
50 }
51 // Empty on a detached HEAD, where the commit names it instead.
52 const branch = current || (await git($, dir, 'rev-parse', '--short', 'HEAD'))
53 const parts = [
54 `⎇ ${branch}`,
55 ...(toplevel === sessionTop ? [] : [tildify(toplevel, await $.env.get('HOME'))]),
56 ...(filePath === undefined ? [] : [basename(filePath)]),
57 ]
58 await update($, label, () => parts.join(SEPARATOR))
59}
60
61/** Hands the edit's result back, re-pinning first when the edit went through. */
62async function pinAfter($: Engine, ran: ToolCallResult, filePath: string): Promise<ToolCallResult> {
63 if (ran.deny === undefined && ran.isError !== true) {
64 await pin($, filePath)
65 }
66 return ran
67}
68
69/** The worktrees a Bash command could have edited: the session's, the last edit's, and any it names. */
70async function candidateTops($: Engine, command: string): Promise<string[]> {
71 const home = await $.env.get('HOME')
72 const named = [...command.matchAll(COMMAND_PATH)]
73 .flatMap(([, path = '']) => (path.startsWith('~/') && home !== undefined ? home + path.slice(1) : path))
74 .slice(0, MAX_COMMAND_PATHS)
75 const tops = await Promise.all(
76 named.map(async path => {
77 const isDir = (await $.fs.stat(path).catch(() => undefined))?.kind === 'dir'
78 return git($, isDir ? path : dirname(path), 'rev-parse', '--show-toplevel')
79 }),
80 )
81 return [...new Set([sessionTop, lastTop, ...tops].filter(top => top !== undefined))]
82}
83
84/** The dirty file in `top` written most recently, if that was at or after `since`. */
85async function newestDirty($: Engine, top: string, since: number): Promise<{ path: string; mtimeMs: number } | undefined> {
86 // Untrimmed: an entry's status can open with a space (` M path`).
87 const status = await $.process.run(['git', '-C', top, 'status', '--porcelain', '-z'])
88 if (status.exitCode !== 0) {
89 return undefined
90 }
91 // `XY path` entries; a rename or copy follows its with the original path, skipped here.
92 const entries = status.stdout.split('\0')
93 const paths: string[] = []
94 for (let i = 0; i < entries.length; i++) {
95 const entry = entries[i] ?? ''
96 if (entry.length < 4 || entry[0] === 'D' || entry[1] === 'D') {
97 continue
98 }
99 paths.push(`${top}/${entry.slice(3).replace(/\/$/, '')}`)
100 if (entry[0] === 'R' || entry[0] === 'C') {
101 i++
102 }
103 }
104 const stats = await Promise.all(
105 paths.map(async path => ({ path, mtimeMs: (await $.fs.stat(path).catch(() => undefined))?.mtimeMs ?? 0 })),
106 )
107 const newest = stats.reduce<(typeof stats)[number] | undefined>(
108 (best, stat) => (best === undefined || stat.mtimeMs > best.mtimeMs ? stat : best),
109 undefined,
110 )
111 return newest !== undefined && newest.mtimeMs >= since ? newest : undefined
112}
113
114/** The file a Bash command edited: the engine's own diff when it has one, else git's newest dirty file. */
115async function bashEditedFile($: Engine, command: string, since: number, ran: ToolCallResult): Promise<string | undefined> {
116 const diff = (ran.result as { bashEditDiff?: { files?: { filePath: string }[]; changedFiles?: string[] } } | undefined)
117 ?.bashEditDiff
118 const reported = diff?.changedFiles ?? diff?.files?.map(file => file.filePath)
119 if (reported !== undefined && reported.length > 0) {
120 return reported[reported.length - 1]
121 }
122 const found = await Promise.all((await candidateTops($, command)).map(top => newestDirty($, top, since)))
123 return found.reduce<(typeof found)[number]>(
124 (best, file) => (file !== undefined && (best === undefined || file.mtimeMs > best.mtimeMs) ? file : best),
125 undefined,
126 )?.path
127}
128
129export const register: Register = on => {
130 // Starts on the session folder's branch; a reload keeps the last edit's.
131 on('session.start', async ($, e, next) => {
132 sessionTop = await git($, undefined, 'rev-parse', '--show-toplevel')
133 if ((await read($, label)) === null) {
134 await pin($)
135 }
136 return next(e)
137 })
138
139 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
140 const text = await read($, label)
141 if (text === null) {
142 return next(e)
143 }
144 // The engine joins a tail with ` · ` and cuts it at the row's end, so a label that won't fit
145 // gets a line of its own instead, cut only past that.
146 const fits =
147 e.viewport === undefined ||
148 PILL_RESERVE + e.props.hint.length + SEPARATOR.length + text.length <= e.viewport.columns
149 if (fits) {
150 return next({ ...e, props: { ...e.props, tail: text } })
151 }
152 return {
153 type: 'Box',
154 props: { flexDirection: 'column' },
155 children: [await next(e), { type: 'Text', props: { dimColor: true, wrap: 'truncate-end' }, children: [text] }],
156 }
157 })
158
159 // A `.catch` so a failing pin never costs the tool its result.
160 on('tool.call', { tool: 'Edit' }, async ($, e, next) => pinAfter($, await next(e), e.file_path)).catch(
161 ($, e, next) => next(e),
162 )
163 on('tool.call', { tool: 'Write' }, async ($, e, next) => pinAfter($, await next(e), e.file_path)).catch(
164 ($, e, next) => next(e),
165 )
166 on('tool.call', { tool: 'NotebookEdit' }, async ($, e, next) =>
167 pinAfter($, await next(e), e.notebook_path),
168 ).catch(($, e, next) => next(e))
169 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
170 // A second's slack: a file system's mtime can trail the clock by its granularity.
171 const since = (await $.clock.now()) - 1000
172 const ran = await next(e)
173 if (ran.deny !== undefined || ran.isError === true) {
174 return ran
175 }
176 const filePath = await bashEditedFile($, e.command, since, ran)
177 return filePath === undefined ? ran : pinAfter($, ran, filePath)
178 }).catch(($, e, next) => next(e))
179}
180types/index.d.ts 8 lines1export type Label = string | null
2
3declare module 'claude-code' {
4 interface PluginState {
5 'where-did-claude-edit': { label: Label }
6 }
7}
8