SLOPSHOPPER

where-did-claude-edit

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

newspinnerguardprocess
v1.1.0MITupdated 2026-10-15Squidsword/where-did-claude-edit
A shopper browsing a rack in a slop shop
README

where-did-claude-edit

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
  • Branch: the edited file's branch, or its short commit on a detached HEAD.
  • Worktree: its root as a cd-able path, ~/… under home. Left out when it's the session's own worktree.
  • File: the edited file's name.

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.

Install

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.

Develop

claude plugin validate .
claude plugin test .

Run a session against your working copy with claude --plugin-dir .; saves hot-reload.

License

MIT. See LICENSE.

Source 2 files
hooks/register.ts 180 lines
1import { 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}
180
types/index.d.ts 8 lines
1export type Label = string | null
2
3declare module 'claude-code' {
4  interface PluginState {
5    'where-did-claude-edit': { label: Label }
6  }
7}
8