Marks your prompts in the transcript with a colored bar and tinted row, and jumps between them with Option+Up and Option+Down

In a long Claude Code session, your own prompts get buried under pages of output. Prompt Marks puts a colored bar and a tinted background behind every prompt you type, and lets you jump from one prompt to the next with the keyboard.
Needs Claude Code v2.1.287 or later. Jumping needs fullscreen rendering.
/plugin install prompt-marks --marketplace adamcaviness/prompt-marks
Then turn on auto-update: /plugin → Marketplaces → prompt-marks → Enable auto-update. Claude Code leaves it off for marketplaces outside Anthropic's, so without it you keep the version you first installed.
It's also in agentic-marketplace, and agentic-toolkit installs it for you.
Open /config and look for the rows starting with Prompt Marks:.
| Option | Default | What it does | | :- | :- | :- | | accent_color | Purple | Bar color: type Purple, Indigo, Blue, Teal, Green, Olive, Amber, Orange, Red, Pink, Slate, or Custom (case doesn't matter). Anything else falls back to Purple. The row tint is mixed from it. | | custom_color | #5b2a86 | Hex color used when accent_color is Custom. | | enabled | on | Turns Prompt Marks off entirely. If agentic-toolkit installed it, use this rather than disabling the plugin, since disabling it also disables the toolkit. | | navigation | on | Jumps between prompts with the keys below. | | styling | on | Draws the bar, with the ❯ pointer on its first row, and the tinted row behind each prompt. The pointer is the accent lightened toward white. | | tint_strength | 22 | Percent of the accent mixed into the background behind each prompt. |
Terminals can't do transparency, so the tint is the accent blended with #1e1e1e on dark themes and #ffffff on light ones. If it looks off against your background, adjust tint_strength.
Mods can't add their own keybinding actions, so Prompt Marks listens on two that already exist: app:diffFileListUp and app:diffFileListDown. Both Ctrl and Option (Alt) with Up/Down are bound to them by default. While the /diff panel is open, the keys scroll its file list as usual.
| Platform | Use | Why | | :- | :- | :- | | macOS | Option+Up/Down | macOS reserves Ctrl+Up/Down for Mission Control | | Windows | Ctrl+Up/Down | Windows Terminal uses Alt+arrows to move between split panes |
Any other key can be bound to the same actions in ~/.claude/keybindings.json. Cmd+Up/Down only works if your terminal passes Cmd through (in iTerm2: Profiles > Keys > Report keys using CSI u) and doesn't already use those keys:
{
"bindings": [
{ "context": "Global", "bindings": { "cmd+up": "app:diffFileListUp", "cmd+down": "app:diffFileListDown" } }
]
}
Prompt Marks runs no programs, reads and writes no files, and sends nothing off your machine. It never changes a setting or a permission decision. Each hook:
| Hook | When | What it does | | :- | :- | :- | | session.start | A session starts | Reads your theme setting to pick the dark or light tint. | | classic.SessionStart | /clear, resume, or fork | Forgets the prompts it was tracking for navigation, then passes the event on unchanged. | | config.set (key theme only) | You change the theme | Reads the new theme to recompute the tint and redraws, then passes the change on unchanged. It never blocks or alters the setting. | | ui.render (UserMessage) | A prompt row is drawn | Draws your own typed prompts with the accent bar and tint, and records where they are for navigation. Notifications and agent messages keep the default row. | | ui.render (AbovePrompt) | The area above the prompt is drawn | Adds two hidden buttons bound to the navigation keys. Nothing visible is drawn. |
claude --plugin-dir . # load this checkout for one session
claude plugin validate --strict .
claude plugin test .
npx -p typescript tsc -p . # after one load has written .claude-plugin/typeshooks/register.tsx 123 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { ACCENTS, blend, resolveAccent } from './color'
4import { pickTarget, type Visible } from './navigation'
5
6const DEFAULT_TINT_STRENGTH = 22
7// Assumed terminal backgrounds for the theme family; the tint is mixed over these.
8const DARK_BACKGROUND = '#1e1e1e'
9const LIGHT_BACKGROUND = '#ffffff'
10// The ❯ pointer sits on the accent bar, so it is lightened toward white to read against the accent on any theme.
11const POINTER = '❯'
12const POINTER_LIGHTNESS = 0.65
13// Engine keybinding actions borrowed for navigation: Ctrl+Up/Down and Option(Alt)+Up/Down by default.
14const ACTION_PREVIOUS = 'app:diffFileListUp'
15const ACTION_NEXT = 'app:diffFileListDown'
16// Rows the person typed (or sent from Remote Control); notifications and agent messages stay unstyled.
17const OWN_ORIGINS: ReadonlySet<string> = new Set(['composer', 'bridge'])
18
19let accent = ACCENTS.Purple!
20let strength = DEFAULT_TINT_STRENGTH
21let tint = blend(accent, DARK_BACKGROUND, DEFAULT_TINT_STRENGTH / 100)
22let pointer = blend('#ffffff', accent, POINTER_LIGHTNESS)
23
24// Prompt message ids in transcript order, as first drawn, and which of them the viewport shows.
25let prompts: string[] = []
26let visible = new Map<string, Visible>()
27let cursor = -1
28
29function reset() {
30 prompts = []
31 visible = new Map()
32 cursor = -1
33}
34
35function applyTheme(theme: unknown) {
36 const isLight = typeof theme === 'string' && theme.startsWith('light')
37 tint = blend(accent, isLight ? LIGHT_BACKGROUND : DARK_BACKGROUND, strength / 100)
38}
39
40async function loadTheme($: EngineInterface) {
41 applyTheme((await $.config.list()).find(row => row.key === 'theme')?.value)
42}
43
44function track(id: string, onScreen: Visible | null | undefined) {
45 if (!prompts.includes(id)) prompts.push(id)
46 if (onScreen) visible.set(id, onScreen)
47 else visible.delete(id)
48}
49
50// A refused target (its row is not drawn right now) is skipped, and the jump
51// tries the next prompt in the same direction.
52async function jump($: EngineInterface, direction: -1 | 1) {
53 for (let target = pickTarget(prompts, visible, cursor, direction); target >= 0 && target < prompts.length; target += direction) {
54 const result = await $.ui.scroll({ to: { requestId: prompts[target]! }, block: 'start' })
55 if (!result.deny) {
56 cursor = target
57 return
58 }
59 }
60 $.ui.toast(direction === -1 ? 'No earlier prompt' : 'No later prompt')
61}
62
63export const register: Register = (on, options) => {
64 // A change in /config reloads the module, so options are read once here.
65 if (options.enabled === false) return
66 accent = resolveAccent(options.accent_color, options.custom_color)
67 pointer = blend('#ffffff', accent, POINTER_LIGHTNESS)
68 if (typeof options.tint_strength === 'number') strength = options.tint_strength
69
70 on('session.start', async ($, e, next) => {
71 await loadTheme($)
72 return next(e)
73 })
74
75 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
76 reset()
77 return next(e)
78 }).catch(($, e, next) => next(e))
79
80 // Reads the theme being set to recompute the tint, then passes the change on unchanged.
81 on('config.set', { key: 'theme' }, async ($, e, next) => {
82 applyTheme(e.value)
83 $.ui.invalidate('ui.render')
84 return next(e)
85 }).catch(($, e, next) => (next.called ? undefined : next(e)))
86
87 const styling = options.styling !== false
88 const navigation = options.navigation !== false
89
90 // Navigation learns the prompts from these rows, so the hook runs when either feature is on.
91 if (styling || navigation) on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
92 if (!OWN_ORIGINS.has(e.props.origin.kind)) return next(e)
93 if (navigation) track(e.requestId, e.props.onScreen)
94 if (!styling) return next(e)
95 const { Box, Text } = $.ui.resolve(e)
96 return (
97 <Box flexDirection="row" marginTop={1} width="100%">
98 <Box width={1} backgroundColor={accent}>
99 <Text color={pointer} backgroundColor={accent}>{POINTER}</Text>
100 </Box>
101 <Box flexGrow={1} paddingX={1} backgroundColor={tint}>
102 <Text>{e.props.text}</Text>
103 </Box>
104 </Box>
105 )
106 })
107
108 // Hidden Buttons hold the navigation chords without drawing anything.
109 if (navigation) on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
110 const theirs = await next(e)
111 const { Box, Button } = $.ui.resolve(e)
112 return (
113 <Box flexDirection="column">
114 {theirs}
115 <Box display="none">
116 <Button key="previous-prompt" label="previous prompt" action={ACTION_PREVIOUS} onPress={() => jump($, -1)} />
117 <Button key="next-prompt" label="next prompt" action={ACTION_NEXT} onPress={() => jump($, 1)} />
118 </Box>
119 </Box>
120 )
121 })
122}
123hooks/color.ts 51 lines1// Terminals have no alpha channel, so a "translucent" tint is computed by
2// mixing the accent color into the background the terminal is assumed to show.
3
4const HEX = /^#?([0-9a-f]{6})$/i
5
6export const isHex = (value: string): boolean => HEX.test(value)
7
8const channels = (hex: string): [number, number, number] => {
9 const digits = HEX.exec(hex)?.[1] ?? '000000'
10 return [0, 2, 4].map(at => parseInt(digits.slice(at, at + 2), 16)) as [number, number, number]
11}
12
13const toHex = (rgb: readonly number[]): string =>
14 '#' + rgb.map(c => Math.round(c).toString(16).padStart(2, '0')).join('')
15
16/** Mixes `amount` (0..1) of `color` over `background`. */
17export const blend = (color: string, background: string, amount: number): string => {
18 const top = channels(color)
19 const bottom = channels(background)
20 const a = Math.min(1, Math.max(0, amount))
21 return toHex(top.map((c, i) => c * a + bottom[i]! * (1 - a)))
22}
23
24// Named accents offered in /config, each dark enough to tint over a dark or light background.
25export const ACCENTS: Readonly<Record<string, string>> = {
26 Purple: '#5b2a86',
27 Indigo: '#3f3d9e',
28 Blue: '#1f5fae',
29 Teal: '#127a7a',
30 Green: '#2e7d32',
31 Olive: '#6b7a1f',
32 Amber: '#b7791f',
33 Orange: '#c2571a',
34 Red: '#b3261e',
35 Pink: '#b0306a',
36 Slate: '#4a5568',
37}
38
39export const CUSTOM = 'Custom'
40
41/**
42 * The hex for a named accent, or `custom` when Custom is picked and valid, else Purple.
43 * The name is typed freely in /config, so case and surrounding spaces are ignored.
44 */
45export const resolveAccent = (name: unknown, custom: unknown): string => {
46 const typed = typeof name === 'string' ? name.trim().toLowerCase() : ''
47 if (typed === CUSTOM.toLowerCase() && typeof custom === 'string' && isHex(custom)) return custom.startsWith('#') ? custom : `#${custom}`
48 const match = Object.keys(ACCENTS).find(key => key.toLowerCase() === typed)
49 return (match && ACCENTS[match]) || ACCENTS.Purple!
50}
51hooks/navigation.ts 31 lines1// Which prompt a jump reveals, from where the prompts sit relative to the viewport.
2
3export type Visible = { first: number; last: number; of: number }
4
5/**
6 * Index of the prompt to reveal, or -1 when there is none in that direction.
7 *
8 * Relative to the topmost prompt on screen: Up reveals that prompt when its top
9 * is scrolled away, else the one before it; Down reveals the one after it. With
10 * no prompt on screen, it steps from the last jump, or starts at the newest.
11 */
12export function pickTarget(
13 prompts: readonly string[],
14 visible: ReadonlyMap<string, Visible>,
15 cursor: number,
16 direction: -1 | 1,
17): number {
18 const top = prompts.findIndex(id => visible.has(id))
19 const target =
20 top === -1
21 ? cursor === -1
22 ? prompts.length - 1
23 : cursor + direction
24 : direction === 1
25 ? top + 1
26 : visible.get(prompts[top]!)!.first > 0
27 ? top
28 : top - 1
29 return target >= 0 && target < prompts.length ? target : -1
30}
31