SLOPSHOPPER

sticky-memo

자주 쓰는 문구를 입력창 위에 붙여 두고, 한 번 클릭하면 복사, 두 번 클릭하면 입력창에 넣어요

newbandcommandtoastprompttimer
★ 2v1.0.0MITupdated 2026-10-11SeongGwangJu/k-mods/mods/sticky-memo
A shopper browsing a rack in a slop shop
README

붙임 메모

자주 쓰는 문구를 입력창 위에 색깔 칩으로 붙여 두고, 한 번 클릭하면 복사, 두 번 클릭하면 입력창에 넣어요.

<img src="../../docs/assets/mods/sticky-memo.gif" alt="붙임 메모: 칩을 한 번 누르면 복사되어 반짝이고, 두 번 누르면 입력창 커서 자리에 들어가요" width="760">

설치

/plugin marketplace add SeongGwangJu/k-mods
/plugin install sticky-memo@k-mods

쓰는 법

입력창 위 막대에 메모가 색깔 칩으로 붙어요. 처음에는 + 붙임 메모 칩 하나만 보여요.

하고 싶은 일방법
메모 내용 복사칩을 한 번 클릭. 칩에 ✓가 잠깐 떠요
입력창에 넣기칩을 두 번 클릭. 커서 자리에 들어가고, 보내지는 않아요
새 메모+ 클릭 → 입력창에 내용을 쓰고 Enter
메모 고치기✎ 클릭(수정 모드) → 칩 클릭 → 입력창에서 고치고 Enter
막대 숨기기·보이기/sticky (작업 중에도 돼요)

수정 모드

✎를 누르면 수정 모드가 돼요. 이때 칩을 누르면 복사나 넣기 대신 그 메모가 펼쳐져요.

  • 내용은 입력창으로 옮겨져요. 여러 줄도 평소처럼 고치고, Enter나 [저장]을 누르면 저장돼요. 원래 입력창에 쓰던 글은 보관했다가 돌려줘요.
  • 제목은 막대의 제목 칸에서 바꾸고 Enter. 비우면 내용 첫 줄이 칩 이름이 돼요.
  • 색은 파스텔 8색 중에서 고르거나 # 칸에 #RRGGBB로 직접 적어요. 누르는 즉시 저장돼요.
  • [취소]는 내용 수정만 버리고, [삭제]는 두 번 눌러야 지워져요.
  • 완료를 누르면 평소 모드로 돌아가요.

+로 새 메모를 만들 때 입력창에 쓰던 글이 있으면 그 글로 시작해요. 방금 쓴 프롬프트를 그대로 메모로 남길 수 있어요. 저장한 뒤에도 입력창 글은 그대로 남아요.

칩이 한 줄에 다 들어가지 않으면 N개 더로 묶여요. 누르면 전부 펼쳐지고, 접기로 다시 접혀요.

메모는 모든 폴더에서 똑같이 보이고, 세션을 끝내도 남아요. 세션을 여러 개 띄워 두면 4초 안에 서로 맞춰져요.

마우스 클릭이 안 될 때

칩 클릭은 Claude Code의 fullscreen 화면에서 받아요(설정 파일의 "tui": "fullscreen"). fullscreen이 아니거나 데스크톱 앱이면 칩이 키보드로 누를 수 있는 버튼으로 바뀌어요. ctrl+x tab으로 막대에 들어가 Tab으로 칩을 고르고 Enter를 누르면 복사, 빠르게 두 번 누르면 넣기예요.

설정

없어요. 색은 메모마다 수정 모드에서 골라요.

어떻게 동작하나요

  • 세션이 시작할 때: /sticky 명령을 등록하고, 이 mod 전용 저장소($.store)에서 메모를 불러와요. 그 뒤 4초마다 저장소를 다시 읽어 다른 세션에서 바꾼 메모를 반영해요.
  • 입력창 위 막대를 그릴 때: 다른 mod가 그린 줄은 그대로 두고 그 아래에 칩 줄을 더해요. 설문이 떠 있으면 비켜 줘요.
  • 칩은 마우스 신호를 직접 받는 요소(Client)로 그려요. Claude Code의 일반 버튼은 더블클릭하면 두 번째 클릭을 넘겨주지 않고, 그 자리 글자를 골라 클립보드에 복사해 버리기 때문이에요.
  • 한 번 클릭은 0.4초 기다렸다가 복사해요. 그 안에 한 번 더 누르면 더블클릭으로 보고 입력창에 넣어요. 바로 복사하면 더블클릭할 때마다 클립보드가 바뀌어서, 메모를 넣고 미리 복사해 둔 다른 글을 붙여넣는 흐름이 깨져요.
  • 수정 모드에서 Enter를 누르면 그 글을 모델에 보내지 않고 메모로 저장해요(prompt.submit). 대화창에 Prompt dropped by a hook: '…' 메모를 저장했어요 한 줄이 남아요. 사람이 입력창에서 친 Enter만 가로채고, 작업 알림이나 다른 세션의 메시지는 그대로 보내요.

권한 (이 mod가 내 컴퓨터에서 하는 일)

  • 이 mod 전용 저장소(~/.claude/plugins/store/ 아래 JSON 파일)에 메모를 저장해요.
  • 칩을 한 번 클릭했을 때만 클립보드에 메모 내용을 써요.
  • 입력창 글을 읽고 바꿔요. 더블클릭으로 넣을 때와, 수정 모드에서 내용을 옮기고 쓰던 글을 돌려줄 때뿐이에요.
  • 네트워크, 일반 파일, 프로그램 실행, 모델 호출은 없어요.

한계

  • 마우스 클릭은 fullscreen 화면에서만 돼요. 그 밖에서는 키보드로 써요.
  • 붙여넣은 긴 글은 입력창에 [Pasted text #1 +14 lines]처럼 접혀 보여요. 이 상태에서는 [저장] 버튼으로 저장할 수 없고(Claude Code가 접힌 글을 펼쳐 주지 않아요), Enter로 저장해야 해요. 수정을 시작할 때 입력창에 붙여넣은 글이 남아 있으면 먼저 보내거나 지워 달라고 안내해요.
  • 화면 우측 상단처럼 고정된 자리는 Claude Code에 없어서 입력창 위 막대에만 붙어요.
  • 여러 기기(예: 맥북과 맥미니) 사이에는 메모가 동기화되지 않아요.

출처·라이선스

메인테이너(SeongGwangJu)가 윈도우의 '메모잇'처럼 쓰려고 만든 오리지널 mod예요. 외부 upstream은 없고, 저장소 루트의 MIT 라이선스를 따라요.

테스트한 Claude Code 버전: 2.1.296

Source 4 files
hooks/register.tsx 495 lines
1// sticky-memo: 자주 쓰는 문구를 입력창 위에 붙여 두는 메모 (윈도우 '메모잇' 같은 쓰임새).
2//   평소: 칩을 한 번 누르면 클립보드에 복사, 두 번 누르면 입력창 커서 자리에 넣는다.
3//   ✎ 수정 모드: 칩을 누르면 펼쳐진다. 내용은 입력창을 빌려 고치고 Enter(또는 [저장])로 저장하고,
4//     제목·색은 막대에서 바로 바꾼다. 사람이 쓰던 글은 보관했다가 수정이 끝나면 돌려준다.
5//   메모는 모든 폴더 공통으로 $.store에 저장되고, 여러 세션이 SYNC_MS마다 서로 맞춘다.
6//   /sticky로 막대를 숨기거나 다시 보인다.
7import { atom, read, update } from 'claude-code'
8import type { Elements, EngineInterface, Register, RenderElement, RenderSurface, Timer } from 'claude-code'
9
10import type { StickyEdit, StickyMemo } from '../types'
11import {
12  ACCENT,
13  COLORS,
14  DOUBLE_MS,
15  FLASH_MS,
16  INK,
17  RESTORE_MS,
18  STORE_KEY,
19  SYNC_MS,
20  WARN,
21  cellWidth,
22  copyFailText,
23  fitCount,
24  hasPlaceholder,
25  labelCells,
26  labelOf,
27  moreLabel,
28  newMemoId,
29  nextColor,
30  normalizeHex,
31  parseBoard,
32  patchMemo,
33  removeMemo,
34  truncateCells,
35  upsertMemo,
36} from './memos'
37
38/** 버튼처럼 쓰는 칩(+·✎·완료·더 보기)의 색: 공용 팔레트의 홈 색 위에 본문 글자. */
39const SOFT = '#3A3E47'
40const SOFT_FG = '#E8EAED'
41/** 막 복사한 칩의 반짝임 (공용 팔레트의 본문 색). */
42const FLASH_BG = '#E8EAED'
43const EDIT_HINT = '내용은 입력창에서 고치고 Enter로 저장해요'
44const EMPTY_HINT = '자주 쓰는 문구를 붙여 두세요'
45const OPEN_HINT = '메모를 누르면 펼쳐져요'
46const PLACEHOLDER_TOAST = '입력창에 붙여넣은 글(또는 이미지)이 있어요. 보내거나 지운 뒤 다시 눌러 주세요'
47
48const memos = atom({ plugin: 'sticky-memo', key: 'memos' } as const, [])
49const rev = atom({ plugin: 'sticky-memo', key: 'rev' } as const, 0)
50const isEditMode = atom({ plugin: 'sticky-memo', key: 'isEditMode' } as const, false)
51const edit = atom({ plugin: 'sticky-memo', key: 'edit' } as const, null)
52const flash = atom({ plugin: 'sticky-memo', key: 'flash' } as const, null)
53const isHidden = atom({ plugin: 'sticky-memo', key: 'isHidden' } as const, false)
54const isAllShown = atom({ plugin: 'sticky-memo', key: 'isAllShown' } as const, false)
55
56// 한 번 클릭을 DOUBLE_MS 동안 붙잡아 두는 자리. 그 안에 같은 칩이 또 눌리면 더블클릭이다.
57let pending: { id: string; at: number; timer: Timer } | null = null
58// 버튼처럼 쓰는 칩을 빠르게 두 번 눌러도 한 번만 처리한다.
59let lastPress: { key: string; at: number } | null = null
60// 제목 칸에 치는 중인 글(Enter 전). 내용을 저장할 때 함께 반영한다.
61let titleTyping: string | null = null
62// prompt.submit의 .catch가 $ 없이 판단하도록 지금 수정 중인지 비춰 둔다.
63let isEditingMirror = false
64
65type Els = Elements['terminal'] | Elements['desktop']
66type Site = {
67  surface: 'terminal' | 'desktop'
68  viewport?: { isFullscreen?: boolean }
69  props: { bodyColumns: number }
70}
71
72export const register: Register = on => {
73  on('session.start', async ($, e, next) => {
74    await $.command.register({ name: 'sticky', description: '붙임 메모 보이기/숨기기 (작업 중에도 동작)', immediate: true })
75    await loadBoard($)
76    $.clock.every(SYNC_MS, () => {
77      void loadBoard($)
78    })
79    return next(e)
80  })
81
82  on('command.run', { command: 'sticky' }, async $ => {
83    const hidden = await read($, isHidden)
84    if (!hidden && !(await endEdit($, 'save'))) {
85      $.ui.toast('수정 중인 메모를 먼저 저장하거나 취소해 주세요')
86      return {}
87    }
88    await update($, isHidden, () => !hidden)
89    return {}
90  })
91
92  // 입력창 위 막대는 여러 mod가 함께 쓰는 자리: 설문이 뜨면 양보하고, 다른 mod의 그림(theirs)도 함께 그린다
93  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
94    const theirs = await next(e)
95    if (e.props.hasSurvey) return theirs
96    if (e.surface !== 'terminal' && e.surface !== 'desktop') return theirs
97    if (await read($, isHidden)) return theirs
98    const els = $.ui.resolve(e)
99    const { Box } = els
100    const board = await drawBoard($, e, els)
101    return (
102      <Box flexDirection="column">
103        {theirs}
104        {board}
105      </Box>
106    )
107  })
108
109  // 칩(Client)이 왼쪽 버튼을 뗄 때마다 보낸다
110  on('ui.message', async ($, e, next) => {
111    const data = e.data as { click?: unknown } | null
112    if (data !== null && typeof data === 'object' && data.click === true) await onClick($, e.element, e.surface)
113    return next(e)
114  })
115
116  // 수정 중에 사람이 Enter를 누르면 모델에 보내지 않고 메모로 저장한다. 붙여넣은 긴 글은 여기서만 온전히 받는다.
117  on('prompt.submit', async ($, e, next) => {
118    if (e.origin.kind !== 'composer') return next(e)
119    const cur = await read($, edit)
120    if (cur === null) return next(e)
121    return { drop: await saveSubmitted($, cur, e.text) }
122  }).catch(($, e, next) =>
123    next.called || !isEditingMirror || e.origin.kind !== 'composer'
124      ? next(e)
125      : { drop: '붙임 메모를 저장하지 못했어요. 다시 Enter를 누르거나 [취소]를 눌러 주세요' },
126  )
127}
128
129/** $.store에서 보드를 읽어, 다른 세션이 바꿨으면(rev가 다르면) 세션 상태에 반영한다. */
130async function loadBoard($: EngineInterface): Promise<void> {
131  const board = parseBoard(await $.store.get(STORE_KEY))
132  if (board.rev === (await read($, rev))) return
133  await update($, memos, () => board.memos)
134  await update($, rev, () => board.rev)
135}
136
137/** 저장소의 최신본에 change를 적용해 저장한다. 다른 세션이 그사이 바꾼 메모를 덮어쓰지 않는다. */
138async function commitMemos($: EngineInterface, change: (list: StickyMemo[]) => StickyMemo[]): Promise<void> {
139  const saved = parseBoard(await $.store.get(STORE_KEY))
140  const nextList = change(saved.memos)
141  const nextRev = Math.max(saved.rev, await read($, rev)) + 1
142  await $.store.set(STORE_KEY, { rev: nextRev, memos: nextList })
143  await update($, memos, () => nextList)
144  await update($, rev, () => nextRev)
145}
146
147async function setEdit($: EngineInterface, value: StickyEdit | null): Promise<void> {
148  isEditingMirror = value !== null
149  await update($, edit, () => value)
150}
151
152/** 칩 하나가 눌렸을 때. key는 'memo:<id>' 또는 'ctl:<이름>'. */
153async function onClick($: EngineInterface, key: string, surface: RenderSurface): Promise<void> {
154  const now = await $.clock.now()
155  const editing = await read($, isEditMode)
156  // 버튼처럼 쓰는 칩과 수정 모드의 칩은 더블클릭이 없다: 두 번째는 버린다
157  if (key.startsWith('ctl:') || editing) {
158    if (lastPress !== null && lastPress.key === key && now - lastPress.at < DOUBLE_MS) return
159    lastPress = { key, at: now }
160  }
161  if (key.startsWith('ctl:')) {
162    await pressControl($, key.slice(4))
163    return
164  }
165  if (!key.startsWith('memo:')) return
166  const id = key.slice(5)
167  if (editing) {
168    await openEdit($, id)
169    return
170  }
171  if (pending !== null && pending.id === id && now - pending.at <= DOUBLE_MS) {
172    pending.timer.cancel()
173    pending = null
174    await insertMemo($, id)
175    return
176  }
177  // 한 번 클릭은 더블클릭이 아닌 게 확실해진 뒤에 복사한다. 바로 복사하면 더블클릭(넣기)할 때마다
178  // 클립보드가 덮어써져서, 메모를 넣고 복사해 둔 다른 글을 붙여넣는 흐름이 깨진다.
179  pending?.timer.cancel()
180  const timer = $.clock.after(DOUBLE_MS, () => {
181    pending = null
182    void copyMemo($, id, surface)
183  })
184  pending = { id, at: now, timer }
185}
186
187async function pressControl($: EngineInterface, name: string): Promise<void> {
188  if (name === 'add') {
189    await startNew($)
190  } else if (name === 'edit') {
191    await update($, isEditMode, () => true)
192  } else if (name === 'done') {
193    if (await endEdit($, 'save')) await update($, isEditMode, () => false)
194  } else if (name === 'more') {
195    await update($, isAllShown, shown => !shown)
196  } else if (name.startsWith('color-')) {
197    const color = COLORS[Number(name.slice('color-'.length))]
198    if (color !== undefined) await setColor($, color)
199  }
200}
201
202async function copyMemo($: EngineInterface, id: string, surface: RenderSurface): Promise<void> {
203  const memo = (await read($, memos)).find(m => m.id === id)
204  if (memo === undefined) return
205  const result = await $.ui.copy({ text: memo.text, surface })
206  if (!result.isCopied) {
207    $.ui.toast(copyFailText(result.reason))
208    return
209  }
210  await update($, flash, () => id)
211  $.clock.after(FLASH_MS, () => {
212    void update($, flash, shown => (shown === id ? null : shown))
213  })
214}
215
216async function insertMemo($: EngineInterface, id: string): Promise<void> {
217  const memo = (await read($, memos)).find(m => m.id === id)
218  if (memo === undefined) return
219  const result = await $.prompt.fill({ text: memo.text, mode: 'insert' })
220  if (!result.isFilled) $.ui.toast('입력창에 넣지 못했어요. 열린 창을 닫고 다시 해 주세요')
221}
222
223/** +: 새 메모를 펼친다. 입력창에 쓰던 글이 있으면 그 글로 시작하고, 저장한 뒤에도 입력창 글은 그대로 남는다. */
224async function startNew($: EngineInterface): Promise<void> {
225  if (!(await endEdit($, 'save'))) return
226  const box = await $.prompt.read()
227  if (hasPlaceholder(box.text)) {
228    $.ui.toast(PLACEHOLDER_TOAST)
229    return
230  }
231  const now = await $.clock.now()
232  const id = newMemoId(now, Math.random())
233  titleTyping = null
234  await update($, isEditMode, () => true)
235  await setEdit($, { id, openedAt: now, isNew: true, draft: box.text, title: null, color: null, isArmedDelete: false, hint: null })
236}
237
238/** 수정 모드에서 칩을 눌렀을 때: 그 메모를 펼치고 내용을 입력창에 옮긴다. 펼친 칩을 또 누르면 접는다. */
239async function openEdit($: EngineInterface, id: string): Promise<void> {
240  const cur = await read($, edit)
241  if (cur !== null && cur.id === id) {
242    await endEdit($, 'save')
243    return
244  }
245  if (!(await endEdit($, 'save'))) return
246  const memo = (await read($, memos)).find(m => m.id === id)
247  if (memo === undefined) return
248  const box = await $.prompt.read()
249  if (hasPlaceholder(box.text)) {
250    $.ui.toast(PLACEHOLDER_TOAST)
251    return
252  }
253  const filled = await $.prompt.fill({ text: memo.text, mode: 'replace' })
254  if (!filled.isFilled) {
255    $.ui.toast('입력창을 쓸 수 없어서 펼치지 못했어요. 열린 창을 닫고 다시 해 주세요')
256    return
257  }
258  titleTyping = null
259  await setEdit($, { id, openedAt: await $.clock.now(), isNew: false, draft: box.text, title: null, color: null, isArmedDelete: false, hint: null })
260}
261
262/**
263 * 펼친 메모를 닫는다. save는 입력창 글을 내용으로 저장하고, cancel은 버리고, delete는 메모를 지운다.
264 * 어느 쪽이든 쓰던 글을 입력창에 돌려준다. 저장할 수 없으면(붙여넣은 글, 빈 내용) 안내만 띄우고 false.
265 */
266async function endEdit($: EngineInterface, how: 'save' | 'cancel' | 'delete'): Promise<boolean> {
267  const cur = await read($, edit)
268  if (cur === null) return true
269  if (how === 'save') {
270    const box = await $.prompt.read()
271    if (hasPlaceholder(box.text)) {
272      await setEdit($, { ...cur, isArmedDelete: false, hint: '붙여넣은 글이 있어서 Enter로 저장해 주세요' })
273      return false
274    }
275    if (box.text.trim() !== '') {
276      await saveMemo($, cur, box.text)
277    } else if (!cur.isNew) {
278      await setEdit($, { ...cur, isArmedDelete: false, hint: '내용이 비어 있어요. 지우려면 [삭제]를 눌러 주세요' })
279      return false
280    }
281  }
282  if (how === 'delete' && !cur.isNew) await commitMemos($, list => removeMemo(list, cur.id))
283  await $.prompt.fill({ text: cur.draft, mode: 'replace' })
284  titleTyping = null
285  await setEdit($, null)
286  return true
287}
288
289/** 펼친 메모를 text 내용으로 저장한다. 바뀐 게 없으면 저장소에 쓰지 않는다. */
290async function saveMemo($: EngineInterface, cur: StickyEdit, text: string): Promise<StickyMemo> {
291  const list = await read($, memos)
292  const old = list.find(m => m.id === cur.id)
293  const title = (titleTyping ?? cur.title ?? old?.title ?? '').trim()
294  const color = cur.color ?? old?.color ?? nextColor(list)
295  if (old !== undefined && old.text === text && old.title === title && old.color === color) return old
296  const memo: StickyMemo = { id: cur.id, title, text, color, updatedAt: await $.clock.now() }
297  await commitMemos($, saved => upsertMemo(saved, memo))
298  return memo
299}
300
301/** 수정 중 Enter: 내용을 저장하고, 엔진이 입력창에 글을 되돌려 놓은 뒤 쓰던 글로 바꿔 넣는다. 막힌 이유(안내)를 돌려준다. */
302async function saveSubmitted($: EngineInterface, cur: StickyEdit, text: string): Promise<string> {
303  if (text.trim() === '') {
304    await setEdit($, { ...cur, isArmedDelete: false, hint: '내용이 비어 있어요. 지우려면 [삭제]를 눌러 주세요' })
305    return '붙임 메모: 내용이 비어 있어서 저장하지 않았어요'
306  }
307  const memo = await saveMemo($, cur, text)
308  $.clock.after(RESTORE_MS, () => {
309    void $.prompt.fill({ text: cur.draft, mode: 'replace' })
310  })
311  titleTyping = null
312  await setEdit($, null)
313  return `'${truncateCells(labelOf(memo), 24)}' 메모를 저장했어요`
314}
315
316async function setTitle($: EngineInterface, value: string): Promise<void> {
317  const cur = await read($, edit)
318  if (cur === null) return
319  titleTyping = null
320  const title = value.trim()
321  if (!cur.isNew) await commitMemos($, list => patchMemo(list, cur.id, { title }))
322  await setEdit($, { ...cur, title, isArmedDelete: false, hint: null })
323}
324
325async function setColor($: EngineInterface, color: string): Promise<void> {
326  const cur = await read($, edit)
327  if (cur === null) return
328  if (!cur.isNew) await commitMemos($, list => patchMemo(list, cur.id, { color }))
329  await setEdit($, { ...cur, color, isArmedDelete: false, hint: null })
330}
331
332async function setHex($: EngineInterface, value: string): Promise<void> {
333  const color = normalizeHex(value)
334  if (color !== null) {
335    await setColor($, color)
336    return
337  }
338  const cur = await read($, edit)
339  if (cur !== null) await setEdit($, { ...cur, hint: '#RRGGBB 형식으로 적어 주세요 (예: #FFB3BA)' })
340}
341
342/** [삭제]는 두 번 눌러야 지운다. 새 메모(아직 저장 전)는 버리기만 한다. */
343async function pressDelete($: EngineInterface): Promise<void> {
344  const cur = await read($, edit)
345  if (cur === null) return
346  if (!cur.isArmedDelete) {
347    await setEdit($, { ...cur, isArmedDelete: true, hint: '한 번 더 누르면 지워져요' })
348    return
349  }
350  await endEdit($, 'delete')
351}
352
353/** 입력창 위 막대에 그릴 메모판: 평소엔 칩 한 줄, 펼친 메모가 있으면 그 아래 제목·색·저장 줄. */
354async function drawBoard($: EngineInterface, e: Site, els: Els): Promise<RenderElement> {
355  const { Box, Text } = els
356  const list = await read($, memos)
357  const editing = await read($, isEditMode)
358  const cur = await read($, edit)
359  const flashed = await read($, flash)
360  const allShown = await read($, isAllShown)
361  const columns = e.props.bodyColumns
362  const surface = e.surface
363  // 마우스 신호를 받을 수 있으면 Client 칩, 아니면 키보드로도 누를 수 있는 Button 칩
364  const site: ChipSite = { isPointer: surface === 'terminal' && e.viewport?.isFullscreen === true, surface }
365
366  const maxLabel = labelCells(columns)
367  const memoChips = list.map(m => {
368    const label = truncateCells(labelOf(m), maxLabel)
369    const key = `memo:${m.id}`
370    // 막 복사한 칩은 폭은 그대로 두고 밝게 반짝이며 앞에 ✓ (폭이 바뀌면 옆 칩들이 밀린다)
371    const width = cellWidth(` ${label} `)
372    if (flashed === m.id) return { width, el: drawChip($, els, site, { key, text: `✓${label} `, bg: FLASH_BG, fg: INK, bold: true, hasTab: true }) }
373    const isOpen = cur?.id === m.id
374    const chip = { key, text: ` ${label} `, bg: isOpen ? INK : m.color, fg: isOpen ? m.color : INK, bold: isOpen, hasTab: true }
375    return { width, el: drawChip($, els, site, chip) }
376  })
377
378  // 세 가지를 모양으로 가른다: 메모는 파스텔 칩, 버튼(+·✎·완료)은 회색 칩, 모드 표시는 배경 없는 보라 글자
379  const modeText = '[✎ 수정 모드]'
380  const lead = editing
381    ? [
382        <Text color={ACCENT} bold>
383          {modeText}
384        </Text>,
385      ]
386    : []
387  const controls: Array<{ key: string; text: string; bg: string; fg: string }> = editing
388    ? [
389        { key: 'ctl:add', text: ' + ', bg: SOFT, fg: SOFT_FG },
390        { key: 'ctl:done', text: ' 완료 ', bg: SOFT, fg: SOFT_FG },
391      ]
392    : list.length === 0
393      ? [{ key: 'ctl:add', text: ' + 붙임 메모 ', bg: COLORS[0], fg: INK }]
394      : [
395          { key: 'ctl:add', text: ' + ', bg: SOFT, fg: SOFT_FG },
396          { key: 'ctl:edit', text: ' ✎ ', bg: SOFT, fg: SOFT_FG },
397        ]
398  const tail = controls.map(c => drawChip($, els, site, c))
399  const panel = cur === null ? null : drawPanel($, els, site, list, cur)
400
401  // 막대는 기본 한 줄: 넘치는 칩은 'N개 더'로 묶고, 누르면 여러 줄로 다 펼친다
402  const reserve = (editing ? cellWidth(modeText) + 1 : 0) + controls.reduce((sum, c) => sum + cellWidth(c.text) + 1, 0)
403  const fit = fitCount(
404    memoChips.map(c => c.width),
405    columns,
406    reserve,
407    1,
408    rest => cellWidth(moreLabel(rest, columns)),
409  )
410  const overflows = fit < memoChips.length
411  const shown = overflows && !allShown ? memoChips.slice(0, fit) : memoChips
412  const moreText = !overflows ? null : allShown ? ' 접기 ' : moreLabel(memoChips.length - fit, columns)
413  const more = moreText === null ? null : drawChip($, els, site, { key: 'ctl:more', text: moreText, bg: SOFT, fg: SOFT_FG })
414  const used = reserve + shown.reduce((sum, c) => sum + c.width + 1, 0) + (moreText === null ? 0 : cellWidth(moreText) + 1)
415  const hint = list.length === 0 && !editing ? EMPTY_HINT : editing && cur === null ? OPEN_HINT : null
416  const showHint = hint !== null && !allShown && columns - used >= cellWidth(hint)
417  return (
418    <Box flexDirection="column">
419      <Box flexDirection="row" columnGap={1} flexWrap={allShown ? 'wrap' : 'nowrap'}>
420        {lead}
421        {shown.map(c => c.el)}
422        {more}
423        {tail}
424        {showHint && <Text dimColor>{hint}</Text>}
425      </Box>
426      {panel}
427    </Box>
428  )
429}
430
431type ChipSite = { isPointer: boolean; surface: RenderSurface }
432type ChipSpec = { key: string; text: string; bg: string; fg: string; bold?: boolean; hasTab?: boolean }
433
434/**
435 * 누를 수 있는 칩 하나. 마우스가 되면 Client(한 번·두 번 클릭을 직접 받음), 아니면 키보드로도 누르는 Button.
436 * Button은 글자(label) 하나만 받아서(2.1.291) 색은 앞의 한 칸(hasTab)으로 보여 준다.
437 */
438function drawChip($: EngineInterface, els: Els, site: ChipSite, chip: ChipSpec): RenderElement {
439  const { Box, Text, Button, Client } = els
440  if (site.isPointer) {
441    return <Client key={chip.key} module="./chip.tsx" props={{ text: chip.text, bg: chip.bg, fg: chip.fg, bold: chip.bold === true }} />
442  }
443  const button = <Button key={chip.key} plain label={chip.text.trim() || '·'} onPress={() => onClick($, chip.key, site.surface)} />
444  if (chip.hasTab !== true) return button
445  return (
446    <Box key={`${chip.key}:tab`} flexDirection="row">
447      <Text backgroundColor={chip.bg}> </Text>
448      {button}
449    </Box>
450  )
451}
452
453/** 펼친 메모의 제목·색·저장 줄. 내용은 입력창에 있다. */
454function drawPanel($: EngineInterface, els: Els, site: ChipSite, list: readonly StickyMemo[], cur: StickyEdit): RenderElement {
455  const { Box, Text, Button, Input } = els
456  const target = list.find(m => m.id === cur.id)
457  const color = cur.color ?? target?.color ?? nextColor(list)
458  const title = cur.title ?? target?.title ?? ''
459  const isCustom = !(COLORS as readonly string[]).includes(color)
460  const fresh = `${cur.id}-${cur.openedAt}`
461  return (
462    <Box flexDirection="column">
463      <Input
464        key={`title-${fresh}`}
465        label="제목"
466        placeholder="비우면 내용 첫 줄"
467        value={title}
468        submitLabel="저장"
469        onInput={(value: string) => {
470          titleTyping = value
471        }}
472        onSubmit={(value: string) => setTitle($, value)}
473      />
474      <Box flexDirection="row" columnGap={1} flexWrap="wrap">
475        <Text dimColor>색</Text>
476        {COLORS.map((c, i) => drawChip($, els, site, { key: `ctl:color-${i}`, text: c === color ? ' ✓ ' : '   ', bg: c, fg: INK, hasTab: true }))}
477        {isCustom && (
478          <Text backgroundColor={color} color={INK}>
479            {' ✓ '}
480          </Text>
481        )}
482        <Input key={`hex-${fresh}`} label="#" placeholder="RRGGBB" value="" submitLabel="적용" onSubmit={(value: string) => setHex($, value)} />
483      </Box>
484      <Box flexDirection="row" columnGap={1} flexWrap="wrap">
485        <Button key="save" label="저장" variant="primary" onPress={() => endEdit($, 'save')} />
486        <Button key="cancel" label="취소" onPress={() => endEdit($, 'cancel')} />
487        <Button key="delete" label={cur.isArmedDelete ? '정말 삭제' : '삭제'} onPress={() => pressDelete($)} />
488        <Text color={cur.hint === null ? undefined : WARN} dimColor={cur.hint === null}>
489          {cur.hint ?? EDIT_HINT}
490        </Text>
491      </Box>
492    </Box>
493  )
494}
495
hooks/memos.ts 176 lines
1// sticky-memo의 순수 함수. $를 받지 않아 테스트하기 쉽다.
2import type { StickyBoard, StickyMemo } from '../types'
3
4/** 파스텔 칩 위 글씨 색 (k-mods 공용 팔레트). */
5export const INK = '#1E2127'
6/** 수정 모드 표시 칩 (공용 팔레트의 강조 보라). */
7export const ACCENT = '#C3B1F5'
8/** 안내 문구 중 주의가 필요한 것 (공용 팔레트의 경고 호박). */
9export const WARN = '#F0C987'
10/** 고를 수 있는 파스텔 8색 (공용 팔레트의 분류 파스텔). 새 메모는 차례대로 돌아가며 받는다. */
11export const COLORS = ['#E3C56A', '#8EC5CC', '#E8A6C4', '#86A0DC', '#A8C98A', '#DE8E62', '#B9AEF5', '#9F8FF2'] as const
12
13/** 이 안에 두 번째 클릭이 오면 더블클릭. 한 번 클릭(복사)은 이만큼 기다렸다가 실행한다. */
14export const DOUBLE_MS = 400
15/** 복사한 칩에 ✓를 보여주는 시간. */
16export const FLASH_MS = 1200
17/** 다른 세션이 바꾼 메모를 가져오는 주기. */
18export const SYNC_MS = 4000
19/** Enter 저장 뒤 엔진이 입력창에 글을 되돌려 놓은 다음, 쓰던 글로 바꿔 넣기까지 기다리는 시간. */
20export const RESTORE_MS = 60
21/** $.store 키. */
22export const STORE_KEY = 'board'
23
24/** 한 글자가 터미널에서 차지하는 칸 수: 한글·한자·전각 문자·그림 문자는 2, 결합 문자는 0. */
25export function charCells(code: number): number {
26  if (code === 0x200b || code === 0x200c || code === 0x200d || code === 0xfe0f) return 0
27  if (code >= 0x0300 && code <= 0x036f) return 0
28  if (
29    (code >= 0x1100 && code <= 0x115f) ||
30    (code >= 0x2e80 && code <= 0x303e) ||
31    (code >= 0x3041 && code <= 0x33ff) ||
32    (code >= 0x3400 && code <= 0x4dbf) ||
33    (code >= 0x4e00 && code <= 0x9fff) ||
34    (code >= 0xa000 && code <= 0xa4cf) ||
35    (code >= 0xac00 && code <= 0xd7a3) ||
36    (code >= 0xf900 && code <= 0xfaff) ||
37    (code >= 0xfe30 && code <= 0xfe4f) ||
38    (code >= 0xff00 && code <= 0xff60) ||
39    (code >= 0xffe0 && code <= 0xffe6) ||
40    (code >= 0x1f300 && code <= 0x1f64f) ||
41    (code >= 0x1f900 && code <= 0x1f9ff) ||
42    (code >= 0x20000 && code <= 0x3fffd)
43  ) {
44    return 2
45  }
46  return 1
47}
48
49/** 문자열이 터미널에서 차지하는 칸 수. */
50export function cellWidth(text: string): number {
51  let cells = 0
52  for (const ch of text) cells += charCells(ch.codePointAt(0) ?? 0)
53  return cells
54}
55
56/** max칸을 넘으면 잘라서 끝에 …을 붙인다 (…도 한 칸으로 센다). */
57export function truncateCells(text: string, max: number): string {
58  if (cellWidth(text) <= max) return text
59  let out = ''
60  let cells = 0
61  for (const ch of text) {
62    const w = charCells(ch.codePointAt(0) ?? 0)
63    if (cells + w > max - 1) break
64    out += ch
65    cells += w
66  }
67  return `${out}…`
68}
69
70/** 비어 있지 않은 첫 줄 (탭·연속 공백은 한 칸으로). */
71export function firstLine(text: string): string {
72  const line = text.split('\n').find(l => l.trim() !== '') ?? ''
73  return line.replace(/\s+/g, ' ').trim()
74}
75
76/** 칩에 보일 이름: 제목, 없으면 내용 첫 줄, 그것도 없으면 (빈 메모). */
77export function labelOf(memo: Pick<StickyMemo, 'title' | 'text'>): string {
78  return memo.title.trim() || firstLine(memo.text) || '(빈 메모)'
79}
80
81/** 폭 3단계(넓음 100칸 이상, 보통 70~99, 좁음)에 따른 칩 이름의 최대 칸 수. */
82export function labelCells(columns: number): number {
83  if (columns >= 100) return 16
84  if (columns >= 70) return 12
85  return 8
86}
87
88/**
89 * 한 줄에 칩을 몇 개까지 넣을 수 있는지. 칩 사이는 gap칸, reserve는 칩 말고 같은 줄에 놓을 것(버튼들)의 칸 수.
90 * 다 못 넣으면 남은 개수를 알리는 칩(moreCells(남은 개수))의 자리도 남긴다.
91 */
92export function fitCount(widths: readonly number[], available: number, reserve: number, gap: number, moreCells: (rest: number) => number): number {
93  const total = widths.reduce((sum, w) => sum + w, 0) + gap * widths.length
94  if (total + reserve <= available) return widths.length
95  let used = reserve
96  let count = 0
97  for (const w of widths) {
98    const rest = widths.length - count - 1
99    if (used + w + gap + gap + moreCells(rest) > available) break
100    used += w + gap
101    count++
102  }
103  return count
104}
105
106/** 남은 메모 수를 알리는 칩 글자. 좁으면 짧게. */
107export function moreLabel(rest: number, columns: number): string {
108  return columns >= 70 ? ` ${rest}개 더 ` : ` +${rest} `
109}
110
111/** '#RRGGBB'나 'RRGGBB', '#RGB'를 '#RRGGBB'(대문자)로. 아니면 null. */
112export function normalizeHex(input: string): string | null {
113  const raw = input.trim().replace(/^#/, '')
114  if (/^[0-9a-fA-F]{6}$/.test(raw)) return `#${raw.toUpperCase()}`
115  if (/^[0-9a-fA-F]{3}$/.test(raw)) {
116    return `#${raw
117      .split('')
118      .map(c => c + c)
119      .join('')
120      .toUpperCase()}`
121  }
122  return null
123}
124
125/**
126 * 입력창 글에 붙여넣기·이미지 자리표시가 있는지. $.prompt.read는 붙여넣은 긴 글을
127 * "[Pasted text #1 +14 lines]"로만 돌려줘서, 이걸 그대로 저장하거나 되돌려 넣으면 내용이 사라진다.
128 */
129export function hasPlaceholder(text: string): boolean {
130  return /\[(Pasted text|Image) #\d+[^\]]*\]/.test(text)
131}
132
133/** $.ui.copy가 실패한 이유를 한국어 안내로. */
134export function copyFailText(reason: 'no-surface' | 'no-clipboard' | 'refused'): string {
135  if (reason === 'no-clipboard') return '복사하지 못했어요. 이 터미널은 클립보드 쓰기를 막고 있어요'
136  if (reason === 'refused') return '복사하지 못했어요. 다른 mod가 막았어요'
137  return '복사하지 못했어요. 그릴 화면이 없어요'
138}
139
140function isMemo(value: unknown): value is StickyMemo {
141  if (typeof value !== 'object' || value === null) return false
142  const m = value as Record<string, unknown>
143  return typeof m.id === 'string' && typeof m.title === 'string' && typeof m.text === 'string' && typeof m.color === 'string'
144}
145
146/** $.store에서 읽은 값을 검사해서 보드로. 모양이 다르면 빈 보드. */
147export function parseBoard(value: unknown): StickyBoard {
148  if (typeof value !== 'object' || value === null) return { rev: 0, memos: [] }
149  const b = value as Record<string, unknown>
150  const memos = Array.isArray(b.memos) ? b.memos.filter(isMemo).map(m => ({ ...m, updatedAt: typeof m.updatedAt === 'number' ? m.updatedAt : 0 })) : []
151  return { rev: typeof b.rev === 'number' ? b.rev : 0, memos }
152}
153
154/** 같은 id가 있으면 그 자리에서 바꾸고, 없으면 끝에 붙인다. */
155export function upsertMemo(list: readonly StickyMemo[], memo: StickyMemo): StickyMemo[] {
156  return list.some(m => m.id === memo.id) ? list.map(m => (m.id === memo.id ? memo : m)) : [...list, memo]
157}
158
159export function removeMemo(list: readonly StickyMemo[], id: string): StickyMemo[] {
160  return list.filter(m => m.id !== id)
161}
162
163export function patchMemo(list: readonly StickyMemo[], id: string, patch: Partial<Omit<StickyMemo, 'id'>>): StickyMemo[] {
164  return list.map(m => (m.id === id ? { ...m, ...patch } : m))
165}
166
167/** 새 메모의 기본 색: 지금 메모 수에 따라 8색을 돌아가며. */
168export function nextColor(list: readonly StickyMemo[]): string {
169  return COLORS[list.length % COLORS.length] ?? COLORS[0]
170}
171
172/** 시각과 난수로 만든 짧은 id. */
173export function newMemoId(now: number, random: number): string {
174  return `m${Math.floor(now).toString(36)}${Math.floor(random * 1e6).toString(36)}`
175}
176
hooks/chip.tsx 27 lines
1// 칩 하나: 색 배경 위 글자. 그리기 스레드에서 돌며 마우스 신호를 직접 받는다.
2//
3// 엔진의 Button은 더블클릭의 두 번째 클릭을 넘겨주지 않고, 대신 그 자리 글자를 단어로 골라
4// 클립보드에 복사해 버린다(fullscreen 터미널). 그래서 칩은 Client로 그리고 왼쪽 버튼을 뗄 때마다
5// hooks 모듈에 알린다. 한 번/두 번 클릭 판정은 hooks 모듈이 $.clock으로 한다.
6import type { ClientModule } from 'claude-code'
7
8export type ChipProps = { text: string; bg: string; fg: string; bold?: boolean }
9
10const Chip: ClientModule<ChipProps> = (props, surface) => {
11  const { Text } = surface.elements
12  surface.onPointer(ev => {
13    if (ev.type !== 'up' || ev.button !== 'left') return
14    // 칩에서 누르고 밖으로 끌고 나가 뗀 건 클릭이 아니다
15    const isLaidOut = surface.columns > 0 && surface.rows > 0
16    if (isLaidOut && (ev.x < 0 || ev.y < 0 || ev.x >= surface.columns || ev.y >= surface.rows)) return
17    surface.post({ click: true })
18  })
19  return (
20    <Text backgroundColor={props.bg} color={props.fg} bold={props.bold === true} wrap="truncate">
21      {props.text}
22    </Text>
23  )
24}
25
26export default Chip
27
types/index.d.ts 48 lines
1// sticky-memo가 세션 상태($.state)에 두는 값들의 형식.
2
3/** 메모 하나. title이 비면 text의 첫 줄을 제목으로 보여준다. color는 '#RRGGBB'. */
4export type StickyMemo = {
5  id: string
6  title: string
7  text: string
8  color: string
9  updatedAt: number
10}
11
12/** $.store에 저장하는 모양. rev는 쓸 때마다 1씩 올라서 다른 세션이 바뀐 걸 알아챈다. */
13export type StickyBoard = {
14  rev: number
15  memos: StickyMemo[]
16}
17
18/**
19 * 펼쳐서 고치는 중인 메모. 내용은 입력창을 빌려 고치므로, 사람이 쓰던 글(draft)을 보관했다가
20 * 수정이 끝나면 돌려준다. title·color가 null이면 아직 안 바꾼 것.
21 * openedAt은 수정을 시작한 시각: 입력칸 key에 넣어 수정마다 새 칸을 그린다(엔진의 입력칸은
22 * key와 value가 그대로면 사람이 쳐 둔 글을 남겨서, 앞 메모의 제목이 다음 메모 칸에 보인다).
23 */
24export type StickyEdit = {
25  id: string
26  openedAt: number
27  isNew: boolean
28  draft: string
29  title: string | null
30  color: string | null
31  isArmedDelete: boolean
32  hint: string | null
33}
34
35declare module 'claude-code' {
36  interface PluginState {
37    'sticky-memo': {
38      memos: StickyMemo[]
39      rev: number
40      isEditMode: boolean
41      edit: StickyEdit | null
42      flash: string | null
43      isHidden: boolean
44      isAllShown: boolean
45    }
46  }
47}
48