SLOPSHOPPER

touched-files

A pane listing the files read and edited this turn, with each one's diff, in Vesuvius colors

newpaneguardcommandtoastprompt
v0.1.0no licenseupdated 2026-10-10pompeitech/affreschi/plugins/touched-files
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · touched-files
│ ┃ Touched files ✕ › fix the failing auth ╭───────────────────────────────────────────╮ │ ┃ 3 files · 3 edited │ touched-files │ │ ┃ ⏺ Read(src/auth.ts) │ 3 files edited this turn · /touched-files │ │ ┃ file-0 ⎿ Read 6 lines ╰───────────────────────────────────────────╯ │ ┃ file-1 ⏺ Update(src/auth.ts) │ ┃ file-2 ⎿ 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 │ │ › /touched-files │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Touched files
3 files · 3 edited file-0 file-1 file-2
README

affreschi

Claude Code interface mods, collected in a private pompeitech marketplace. Just as frescoes decorated the walls of Pompeii's houses, these mods decorate Claude Code's interface.

Layout

affreschi/
├── .claude-plugin/marketplace.json   # the list of mods
└── plugins/
    └── <mod-name>/                   # one folder (one plugin) per mod
        ├── .claude-plugin/plugin.json
        ├── hooks/
        │   ├── hooks.json
        │   ├── palette.ts            # Vesuvius colors
        │   └── register.tsx
        ├── types/index.d.ts          # the mod's $.state contract, when it has one
        └── tests/

Adding a mod

  1. Create the folder plugins/<mod-name>/ with its files.
  2. Add it to the plugins array in .claude-plugin/marketplace.json:
   { "name": "<mod-name>", "source": "./plugins/<mod-name>", "description": "..." }
  1. Check it: claude plugin validate ./plugins/<mod-name>

Development

Load a mod for the current session only (it reloads when you save):

claude --plugin-dir ./plugins/<mod-name>

Installing

/plugin marketplace add pompeitech/affreschi
/plugin install <mod-name>@affreschi

The mods

ModWhat it doesWhere it shows
vesuvius-themeA spinner with a glowing crater and volcanic words, a stripe of color beside every tool call (magma while it runs, lava when it fails, ash once done), and the end-of-turn line as an eruption (▲ Erupted for 1m 4s)Terminal; on the desktop, only the tool call rows
touched-files/touched-files opens a pane listing the files read (R) and edited (M, with +/-) this turn; press a file to see its diff. At the end of a turn a toast says how many files changedTerminal and desktop
context-mapA band above the prompt with the context window split by category, a percentage that turns from tuff to magma to lava past 60% and 85%, and details for the legendTerminal and desktop

The palette

The colors live in each mod's hooks/palette.ts (a plugin cannot import another plugin's files, so all three keep a copy). They are placeholders, to be replaced with the Vesuvius design system's.

Tests

claude plugin validate ./plugins/<mod-name>
claude plugin test ./plugins/<mod-name>
Source 3 files
hooks/register.tsx 162 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { TouchedFile } from '../types'
5import { VESUVIUS } from './palette'
6
7const PANE = 'touched-files'
8const DIFF_MAX = 40_000
9
10const files = atom({ plugin: 'touched-files', key: 'files' } as const, [])
11const selected = atom({ plugin: 'touched-files', key: 'selected' } as const, null)
12
13type Hunk = {
14  oldStart: number
15  oldLines: number
16  newStart: number
17  newLines: number
18  lines: string[]
19}
20
21// Turns Edit/Write's structured patch into unified hunks that `Code` can draw.
22export function toHunks(patch: readonly Hunk[]): { diff: string; added: number; removed: number } {
23  let added = 0
24  let removed = 0
25  const parts = patch.map(h => {
26    for (const line of h.lines) {
27      if (line.startsWith('+')) added += 1
28      else if (line.startsWith('-')) removed += 1
29    }
30
31    return [`@@ -${h.oldStart},${h.oldLines} +${h.newStart},${h.newLines} @@`, ...h.lines].join('\n')
32  })
33
34  return { diff: parts.join('\n'), added, removed }
35}
36
37// Adds or updates a file, moving it to the top of the list.
38export function recordTouch(list: readonly TouchedFile[], touch: Partial<TouchedFile> & { path: string }): TouchedFile[] {
39  const before = list.find(f => f.path === touch.path)
40  const base: TouchedFile = before ?? { path: touch.path, isRead: false, isEdited: false, added: 0, removed: 0, diff: '' }
41  const diff = touch.diff ? [base.diff, touch.diff].filter(Boolean).join('\n') : base.diff
42  const next: TouchedFile = {
43    path: touch.path,
44    isRead: base.isRead || Boolean(touch.isRead),
45    isEdited: base.isEdited || Boolean(touch.isEdited),
46    added: base.added + (touch.added ?? 0),
47    removed: base.removed + (touch.removed ?? 0),
48    diff: diff.length > DIFF_MAX ? diff.slice(0, DIFF_MAX) : diff,
49  }
50
51  return [next, ...list.filter(f => f.path !== touch.path)].slice(0, 200)
52}
53
54export function splitPath(path: string): { file: string; folder: string } {
55  const i = path.lastIndexOf('/')
56
57  return i < 0 ? { file: path, folder: '' } : { file: path.slice(i + 1), folder: path.slice(0, i + 1) }
58}
59
60export const register: Register = on => {
61  on('session.start', async ($, e, next) => {
62    await $.command.register({
63      name: 'touched-files',
64      description: 'Open the pane listing the files read and edited this turn',
65      immediate: true,
66    })
67
68    return next(e)
69  })
70
71  on('command.run', { command: 'touched-files' }, async $ => {
72    await $.ui.open({ id: PANE, title: 'Touched files', focus: true, closeOnEscape: true })
73
74    return {}
75  })
76
77  // Every new prompt starts a new turn: the list starts over.
78  on('prompt.submit', async ($, e, next) => {
79    await update($, files, () => [])
80    await update($, selected, () => null)
81
82    return next(e)
83  })
84
85  on('tool.call', { tool: ['Read', 'Edit', 'Write', 'NotebookEdit'] }, async ($, e, next) => {
86    const ran = await next(e)
87    if (ran.deny !== undefined || ran.isError) return ran
88
89    if (e.tool === 'Read') {
90      await update($, files, list => recordTouch(list, { path: e.file_path, isRead: true }))
91
92      return ran
93    }
94
95    const path = e.tool === 'NotebookEdit' ? e.notebook_path : e.file_path
96    const patch = (ran.result as { structuredPatch?: Hunk[] } | undefined)?.structuredPatch ?? []
97    await update($, files, list => recordTouch(list, { path, isEdited: true, ...toHunks(patch) }))
98
99    return ran
100  })
101
102  // At the end of a turn, if anything changed, a toast says the pane is there.
103  on('turn.complete', async ($, e, next) => {
104    const list = await read($, files)
105    const edited = list.filter(f => f.isEdited).length
106    if (edited > 0) {
107      $.ui.toast(`${edited} ${edited === 1 ? 'file' : 'files'} edited this turn · /touched-files`)
108    }
109
110    return next(e)
111  })
112
113  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
114    const { Box, Text, Button, Code } = $.ui.resolve(e)
115    const list = await read($, files)
116    const chosen = await read($, selected)
117    const current = list.find(f => f.path === chosen)
118    const edited = list.filter(f => f.isEdited).length
119
120    if (list.length === 0) {
121      return <Text color={VESUVIUS.ash}>No files touched this turn.</Text>
122    }
123
124    return (
125      <Box flexDirection="column" rowGap={1}>
126        <Text color={VESUVIUS.tuff}>
127          {list.length} {list.length === 1 ? 'file' : 'files'} · <Text color={VESUVIUS.magma}>{edited} edited</Text>
128        </Text>
129        <Box flexDirection="column">
130          {list.map((f, i) => {
131            const { file, folder } = splitPath(f.path)
132
133            return (
134              <Button
135                key={`file-${i}`}
136                plain
137                onPress={() => update($, selected, s => (s === f.path ? null : f.path))}
138              >
139                <Text color={f.isEdited ? VESUVIUS.lava : VESUVIUS.gulf} bold>
140                  {f.isEdited ? 'M' : 'R'}
141                </Text>{' '}
142                <Text bold={f.path === chosen}>{file}</Text>{' '}
143                <Text color={VESUVIUS.ash}>{folder}</Text>
144                {f.isEdited ? ' ' : ''}
145                {f.isEdited ? <Text color={VESUVIUS.vineyard}>+{f.added}</Text> : ''}
146                {f.isEdited ? ' ' : ''}
147                {f.isEdited ? <Text color={VESUVIUS.lava}>-{f.removed}</Text> : ''}
148              </Button>
149            )
150          })}
151        </Box>
152        {current !== undefined && current.diff !== '' && (
153          <Code source={current.diff} format="diff" path={current.path} wrap="truncate-end" />
154        )}
155        {current !== undefined && current.diff === '' && (
156          <Text color={VESUVIUS.ash}>{current.isEdited ? 'No differences to show.' : 'Read only, not edited.'}</Text>
157        )}
158      </Box>
159    )
160  })
161}
162
hooks/palette.ts 22 lines
1// Vesuvius palette (placeholder): replace the hex values with the design system's.
2// Every mod in affreschi keeps its own copy, because a plugin cannot import another plugin's files.
3export const VESUVIUS = {
4  lava: '#E4572E',
5  magma: '#F28C28',
6  sulfur: '#F2C14E',
7  tuff: '#C9B79C',
8  ash: '#8A8D91',
9  gulf: '#2E86AB',
10  vineyard: '#7A9E4E',
11} as const
12
13// The order categories take their colors in, warmest first.
14export const SEQUENCE = [
15  VESUVIUS.lava,
16  VESUVIUS.magma,
17  VESUVIUS.sulfur,
18  VESUVIUS.tuff,
19  VESUVIUS.gulf,
20  VESUVIUS.vineyard,
21] as const
22
types/index.d.ts 15 lines
1export type TouchedFile = {
2  path: string
3  isRead: boolean
4  isEdited: boolean
5  added: number
6  removed: number
7  diff: string
8}
9
10declare module 'claude-code' {
11  interface PluginState {
12    'touched-files': { files: TouchedFile[]; selected: string | null }
13  }
14}
15