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

자주 쓰는 문구를 입력창 위에 색깔 칩으로 붙여 두고, 한 번 클릭하면 복사, 두 번 클릭하면 입력창에 넣어요.
<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 (작업 중에도 돼요) |
✎를 누르면 수정 모드가 돼요. 이때 칩을 누르면 복사나 넣기 대신 그 메모가 펼쳐져요.
[저장]을 누르면 저장돼요. 원래 입력창에 쓰던 글은 보관했다가 돌려줘요.# 칸에 #RRGGBB로 직접 적어요. 누르는 즉시 저장돼요.[취소]는 내용 수정만 버리고, [삭제]는 두 번 눌러야 지워져요.완료를 누르면 평소 모드로 돌아가요.+로 새 메모를 만들 때 입력창에 쓰던 글이 있으면 그 글로 시작해요. 방금 쓴 프롬프트를 그대로 메모로 남길 수 있어요. 저장한 뒤에도 입력창 글은 그대로 남아요.
칩이 한 줄에 다 들어가지 않으면 N개 더로 묶여요. 누르면 전부 펼쳐지고, 접기로 다시 접혀요.
메모는 모든 폴더에서 똑같이 보이고, 세션을 끝내도 남아요. 세션을 여러 개 띄워 두면 4초 안에 서로 맞춰져요.
칩 클릭은 Claude Code의 fullscreen 화면에서 받아요(설정 파일의 "tui": "fullscreen"). fullscreen이 아니거나 데스크톱 앱이면 칩이 키보드로 누를 수 있는 버튼으로 바뀌어요. ctrl+x tab으로 막대에 들어가 Tab으로 칩을 고르고 Enter를 누르면 복사, 빠르게 두 번 누르면 넣기예요.
없어요. 색은 메모마다 수정 모드에서 골라요.
/sticky 명령을 등록하고, 이 mod 전용 저장소($.store)에서 메모를 불러와요. 그 뒤 4초마다 저장소를 다시 읽어 다른 세션에서 바꾼 메모를 반영해요.Client)로 그려요. Claude Code의 일반 버튼은 더블클릭하면 두 번째 클릭을 넘겨주지 않고, 그 자리 글자를 골라 클립보드에 복사해 버리기 때문이에요.prompt.submit). 대화창에 Prompt dropped by a hook: '…' 메모를 저장했어요 한 줄이 남아요. 사람이 입력창에서 친 Enter만 가로채고, 작업 알림이나 다른 세션의 메시지는 그대로 보내요.~/.claude/plugins/store/ 아래 JSON 파일)에 메모를 저장해요.[Pasted text #1 +14 lines]처럼 접혀 보여요. 이 상태에서는 [저장] 버튼으로 저장할 수 없고(Claude Code가 접힌 글을 펼쳐 주지 않아요), Enter로 저장해야 해요. 수정을 시작할 때 입력창에 붙여넣은 글이 남아 있으면 먼저 보내거나 지워 달라고 안내해요.메인테이너(SeongGwangJu)가 윈도우의 '메모잇'처럼 쓰려고 만든 오리지널 mod예요. 외부 upstream은 없고, 저장소 루트의 MIT 라이선스를 따라요.
테스트한 Claude Code 버전: 2.1.296
hooks/register.tsx 495 lines1// 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}
495hooks/memos.ts 176 lines1// 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}
176hooks/chip.tsx 27 lines1// 칩 하나: 색 배경 위 글자. 그리기 스레드에서 돌며 마우스 신호를 직접 받는다.
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
27types/index.d.ts 48 lines1// 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