SLOPSHOPPER

pin-memo

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

newbandcommandtoast
★ 879v0.1.0no licenseupdated 2026-10-13YeonwooSung/my-claude-code-mods/pin-memo
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pin-memo
› 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 › /pin ⎿ pin-memo: 핀 메모가 없습니다. /pin <memo> ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Pin Memo

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. 한국어.

Install

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

Commands

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

Persistence and limits

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.

Development

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.

Source 4 files
hooks/register.tsx 101 lines
1import 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}
101
hooks/core/controller.ts 68 lines
1import { 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}
68
hooks/core/memo.ts 79 lines
1export 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}
79
hooks/core/view.ts 60 lines
1import 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