SLOPSHOPPER

Prompt Marks

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

newbandrowstoast
★ 1v0.4.0MITupdated 2026-10-08AdamCaviness/prompt-marks
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · prompt-marks
❯ fix the failing auth test and add an audit log call ⏺ 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 ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩
Your message
❯ fix the failing auth test and add an audit log call
README

Prompt Marks

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.

  • Option+Up and Option+Down on macOS, Ctrl+Up and Ctrl+Down on Windows and Linux.
  • Nothing extra is drawn on screen. Only your prompt rows change.

Needs Claude Code v2.1.287 or later. Jumping needs fullscreen rendering.

Install

/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.

Configure

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.

Keys

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" } }
  ]
}

What it hooks

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. |

Develop

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/types
Source 3 files
hooks/register.tsx 123 lines
1import 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}
123
hooks/color.ts 51 lines
1// 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}
51
hooks/navigation.ts 31 lines
1// 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