/jrdraft: a scratch pane to write your next prompt while Claude works, kept per project, sent to the prompt box when you are ready.

While Claude works, you already know what comes next. /jrdraft opens a scratch pane to write it, so the thought is not lost and the prompt box stays free.
jr draft acme-site · 6 words · kept until you send it
┌──────────────────────────────────────────────────────┐
│ then add tests for the parser │
│ │
└──────────────────────────────────────────────────────┘
[ Send to prompt box (s) ] [ Clear (c) ]
| Command | |
|---|---|
/jrdraft | Open the scratch pane |
In the pane: type freely (several lines), Enter or s sends to the prompt box, c clears, Esc closes.
/plugin install jr-draft --marketplace jrlprost/claude-code-mods
Keeps drafts in the plugin's own store and fills the prompt box when you ask. Reads no files, runs nothing, sends nothing over the network.
hooks/register.tsx 101 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { gradientText } from './jr/draw'
5import { GRADIENTS } from './jr/gradient'
6
7const PANE = 'jr-draft'
8const draft = atom({ plugin: 'jr-draft', key: 'text' } as const, '')
9const project = atom({ plugin: 'jr-draft', key: 'project' } as const, '')
10const boot = { isDone: false }
11
12const keyOf = (name: string) => `draft:${name}`
13
14/** Command, project and saved draft, once: at session start or first thing after a mid-session install. */
15async function ensureReady($: EngineInterface) {
16 if (boot.isDone) return
17 boot.isDone = true
18 await $.command.register({ name: 'jrdraft', description: 'A scratch pane to draft your next prompt while Claude works' })
19 const root = await $.session.root()
20 const name = root.split('/').pop() || root
21 await update($, project, () => name)
22 const saved = (await $.store.get(keyOf(name))) as string | undefined
23 if (saved) await update($, draft, () => saved)
24}
25
26async function save($: EngineInterface, text: string) {
27 await update($, draft, () => text)
28 await $.store.set(keyOf(await read($, project)), text)
29}
30
31/** Puts the draft in the prompt box and empties the scratch area. */
32async function sendToPrompt($: EngineInterface) {
33 const text = await read($, draft)
34 if (!text.trim()) return
35 const filled = await $.prompt.fill({ text, mode: 'replace' })
36 if (filled.isFilled) {
37 await save($, '')
38 $.ui.toast('jr draft · in the prompt box: edit it or press Enter')
39 } else {
40 $.ui.toast('jr draft · the prompt box is busy; your draft is kept')
41 }
42}
43
44export const register: Register = on => {
45 on('session.start', async ($, e, next) => {
46 const started = await next(e)
47 await ensureReady($)
48 return started
49 })
50
51 on('ui.render', { component: 'AbovePrompt' }, ($, e, next) => {
52 if (!boot.isDone) {
53 $.clock.after(0, () => {
54 void ensureReady($)
55 })
56 }
57 return next(e)
58 })
59
60 on('command.run', { command: 'jrdraft' }, async $ => {
61 await ensureReady($)
62 await $.ui.open({ id: PANE, title: 'jr draft', focus: true, closeOnEscape: true, rows: 12 })
63 return { text: 'jr draft opened.' }
64 })
65
66 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
67 const elements = $.ui.resolve(e)
68 const { Box, Text, Button } = elements
69 const Input = 'Input' in elements ? elements.Input : undefined
70 const text = await read($, draft)
71 const name = await read($, project)
72 const words = text.trim() ? text.trim().split(/\s+/).length : 0
73 return (
74 <Box flexDirection="column">
75 <Box>
76 {gradientText(Text, 'jr draft', GRADIENTS.brand)}
77 <Text dimColor>{` ${name} · ${words} word${words === 1 ? '' : 's'} · kept until you send it`}</Text>
78 </Box>
79 {Input ? (
80 <Input
81 key="draft"
82 value={text}
83 rows={6}
84 autoFocus
85 placeholder="Draft the next prompt here while Claude works…"
86 submitLabel="to prompt box"
87 onInput={(value: string) => void save($, value)}
88 onSubmit={(value: string) => void save($, value).then(() => sendToPrompt($))}
89 />
90 ) : (
91 <Text>{text || 'Open /jrdraft in the terminal or the desktop app to type here.'}</Text>
92 )}
93 <Box columnGap={2}>
94 <Button key="send" label="Send to prompt box" hotkey="s" onPress={() => void sendToPrompt($)} />
95 <Button key="clear" label="Clear" hotkey="c" onPress={() => void save($, '')} />
96 </Box>
97 </Box>
98 )
99 })
100}
101hooks/jr/draw.tsx 35 lines1// Drawing helpers shared by every jr mod. Synced into each plugin's hooks/jr/.
2import type { Elements } from 'claude-code'
3
4import { bar, paint } from './gradient'
5
6type TextElement = Elements['terminal']['Text']
7
8/** `text` in a gradient, one color per character, bold by default. */
9export const gradientText = (Text: TextElement, text: string, stops: readonly string[], bold = true) => (
10 <Text bold={bold}>
11 {paint(text, stops).map(({ char, color }) => (
12 <Text color={color}>{char}</Text>
13 ))}
14 </Text>
15)
16
17/** A gauge: filled cells in the gradient, empty cells dim. */
18export const gauge = (Text: TextElement, ratio: number, width: number, stops: readonly string[]) => (
19 <Text>
20 {bar(ratio, width, stops).map(cell =>
21 cell.empty ? (
22 <Text color={cell.color} dimColor>
23 {cell.char}
24 </Text>
25 ) : (
26 <Text color={cell.color}>{cell.char}</Text>
27 ),
28 )}
29 </Text>
30)
31
32/** The jr tag that opens every band line: a small gradient label. */
33export const tag = (Text: TextElement, label: string, stops: readonly string[]) =>
34 gradientText(Text, `${label} `, stops)
35hooks/jr/gradient.ts 75 lines1// Shared look of every jr mod: one palette, gradients drawn one cell at a time.
2// Synced into each plugin's hooks/jr/ by scripts/sync-shared.sh: edit it here only.
3
4export type Rgb = readonly [number, number, number]
5
6const hexToRgb = (hex: string): Rgb => {
7 const n = Number.parseInt(hex.replace('#', ''), 16)
8 return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
9}
10
11const rgbToHex = ([r, g, b]: Rgb) =>
12 `#${[r, g, b].map(v => Math.round(v).toString(16).padStart(2, '0')).join('')}`
13
14export const GRADIENTS = {
15 // The jr signature: violet to pink to amber.
16 brand: ['#7f5af0', '#ff5ca8', '#ffb86b'],
17 // Calm to alarm, for gauges where full is bad.
18 heat: ['#2cb67d', '#f4d35e', '#ff8c42', '#ef4565'],
19 // Cool, for neutral figures.
20 ice: ['#3da9fc', '#7f5af0'],
21 // Money and growth.
22 money: ['#2cb67d', '#a8e6cf'],
23} as const satisfies Record<string, readonly string[]>
24
25export type GradientName = keyof typeof GRADIENTS
26
27/** The color at `t` (0..1) along the stops. */
28export const colorAt = (stops: readonly string[], t: number): string => {
29 if (stops.length === 1) return stops[0]!
30 const clamped = Math.min(1, Math.max(0, t))
31 const span = clamped * (stops.length - 1)
32 const i = Math.min(stops.length - 2, Math.floor(span))
33 const a = hexToRgb(stops[i]!)
34 const b = hexToRgb(stops[i + 1]!)
35 const local = span - i
36 return rgbToHex([0, 1, 2].map(k => a[k]! + (b[k]! - a[k]!) * local) as unknown as Rgb)
37}
38
39/** One color per character of `text`, spread across the stops. */
40export const paint = (text: string, stops: readonly string[]) => {
41 const chars = [...text]
42 return chars.map((char, i) => ({ char, color: colorAt(stops, chars.length > 1 ? i / (chars.length - 1) : 0) }))
43}
44
45const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
46
47/**
48 * A bar `width` cells wide filled to `ratio` (0..1), with eighth-cell precision.
49 * Each filled cell takes the gradient color of its position along the whole bar,
50 * so a nearly full heat bar ends red and a low one stays green.
51 */
52export const bar = (ratio: number, width: number, stops: readonly string[]) => {
53 const filled = Math.min(1, Math.max(0, ratio)) * width
54 const full = Math.floor(filled)
55 const partial = EIGHTHS[Math.round((filled - full) * 7)] ?? ''
56 const cells: { char: string; color: string; empty?: true }[] = []
57 for (let i = 0; i < width; i++) {
58 const color = colorAt(stops, width > 1 ? i / (width - 1) : 0)
59 if (i < full) cells.push({ char: '█', color })
60 else if (i === full && partial) cells.push({ char: partial, color })
61 else cells.push({ char: '·', color, empty: true })
62 }
63 return cells
64}
65
66export const formatTokens = (n: number) =>
67 n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 10_000 ? `${Math.round(n / 1000)}k` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`
68
69export const formatDuration = (ms: number) => {
70 const minutes = Math.max(0, Math.round(ms / 60_000))
71 const h = Math.floor(minutes / 60)
72 const m = minutes % 60
73 return h > 0 ? `${h}h${String(m).padStart(2, '0')}` : `${m}min`
74}
75types/index.d.ts 8 lines1export type JrDraftText = string
2
3declare module 'claude-code' {
4 interface PluginState {
5 'jr-draft': { text: JrDraftText; project: string }
6 }
7}
8