SLOPSHOPPER

recent-prompts

Shows your last two messages above the prompt and a searchable history pane; click one to scroll the transcript to it.

newpanebandcommandtoastprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · recent-prompts
│ ┃ My messages ✕ › fix the failing auth test and add an audit log call │ ┃ Search : words from your messages ⏎ filter │ ┃ 0 messages ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ 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 │ │ › /prompts │ ⎿ recent-prompts: My messages pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · My messages
Search : words from your messages ⏎ filter 0 messages
README

claude-code-kit

Skills, mods, hooks and a status line for Claude Code, plus a sync engine that keeps your Claude Code config the same on all your computers.

Install

git clone https://github.com/robinalexandre/claude-code-kit.git
cd claude-code-kit
./install.sh            # asks about each part
./install.sh --list     # lists the parts
./install.sh --all      # installs every part
./install.sh statusline file-tools-guard   # installs the named parts

Needs jq. Works on macOS and Linux, and on Windows from Git Bash. The installer copies each part into ~/.claude, adds its hooks to ~/.claude/settings.json and saves whatever it replaces in ~/.claude/backups/before-kit-<date>/. Running it again is safe. Start a new Claude Code session afterwards.

The sync engine installs differently: see sync/README.md.

What's inside

Skills

SkillWhat it doesNeeds
insta360-reelBuilds a vertical Instagram Reel straight from raw Insta360 X-series .insv files, no Insta360 Studio: calibrates the camera mount, finds every action with a motion analysis, lets you pick from numbered candidate clips, then renders them with a rider-locked horizon. Tuned on wingfoil sessions.ffmpeg, python3
instagram-reelBuilds a Reel from a folder of phone videos: reviews every clip, proposes numbered candidates, assembles once, checks the result frame by frame. Can save the result to the Photos library with write-only access.ffmpeg; Photos saving needs macOS and Xcode's swiftc
test-evidenceCaptures proof that a ticket works locally: browser screenshots, a short video of the flow, the network requests and console behind it (through the chrome-devtools MCP), or one app window or screen region, never the whole screen unless asked. Masks tokens and cookies, writes a short test report and, after your yes, attaches it to the Jira issue or as a resolved comment on the Azure DevOps PR.chrome-devtools MCP, jq, perl; video needs ffmpeg
handoffWrites a handoff note so you can continue the current work on your other computer, and picks it up there.the sync engine

Mods

Mods change Claude Code's own interface. The installer loads them through CLAUDE_CODE_PLUGIN_DIRS. Run a mod's tests with claude plugin test mods/<name>.

ModWhat it does
paste-previewShows pasted text in full in the prompt box and the transcript, and previews pasted images above the prompt and under the sent message (macOS, Windows).
recent-promptsShows your last two messages above the prompt, and a searchable history pane; click one to scroll the transcript to it.
wave-checkWhen a reply doesn't start with your marker emoji, asks Claude to check that reply against your CLAUDE.md and fix what slipped.

Hooks

HookEventsWhat it does
file-tools-guardPreToolUse (Bash)Denies shell workarounds for files (cat/head/tail to read, sed -i, sed -n, heredocs, cat > file, python -c) so Claude uses its Read, Edit and Write tools. Pipelines are still allowed.
recent-activityStop, SessionStartKeeps a small record of each session under your dev folder (CLAUDE_DEV_ROOT, default ~/dev) and starts every session with a digest of the last 14 days: titles, branches, PRs, last asks. Lets Claude resolve "the bug from yesterday" across repos.
session-registry + claude-resume-here.zshSessionStart, SessionEndRecords open sessions. In a new JetBrains or Ghostty terminal tab (zsh), offers to resume the session that was left open in that folder.
wave-checkUserPromptSubmit, StopWarns when a reply didn't start with your marker emoji, and reminds Claude on the next prompt.
nocturne-notifymost eventsSends Claude's state to Agent Nocturne (keyboard-backlight signals) without the ~2 s per tool call its own adapter adds.

Status line

statusline-command.sh: folder, git branch (and worktree), model, context used, effort level and the 5-hour rate limit with its reset time, colour-coded.

Sync engine

sync/: keeps CLAUDE.md, skills, mods, hooks, settings, MCP servers (without their secrets), memory and the untracked agent files of your repos the same on every computer, through a private GitHub repo. Secrets are replaced by __SECRET__ before anything leaves the computer.

The marker emoji

The wave-check mod and hook rest on one line in ~/.claude/CLAUDE.md: "Start every message with 🌊" (or any emoji you like). A reply without it is a cheap, visible sign that Claude has started to drift from your instructions, usually deep in a long task. The installer asks for your emoji, stores it as CLAUDE_REPLY_MARKER in the env of ~/.claude/settings.json and offers to add the rule to your CLAUDE.md. Until the variable is set, both parts stay idle.

Installing by hand

Copy the part into the same place under ~/.claude (skills/<name>, mods/<name>, hooks/<file>, statusline-command.sh). For a hook or the status line, merge settings/\<part\>.json into ~/.claude/settings.json, replacing __HOME__ with your home folder. For a mod, add its folder to CLAUDE_CODE_PLUGIN_DIRS in the env of settings.json (:-separated, ; on Windows).

License

MIT

Source 2 files
hooks/register.tsx 254 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { RecentPrompt } from '../types'
5
6const PANE = 'my-messages'
7const TITLE = 'My messages'
8const COMMAND = 'prompts'
9const BAND_COUNT = 2
10const KEPT = 1000
11const WAITING_KEPT = 20
12const HISTORY_BUTTON_WIDTH = 12
13const JUMP_ICON = '↗'
14
15const prompts = atom({ plugin: 'recent-prompts', key: 'prompts' } as const, [])
16const query = atom({ plugin: 'recent-prompts', key: 'query' } as const, '')
17const waiting = atom({ plugin: 'recent-prompts', key: 'waiting' } as const, [])
18
19type Block = { type: string; [field: string]: unknown }
20type Shown = { before: string; match: string; after: string }
21
22const isPerson = (kind: string) => kind === 'composer' || kind === 'bridge'
23
24const flatten = (text: string) => text.replace(/\s+/g, ' ').trim()
25
26const textOf = (blocks: Block[]) => blocks.flatMap(block => (typeof block.text === 'string' ? [block.text] : [])).join('\n')
27
28const clockTime = (milliseconds: number) => {
29  const date = new Date(milliseconds)
30
31  return [date.getHours(), date.getMinutes()].map(part => String(part).padStart(2, '0')).join(':')
32}
33
34const remember = async ($: EngineInterface, id: string, text: string) => {
35  const prompt: RecentPrompt = { id, text, time: clockTime(await $.clock.now()) }
36  await update($, prompts, list => [...list, prompt].slice(-KEPT))
37}
38
39const withoutFirst = (list: string[], text: string) => {
40  const at = list.indexOf(text)
41
42  return at < 0 ? list : [...list.slice(0, at), ...list.slice(at + 1)]
43}
44
45// Folds one UTF-16 unit at a time so indexes in the folded text match the original.
46const fold = (text: string) =>
47  text
48    .split('')
49    .map(unit => unit.normalize('NFD').charAt(0).toLowerCase())
50    .join('')
51
52const truncate = (text: string, width: number) =>
53  text.length > width ? `${text.slice(0, Math.max(0, width - 1))}…` : text
54
55const shownLine = (text: string, search: string, width: number): Shown | null => {
56  const flat = flatten(text)
57  const needle = fold(search.trim())
58
59  if (needle === '') {
60    return { before: truncate(flat, width), match: '', after: '' }
61  }
62
63  const at = fold(flat).indexOf(needle)
64
65  if (at < 0) {
66    return null
67  }
68
69  const start = at + needle.length < width ? 0 : Math.min(at, Math.max(1, at - Math.floor((width - needle.length) / 3)))
70  const prefix = start > 0 ? '…' : ''
71  const room = width - prefix.length
72  const isCut = flat.length - start > room
73  const end = isCut ? start + room - 1 : flat.length
74  const matchEnd = Math.min(end, at + needle.length)
75
76  return {
77    before: prefix + flat.slice(start, at),
78    match: flat.slice(at, matchEnd),
79    after: flat.slice(matchEnd, end) + (isCut ? '…' : ''),
80  }
81}
82
83const jump = async ($: EngineInterface, id: string) => {
84  const { deny } = await $.ui.scroll({ to: { requestId: id }, block: 'start' })
85
86  if (deny !== undefined) {
87    $.ui.toast(`Can't jump to that message: ${deny}`)
88  }
89}
90
91const toggleHistory = async ($: EngineInterface) => {
92  const isOpen = (await $.ui.panes()).some(pane => pane.id === PANE)
93
94  if (isOpen) {
95    await $.ui.close({ id: PANE })
96
97    return 'closed'
98  }
99
100  await $.ui.open({ id: PANE, title: TITLE, focus: true, columns: 56 })
101  await $.ui.scroll({ in: PANE, to: 'end' })
102
103  return 'opened'
104}
105
106export const register: Register = on => {
107  on('session.start', async ($, e, next) => {
108    await $.command.register({ name: COMMAND, description: 'Open or close the searchable list of your messages' })
109
110    return next(e)
111  })
112
113  on('command.run', { command: COMMAND }, async $ => ({ text: `My messages pane ${await toggleHistory($)}.` }))
114
115  // A message typed while a turn runs is stored later, wrapped in the engine's framing, as a queued_command
116  // attachment (through the delivery door when absorbed mid-turn); its text is kept at Enter so that row
117  // can be told apart and listed as typed.
118  on('prompt.submit', async ($, e, next) => {
119    if (e.turnId !== undefined && isPerson(e.origin.kind) && e.text.trim() !== '') {
120      await update($, waiting, list => [...list, e.text].slice(-WAITING_KEPT))
121    }
122
123    return next(e)
124  }).catch(($, e, next) => next(e))
125
126  on('session.append', { door: 'prompt' }, async ($, e, next) => {
127    const stored = await next(e)
128
129    if (!isPerson(e.origin.kind) || e.agentId !== undefined || stored.uuid === undefined) {
130      return stored
131    }
132
133    const blocks: Block[] = stored.message.content
134    const first = blocks.find(block => block.type === 'text')
135    const text = typeof first?.text === 'string' ? first.text : ''
136
137    if (text.trim() !== '') {
138      await update($, waiting, list => withoutFirst(list, text))
139      await remember($, stored.uuid, text)
140    }
141
142    return stored
143  }).catch(($, e, next) => next(e))
144
145  for (const door of ['attachment', 'delivery'] as const) {
146    on('session.append', { door }, async ($, e, next) => {
147      const stored = await next(e)
148
149      if (stored.uuid === undefined || stored.message.name !== 'queued_command' || e.agentId !== undefined) {
150        return stored
151      }
152
153      const shown = flatten(textOf(stored.message.content))
154      const typed = (await read($, waiting)).find(text => shown.includes(flatten(text)))
155
156      if (typed !== undefined) {
157        await update($, waiting, list => withoutFirst(list, typed))
158        await remember($, stored.uuid, typed)
159      }
160
161      return stored
162    }).catch(($, e, next) => next(e))
163  }
164
165  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
166    const beneath = await next(e)
167
168    if (e.props.hasSurvey || e.props.view.agentId !== undefined) {
169      return beneath
170    }
171
172    const recent = (await read($, prompts)).slice(-BAND_COUNT)
173
174    if (recent.length === 0) {
175      return beneath
176    }
177
178    const { Box, Button, Text } = $.ui.resolve(e)
179    const width = Math.max(10, e.props.bodyColumns - 2 - HISTORY_BUTTON_WIDTH)
180
181    return (
182      <Box flexDirection="column">
183        {recent.map((prompt, index) => (
184          <Box key={`band-${index}`} flexDirection="row" justifyContent="space-between">
185            <Box flexDirection="row">
186              <Text color="cyan">❯ </Text>
187              <Button
188                key={`prompt-${index}`}
189                label={truncate(flatten(prompt.text), width)}
190                plain
191                dimColor
192                onPress={() => void jump($, prompt.id)}
193              />
194            </Box>
195            {index === recent.length - 1 && (
196              <Button key="history" label="history" dimColor onPress={() => void toggleHistory($)} />
197            )}
198          </Box>
199        ))}
200        {beneath}
201      </Box>
202    )
203  })
204
205  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
206    if (e.surface !== 'terminal') {
207      return next(e)
208    }
209
210    const { Box, Button, Input, Text } = $.ui.resolve(e)
211    const list = await read($, prompts)
212    const search = await read($, query)
213    const width = Math.max(10, e.props.bodyColumns - 8)
214    const rows = list.flatMap(prompt => {
215      const shown = shownLine(prompt.text, search, width)
216
217      return shown === null ? [] : [{ prompt, shown }]
218    })
219    const setQuery = (value: string) => void update($, query, () => value)
220    const summary = search.trim() === '' ? `${list.length} messages` : `${rows.length} of ${list.length} match`
221
222    return (
223      <Box flexDirection="column">
224        <Input
225          key="search"
226          label="Search "
227          placeholder="words from your messages"
228          value={search}
229          submitLabel="filter"
230          autoFocus
231          onInput={setQuery}
232          onSubmit={setQuery}
233        />
234        <Text dimColor>{summary}</Text>
235        {rows.map(({ prompt, shown }) => (
236          <Box key={`line-${prompt.id}`} flexDirection="row">
237            <Button key={`row-${prompt.id}`} label={JUMP_ICON} plain dimColor onPress={() => void jump($, prompt.id)} />
238            <Text>
239              <Text dimColor> {prompt.time ?? '--:--'} </Text>
240              {shown.before}
241              {shown.match !== '' && (
242                <Text color="yellow" bold>
243                  {shown.match}
244                </Text>
245              )}
246              {shown.after}
247            </Text>
248          </Box>
249        ))}
250      </Box>
251    )
252  })
253}
254
types/index.d.ts 8 lines
1export type RecentPrompt = { id: string; text: string; time?: string }
2
3declare module 'claude-code' {
4  interface PluginState {
5    'recent-prompts': { prompts: RecentPrompt[]; query: string; waiting: string[] }
6  }
7}
8