Persistent pinned memos and clickable todo checkboxes above the Claude Code prompt

Pin notes and clickable todos above Claude Code's input. Notes and completion states return when you resume the same session in the same folder. 한국어.
Verified using Claude Code 2.1.292 engine tests and declarations. The visual band targets terminal and Desktop; VS Code chat and headless sessions can use commands and persistence.
claude plugin marketplace add YeonwooSung/my-claude-code-mods
claude plugin install pin-memo@my-claude-code-mods
# Or load this checkout for one session:
claude --plugin-dir ./pin-memo
/pin Remember to run tests
/pin - [ ] Check deployment secrets
/pin - [x] Update docs
/pin toggle 2
/pin check 2
/pin uncheck 2
/pin edit 2 - [ ] Check production deployment
/pin remove 1
/pin list
/pin clear
/pin help
Plain /pin lists full notes. Use /pin add <memo> to add content starting with reserved command words. Editing without a checkbox marker converts a todo to a plain note. Multiline input is one memo; the band displays one abbreviated row and /pin list preserves full content. Notes do not enter model context automatically.
The teal rounded band shows note and completion counts. Completed todos remain visible, dimmed and struck through. Click a checkbox or focus the band with ctrl+x tab, then Tab/Enter. No digit shortcuts interfere with typing into the prompt. Collapse and scroll use the engine's controls. Surveys temporarily take priority over the band.
The engine's plugin-owned $.store holds JSON documents under keys combining absolute cwd and session ID. A new session or /clear gets a separate list; resuming the old identity restores its notes. Different symlink spellings are distinct folder identities.
Changes are saved before being shown as successful. Failed writes preserve the previous state and show an error. Invalid saved documents block edits rather than being overwritten; explicit /pin clear resets the current document. There is no automatic expiry.
Up to 100 notes, each at most 2,000 Unicode code points. IDs are never reused after deletion. The engine limits the entire plugin store to 4 MiB. Concurrent editing of the same session by separate processes has no live synchronization or conflict guarantee.
npm test
CLAUDE_BIN=/path/to/claude-2.1.292 npm run test:adapters
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 /path/to/claude-2.1.292 plugin test pin-memo
/path/to/claude-2.1.292 plugin validate pin-memo
npm run typecheck
Typecheck requires matching local engine declarations in .claude-plugin/types. For this implementation the declarations generated by 2.1.292 for an existing mod were copied locally; these files are ignored. Core tests exercise commands, document validation, storage failure/races and Unicode width. Engine tests exercise render trees and checkbox presses on terminal/Desktop. Actual terminal/Desktop pixel captures have not been verified.
hooks/register.tsx 101 lines1import type { EngineInterface, Register } from 'claude-code'
2import { MemoController, storeKey } from './core/controller.ts'
3import { cleanText } from './core/memo.ts'
4import { buildView, cellWidth } from './core/view.ts'
5
6type AdapterState = { controller?: MemoController; identityError?: string; identityRequest: number; appliedRequest: number }
7function paint($: EngineInterface): void { try { $.ui.invalidate('ui.render') } catch { /* The next render still reads saved state. */ } }
8function notify($: EngineInterface, error: unknown): void {
9 try { $.ui.toast(cleanText(error instanceof Error ? error.message : String(error))) } catch { /* Feedback must not affect persistence. */ }
10}
11async function refresh($: EngineInterface, state: AdapterState): Promise<{ controller: MemoController; key: string }> {
12 const request = ++state.identityRequest
13 try {
14 const [cwd, id] = await Promise.all([$.session.cwd(), $.session.id()])
15 const key = storeKey(cwd, id)
16 if (request < state.appliedRequest && state.controller?.snapshot().key !== key) throw new Error('세션 확인 요청이 변경되었습니다.')
17 state.appliedRequest = Math.max(request, state.appliedRequest)
18 state.controller ??= new MemoController({ get: key => $.store.get(key), set: (key, value) => $.store.set(key, value) })
19 state.identityError = undefined
20 await state.controller.activate(cwd, id)
21 if (state.controller.snapshot().key !== key) throw new Error('세션이 바뀌었습니다.')
22 return { controller: state.controller, key }
23 } catch (error) {
24 if (request >= state.appliedRequest) {
25 state.appliedRequest = request
26 state.controller?.detach()
27 state.identityError = '세션 폴더와 ID를 확인할 수 없습니다.'
28 }
29 throw error
30 }
31}
32async function observe($: EngineInterface, state: AdapterState): Promise<void> {
33 try { await refresh($, state) } catch { /* Session startup and turns still proceed. */ }
34 paint($)
35}
36
37export const register: Register = on => {
38 const state: AdapterState = { identityRequest: 0, appliedRequest: 0 }
39 on('session.start', async ($, e, next) => {
40 try { await $.command.register({ name: 'pin', description: 'Pin a memo or todo: <memo> | - [ ] <todo> | list | toggle <id> | help' }) } catch { /* Do not prevent session startup. */ }
41 // Reload the store even when a test or host starts this identity again.
42 state.appliedRequest = ++state.identityRequest
43 state.controller?.detach()
44 await observe($, state)
45 return next(e)
46 })
47 on('session.end', async ($, e, next) => {
48 if (e.reason === 'clear' || e.reason === 'resume') { state.appliedRequest = ++state.identityRequest; state.controller?.detach(); paint($) }
49 const result = await next(e)
50 if (e.reason === 'clear' || e.reason === 'resume') await observe($, state)
51 return result
52 })
53 on('turn.start', async ($, e, next) => {
54 await observe($, state)
55 return next(e)
56 })
57 on('command.run', { command: 'pin' }, async ($, e) => {
58 try {
59 const current = await refresh($, state)
60 const result = await current.controller.run(e.args ?? '', current.key)
61 paint($)
62 return { text: result.text }
63 } catch (error) {
64 paint($)
65 return { text: cleanText(state.identityError ?? (error instanceof Error ? error.message : String(error))) }
66 }
67 })
68 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
69 const existing = await next(e)
70 if (e.props.hasSurvey || (e.surface !== 'terminal' && e.surface !== 'desktop')) return existing
71 try {
72 const snapshot = state.controller?.snapshot()
73 const view = buildView(snapshot?.document, state.identityError ?? snapshot?.error, e.props.bodyColumns, e.props.maxRows)
74 if (!view) return existing
75 const { Box, Text, Button } = $.ui.resolve(e)
76 const check = async (id: number) => {
77 try {
78 const current = await refresh($, state)
79 await current.controller.run(`toggle ${id}`, snapshot?.key, snapshot?.generation)
80 } catch (error) { notify($, error) }
81 paint($)
82 }
83 return <Box flexDirection="column">
84 {existing}
85 <Box key="pin-memo" flexDirection="column" width={e.props.bodyColumns}
86 borderStyle={view.border ? 'round' : undefined} borderColor="#62B8AD"
87 paddingX={view.border ? 1 : 0} backgroundColor="#1D2029">
88 <Box key="pin-title"><Text bold color="#D5C57A" wrap="truncate-end">{view.title}</Text></Box>
89 {view.error ? <Box key="pin-error"><Text color="#E9A09A" wrap="truncate-end">{view.error}</Text></Box> : null}
90 {view.rows.map(row => <Box key={`pin-row-${row.id}`} flexDirection="row">
91 {row.checked !== null && cellWidth(`[ ] ${row.id} `) <= view.innerWidth
92 ? <><Button key={`pin-check-${row.id}`} plain dimColor={row.checked} onPress={async () => { await check(row.id) }}>{row.checked ? '[x]' : '[ ]'}</Button>
93 <Text color="#D6D9E0" dimColor={row.checked} strikethrough={row.checked} wrap="truncate-end">{` ${row.id} ${row.text}`}</Text></>
94 : <Text color="#D6D9E0" dimColor={row.checked === true} strikethrough={row.checked === true} wrap="truncate-end">{row.prefix + row.text}</Text>}
95 </Box>)}
96 </Box>
97 </Box>
98 } catch { return existing }
99 })
100}
101hooks/core/controller.ts 68 lines1import { decodeDocument, emptyDocument, execute, type MemoDocument, type CommandResult } from './memo.ts'
2export type Storage = { get(key: string): Promise<unknown>; set(key: string, value: unknown): Promise<void> }
3type Context = { key: string; generation: number; ready: Promise<void>; tail: Promise<void>; document?: MemoDocument; error?: string }
4
5export function storeKey(cwd: string, id: string): string {
6 if (!cwd || !id || !/^(?:\/|[A-Za-z]:[\\/])/.test(cwd)) throw new Error('세션 폴더와 ID를 확인할 수 없습니다.')
7 const folder = cwd.startsWith('/') ? cwd.replace(/\/+$/, '') || '/'
8 : /^[A-Za-z]:[\\/]$/.test(cwd) ? cwd : cwd.replace(/[\\/]+$/, '')
9 return `pin-memo:v1:${JSON.stringify([folder, id])}`
10}
11
12export class MemoController {
13 private current?: Context
14 private generation = 0
15 private pending = new Map<string, Promise<void>>()
16 constructor(private readonly store: Storage) {}
17
18 activate(cwd: string, id: string): Promise<void> {
19 let key: string
20 try { key = storeKey(cwd, id) } catch (error) { this.detach(); return Promise.reject(error) }
21 if (this.current?.key === key && this.current.document) return this.current.ready
22 if (this.current?.key === key && !this.current.error) return this.current.ready
23 const prior = this.pending.get(key) ?? Promise.resolve()
24 const ctx: Context = { key, generation: ++this.generation, ready: Promise.resolve(), tail: Promise.resolve() }
25 this.current = ctx
26 ctx.ready = (async () => {
27 await prior
28 try { ctx.document = decodeDocument(await this.store.get(key)); ctx.error = undefined }
29 catch (error) { ctx.error = `메모를 불러오지 못했습니다: ${error instanceof Error ? error.message : String(error)}` }
30 })()
31 return ctx.ready
32 }
33
34 detach(): void { this.current = undefined }
35
36 snapshot(): { key?: string; generation?: number; document?: MemoDocument; error?: string } {
37 const ctx = this.current
38 return ctx ? { key: ctx.key, generation: ctx.generation, document: ctx.document ? decodeDocument(ctx.document) : undefined, error: ctx.error } : {}
39 }
40
41 async run(args: string, expectedKey?: string, expectedGeneration?: number): Promise<CommandResult> {
42 const ctx = this.current
43 if (!ctx || (expectedKey !== undefined && ctx.key !== expectedKey) || (expectedGeneration !== undefined && ctx.generation !== expectedGeneration)) throw new Error('세션이 바뀌었습니다. 현재 메모 화면에서 다시 실행하세요.')
44 const work = ctx.tail.then(async () => {
45 await ctx.ready
46 if (this.current !== ctx) throw new Error('세션이 바뀌었습니다. 현재 메모 화면에서 다시 실행하세요.')
47 if (!ctx.document && args.trim() !== 'clear') throw new Error(ctx.error ?? '메모를 불러오는 중입니다.')
48 const recovering = !ctx.document
49 const result = execute(ctx.document ?? emptyDocument(), args)
50 if (result.changed || recovering) {
51 try { await this.store.set(ctx.key, result.document) }
52 catch (error) {
53 ctx.error = `메모를 저장하지 못했습니다: ${error instanceof Error ? error.message : String(error)}`
54 throw new Error(ctx.error)
55 }
56 ctx.document = result.document
57 ctx.error = undefined
58 }
59 return result
60 })
61 const tail = work.then(() => undefined, () => undefined)
62 ctx.tail = tail
63 this.pending.set(ctx.key, tail)
64 void tail.then(() => { if (this.pending.get(ctx.key) === tail) this.pending.delete(ctx.key) })
65 return work
66 }
67}
68hooks/core/memo.ts 79 lines1export type Memo = { id: number; text: string; checked: boolean | null }
2export type MemoDocument = { version: 1; nextId: number; items: Memo[] }
3export type CommandResult = { document: MemoDocument; changed: boolean; text: string }
4
5export function emptyDocument(): MemoDocument { return { version: 1, nextId: 1, items: [] } }
6
7export function cleanText(text: string): string {
8 return text.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
9 .replace(/(?:\x1b\[|\x9b)[0-?]*[ -/]*[@-~]/g, '')
10 .replace(/\r\n?/g, '\n').replace(/[\x00-\x09\x0b-\x1f\x7f-\x9f]/g, '').trim()
11}
12
13function content(raw: string): Pick<Memo, 'text' | 'checked'> {
14 const clean = cleanText(raw)
15 const match = /^(?:-\s+)?\[([ xX])\](?:\s+|$)([\s\S]*)$/.exec(clean)
16 const text = (match ? match[2]! : clean).trim()
17 if (!text) throw new Error('메모 내용을 입력하세요. /pin help')
18 if (Array.from(text).length > 2000) throw new Error('메모는 최대 2,000자입니다.')
19 return { text, checked: match ? match[1]!.toLowerCase() === 'x' : null }
20}
21
22export function decodeDocument(value: unknown): MemoDocument {
23 if (value === undefined) return emptyDocument()
24 const bad = () => { throw new Error('저장된 메모 형식이 올바르지 않습니다. /pin clear로 초기화할 수 있습니다.') }
25 if (!value || typeof value !== 'object') return bad()
26 const v = value as Record<string, unknown>
27 if (v.version !== 1 || !Number.isSafeInteger(v.nextId) || (v.nextId as number) < 1 || !Array.isArray(v.items) || v.items.length > 100) return bad()
28 const ids = new Set<number>()
29 const items: Memo[] = []
30 for (const item of v.items) {
31 if (!item || typeof item !== 'object') return bad()
32 const m = item as Memo
33 if (!Number.isSafeInteger(m.id) || m.id < 1 || m.id >= (v.nextId as number) || ids.has(m.id)
34 || typeof m.text !== 'string' || !m.text || cleanText(m.text) !== m.text || Array.from(m.text).length > 2000
35 || (m.checked !== null && typeof m.checked !== 'boolean')) return bad()
36 ids.add(m.id)
37 items.push({ id: m.id, text: m.text, checked: m.checked })
38 }
39 return { version: 1, nextId: v.nextId as number, items }
40}
41
42export const HELP = '/pin <memo> | /pin - [ ] <todo>\n/pin add <memo> | list | help\n/pin toggle|check|uncheck|remove <id>\n/pin edit <id> <memo> | clear'
43export function listText(doc: MemoDocument): string {
44 return doc.items.length ? doc.items.map(m => `${m.id} ${m.checked === null ? '·' : m.checked ? '[x]' : '[ ]'} ${m.text}`).join('\n') : '핀 메모가 없습니다. /pin <memo>'
45}
46
47export function execute(doc: MemoDocument, args: string): CommandResult {
48 const raw = args.trim()
49 const match = /^(\S+)(?:\s+([\s\S]*))?$/.exec(raw)
50 const command = match?.[1] ?? 'list'
51 const rest = match?.[2] ?? ''
52 const unchanged = (text: string): CommandResult => ({ document: doc, changed: false, text })
53 if (command === 'help' || command === 'list') {
54 if (rest) throw new Error(HELP)
55 return unchanged(command === 'help' ? HELP : listText(doc))
56 }
57 if (command === 'clear') {
58 if (rest) throw new Error(HELP)
59 return { document: { ...doc, items: [] }, changed: doc.items.length > 0, text: '핀 메모를 비웠습니다.' }
60 }
61 if (['toggle', 'check', 'uncheck', 'remove', 'edit'].includes(command)) {
62 const idMatch = /^(\d+)(?:\s+([\s\S]*))?$/.exec(rest)
63 if (!idMatch || (command !== 'edit' && idMatch[2]) || (command === 'edit' && !idMatch[2])) throw new Error(HELP)
64 const id = Number(idMatch[1])
65 const item = doc.items.find(m => m.id === id)
66 if (!item) throw new Error(`메모 #${id}가 없습니다.`)
67 if (command === 'remove') return { document: { ...doc, items: doc.items.filter(m => m.id !== id) }, changed: true, text: `메모 #${id} 삭제` }
68 if (command !== 'edit' && item.checked === null) throw new Error('일반 메모에는 체크 상태가 없습니다. /pin edit로 체크 항목으로 바꾸세요.')
69 const updated = command === 'edit' ? { ...item, ...content(idMatch[2]!) }
70 : { ...item, checked: command === 'toggle' ? !item.checked : command === 'check' }
71 if (updated.text === item.text && updated.checked === item.checked) return unchanged(`메모 #${id} 상태 유지`)
72 return { document: { ...doc, items: doc.items.map(m => m.id === id ? updated : m) }, changed: true, text: `메모 #${id} 변경` }
73 }
74 const memo = content(command === 'add' ? rest : raw)
75 if (doc.items.length >= 100) throw new Error('핀 메모는 최대 100개입니다.')
76 if (doc.nextId >= Number.MAX_SAFE_INTEGER) throw new Error('메모 번호 한도를 초과했습니다.')
77 return { document: { ...doc, nextId: doc.nextId + 1, items: [...doc.items, { id: doc.nextId, ...memo }] }, changed: true, text: `메모 #${doc.nextId} 고정` }
78}
79hooks/core/view.ts 60 lines1import type { MemoDocument } from './memo.ts'
2export type MemoView = { border: boolean; innerWidth: number; title: string; error?: string; rows: { id: number; checked: boolean | null; prefix: string; text: string }[] }
3
4function clusters(text: string): string[] {
5 const out: string[] = []
6 for (const ch of text) {
7 const last = out.at(-1)
8 if (last && (/^[\p{Mark}\u200d\ufe0f\u{1f3fb}-\u{1f3ff}]$/u.test(ch) || last.endsWith('\u200d')
9 || (/^\p{Regional_Indicator}$/u.test(ch) && /^\p{Regional_Indicator}$/u.test(last)))) out[out.length - 1] += ch
10 else out.push(ch)
11 }
12 return out
13}
14function clusterWidth(ch: string): number {
15 if (/\p{Extended_Pictographic}|\p{Regional_Indicator}|\ufe0f/u.test(ch)) return 2
16 let width = 0
17 for (const c of ch) {
18 const p = c.codePointAt(0)!
19 if (/\p{Mark}|\p{Format}/u.test(c)) continue
20 width += p >= 0x1100 && (p <= 0x115f || p === 0x2329 || p === 0x232a
21 || (p >= 0x2e80 && p <= 0xa4cf && p !== 0x303f) || (p >= 0xac00 && p <= 0xd7a3)
22 || (p >= 0xf900 && p <= 0xfaff) || (p >= 0xfe10 && p <= 0xfe6f)
23 || (p >= 0xff00 && p <= 0xff60) || (p >= 0xffe0 && p <= 0xffe6)
24 || (p >= 0x16fe0 && p <= 0x16fe4) || (p >= 0x16ff0 && p <= 0x16ff6)
25 || (p >= 0x17000 && p <= 0x18dff) || (p >= 0x1aff0 && p <= 0x1afff)
26 || (p >= 0x1b000 && p <= 0x1b2ff) || (p >= 0x1f200 && p <= 0x1f251)
27 || (p >= 0x20000 && p <= 0x3fffd)) ? 2 : 1
28 }
29 return width
30}
31export function cellWidth(text: string): number { return clusters(text).reduce((n, ch) => n + clusterWidth(ch), 0) }
32export function fit(text: string, width: number): string {
33 if (width <= 0) return ''
34 if (cellWidth(text) <= width) return text
35 let result = '', used = 0
36 for (const ch of clusters(text)) {
37 const w = clusterWidth(ch)
38 if (used + w > width - 1) break
39 result += ch; used += w
40 }
41 return result + '…'
42}
43
44export function buildView(doc: MemoDocument | undefined, error: string | undefined, width: number, maxRows: number): MemoView | undefined {
45 if (!Number.isFinite(width) || !Number.isFinite(maxRows) || width < 1 || maxRows < 1 || (!doc?.items.length && !error)) return undefined
46 width = Math.floor(width)
47 const border = width >= 30 && maxRows >= 3
48 const innerWidth = border ? width - 4 : width
49 return {
50 border, innerWidth,
51 title: fit(`📌 핀 ${doc?.items.length ?? 0} (완료 ${doc?.items.filter(m => m.checked === true).length ?? 0})`, innerWidth),
52 ...(error ? { error: fit(error.replace(/\s+/g, ' '), innerWidth) } : {}),
53 rows: (doc?.items ?? []).map(m => {
54 const fullPrefix = m.checked === null ? `${m.id} · ` : `${m.checked ? '[x]' : '[ ]'} ${m.id} `
55 const prefix = fit(fullPrefix, innerWidth)
56 return { id: m.id, checked: m.checked, prefix, text: fit(m.text.replace(/\s+/g, ' '), innerWidth - cellWidth(prefix)) }
57 }),
58 }
59}
60