Pins your latest prompt above the input; /prompts lists this session's prompts

A Claude Code mod that keeps your latest prompt in view.
/prompts opens a list of this session's prompts, newest first, with the time each was sent. Run it again, or press Esc, to close it. It works mid-turn too.Only your own prompts are recorded: ones typed at the terminal, sent over Remote Control, or from an SDK host. Task notifications, messages from other sessions and prompts other plugins submit are left out. The pane keeps the last 100.
claude plugin marketplace add AJMarquez99/prompt-pin
claude plugin install prompt-pin@prompt-pin
Start a new session to load it. Tested with Claude Code 2.1.300.
Without an SSH key for GitHub, add the marketplace by its HTTPS URL instead:
claude plugin marketplace add https://github.com/AJMarquez99/prompt-pin.git
To update to the latest version:
claude plugin marketplace update prompt-pin
claude plugin update prompt-pin@prompt-pin
To uninstall:
claude plugin uninstall prompt-pin@prompt-pin
claude plugin marketplace remove prompt-pin
/resume or /branch, the history is rebuilt from the transcript. The transcript keeps no times, so those rows show none. /clear starts an empty history.claude --plugin-dir . # load the working copy in a session
claude plugin test # run the tests
claude plugin validate . # check the manifest and module
tsconfig.json extends .claude-plugin/types/tsconfig.json, which Claude Code generates (and .gitignore excludes) the first time it loads the mod. Run claude --plugin-dir . once before type-checking with tsc.
The mod lives in hooks/register.tsx; text helpers are in hooks/format.ts.
hooks/register.tsx 111 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { PromptEntry } from '../types'
5import { clip, clock, label, oneLine } from './format'
6
7const PANE = 'prompt-pin'
8const CAP = 100
9// A person's own prompts: typed at the terminal, or sent over Remote Control or the SDK host.
10const USER_ORIGINS: readonly string[] = ['composer', 'bridge', 'sdk']
11// The pane takes the keyboard so Esc closes it, like the engine's own views.
12const OPEN = { id: PANE, title: 'Prompts', focus: true, closeOnEscape: true } as const
13const history = atom({ plugin: 'prompt-pin', key: 'history' } as const, [] as PromptEntry[])
14
15// The toggle is not a prompt worth pinning.
16const isOwnCommand = (text: string): boolean => /^\/prompts(\s|$)/.test(text.trim())
17
18export const register: Register = on => {
19 on('session.start', async ($, e, next) => {
20 await $.command.register({ name: 'prompts', description: "Show this session's prompts in a pane", immediate: true })
21
22 return next(e)
23 })
24
25 // /resume and /branch reset $.state, so rebuild the history from the transcript.
26 // The transcript keeps no times, and other user-role rows (notifications, peer
27 // messages) arrive wrapped in a tag, so those are skipped.
28 on('classic.SessionStart', { source: ['resume', 'fork'] }, async ($, e, next) => {
29 const entries = (await $.session.messages())
30 .filter(m => m.role === 'user' && (m.toolResults ?? []).length === 0)
31 .map(m => m.text.trim())
32 .filter(text => text !== '' && !text.startsWith('<') && !isOwnCommand(text))
33 .map((text): PromptEntry => ({ text }))
34 await update($, history, () => entries.slice(-CAP))
35
36 return next(e)
37 })
38
39 on('command.run', { command: 'prompts' }, async $ => {
40 if ((await $.ui.panes()).some(pane => pane.id === PANE)) {
41 await $.ui.close({ id: PANE })
42
43 return { text: 'Prompts pane closed.' }
44 }
45
46 await $.ui.open(OPEN)
47
48 return { text: 'Prompts pane opened.' }
49 })
50
51 on('prompt.submit', async ($, e, next) => {
52 const out = await next(e)
53
54 // Record what entered, after any later mod's rewrite.
55 if (USER_ORIGINS.includes(e.origin.kind) && out.drop === undefined && !isOwnCommand(out.text)) {
56 const entry: PromptEntry = { text: out.text, at: await $.clock.now() }
57 await update($, history, list => [...list, entry].slice(-CAP))
58 }
59
60 return out
61 })
62
63 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
64 const { Box, Text } = $.ui.resolve(e)
65 const list = [...(await read($, history))].reverse()
66 // Approximates 3 wrapped rows by character count.
67 const max = Math.max(20, e.props.bodyColumns * 3 - 8)
68
69 return (
70 <Box flexDirection="column">
71 {list.length === 0 && <Text dimColor>No prompts yet.</Text>}
72 {list.map((entry, i) => (
73 <Text bold={i === 0} dimColor={i !== 0}>
74 {i === 0 ? '▶ ' : ' '}
75 {clock(entry.at)} {clip(oneLine(label(entry.text)), max)}
76 </Text>
77 ))}
78 </Box>
79 )
80 })
81
82 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
83 const list = await read($, history)
84 const latest = list[list.length - 1]
85
86 if (e.props.hasSurvey || latest === undefined) {
87 return next(e)
88 }
89
90 const { Box, Button, Text } = $.ui.resolve(e)
91 // Leave room for the button; the surface also truncates at the edge.
92 const max = Math.max(10, e.props.bodyColumns - 14)
93 // Keep what the mods after this one draw in the band, under the pin.
94 const theirs = await next(e)
95
96 return (
97 <Box flexDirection="column">
98 <Box flexDirection="row">
99 <Box flexGrow={1}>
100 <Text wrap="truncate-end" dimColor={!e.props.isWorking}>
101 📌 {clip(oneLine(label(latest.text)), max)}
102 </Text>
103 </Box>
104 <Button key="history" label="History" onPress={() => $.ui.open(OPEN)} />
105 </Box>
106 {theirs}
107 </Box>
108 )
109 })
110}
111hooks/format.ts 16 lines1export const oneLine = (text: string): string => text.replace(/\s+/g, ' ').trim()
2
3export const clip = (text: string, max: number): string =>
4 text.length <= max ? text : `${text.slice(0, Math.max(0, max - 1))}…`
5
6export const label = (text: string): string => (text.trim() === '' ? '(attachment)' : text)
7
8// Rebuilt prompts have no time; the blank keeps the column aligned.
9export const clock = (at: number | undefined): string => {
10 if (at === undefined) return ' '
11
12 const d = new Date(at)
13
14 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
15}
16types/index.d.ts 9 lines1// `at` is absent for a prompt rebuilt from the transcript after /resume or /branch.
2export type PromptEntry = { text: string; at?: number }
3
4declare module 'claude-code' {
5 interface PluginState {
6 'prompt-pin': { history: PromptEntry[] }
7 }
8}
9