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

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.
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.
| Skill | What it does | Needs |
|---|---|---|
| insta360-reel | Builds 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-reel | Builds 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-evidence | Captures 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 |
| handoff | Writes a handoff note so you can continue the current work on your other computer, and picks it up there. | the sync engine |
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>.
| Mod | What it does |
|---|---|
| paste-preview | Shows 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-prompts | Shows your last two messages above the prompt, and a searchable history pane; click one to scroll the transcript to it. |
| wave-check | When a reply doesn't start with your marker emoji, asks Claude to check that reply against your CLAUDE.md and fix what slipped. |
| Hook | Events | What it does |
|---|---|---|
| file-tools-guard | PreToolUse (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-activity | Stop, SessionStart | Keeps 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.zsh | SessionStart, SessionEnd | Records open sessions. In a new JetBrains or Ghostty terminal tab (zsh), offers to resume the session that was left open in that folder. |
| wave-check | UserPromptSubmit, Stop | Warns when a reply didn't start with your marker emoji, and reminds Claude on the next prompt. |
| nocturne-notify | most events | Sends Claude's state to Agent Nocturne (keyboard-backlight signals) without the ~2 s per tool call its own adapter adds. |
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/: 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 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.
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).
hooks/register.tsx 254 lines1import { 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}
254types/index.d.ts 8 lines1export 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