Tags each session's sidebar title with a project color and a work emoji; /tag changes it with words, emoji or a picker

A Claude Code mod that puts a small color and picture at the start of each session's name in the Claude desktop app's sidebar, like 🟦🎬 Video edit, so you can spot sessions at a glance.
At the prompt of a Claude Code terminal session:
/plugin install session-tags --marketplace retropunk/claude
Answer y to add the marketplace, then press Enter to install it for your user account.
Or from a shell:
claude plugin install session-tags --marketplace retropunk/claude
In the Claude desktop app, where /plugin doesn't run: click + next to the prompt box, then Plugins, Add marketplace with retropunk/claude, then Add plugin.
Tags need the Claude desktop app, since that's where the sidebar is. Sessions started after the install pick the mod up.
An installed copy doesn't update on its own. To get the latest version, run this in a terminal (or ask Claude to run it for you), then start a new session:
claude plugin update session-tags@retropunk-claude
/tag auto off to stop automatic tags.Type words, and they turn into emoji:
/tag red fire → 🟥🔥
/tag yellow space → 🟨🪐
/tag blue video → 🟦🎬
Other ways to tag:
| Command | What it does |
|---|---|
/tag help | Shows every option, with examples |
/tag pick | Opens a picker beside the chat: choose a color, a picture, or ideas from a theme, then press Apply |
/tag pick ocean | Opens the picker with ocean ideas (any theme works) |
/tag | Lets Claude suggest a tag for this session |
/tag 🟪🎨 | Uses exactly the emoji you type |
/tag shape circles | Draws colors as circles (also hearts, or squares, the default) |
/tag red circle | A color in a shape, for just this tag |
/tag project red | Gives every session in this folder that color from now on |
/tag reset | Removes the tag; Claude picks a new one after the next reply |
/tag clear | Removes the tag and stops tagging this session |
/tag auto off / on | Turns automatic tags off or on |
/tag remove all | Shows what would change, then /tag remove all yes removes every session's tag and turns automatic tags off |
New to emoji? You never have to type one: words and the picker do it for you. To type one on a Mac anyway, press Control + Command + Space, search for a name like "purple square", and press Return.
claude plugin validate . checks the mod, and claude plugin test . runs its tests.
hooks/register.tsx 636 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, McpToolResult, Register } from 'claude-code'
3
4import type { Idea, Picker, Shape } from '../types'
5import {
6 EMOJI,
7 FUN_CHOICES,
8 MOST_IDEAS,
9 SHAPES,
10 WORK_CHOICES,
11 colorChoices,
12 inShape,
13 shapeNamed,
14 parseIdeas,
15 readWords,
16 splitTag,
17 tagOf,
18 untagged,
19} from './words'
20
21// The desktop app's own session tools: renaming through them changes the
22// title its sidebar and header draw.
23const APP = 'ccd_session_mgmt'
24
25const PICKER = 'tag-picker'
26const EMPTY_PICKER: Picker = {
27 color: '',
28 icon: '',
29 title: '',
30 note: '',
31 theme: '',
32 ideas: FUN_CHOICES,
33 shape: 'square',
34}
35const picker = atom({ plugin: 'session-tags', key: 'picker' } as const, EMPTY_PICKER)
36
37// The picker's state survives a reload, so one written by an older version
38// may lack fields this one draws; the defaults fill them in.
39const readPicker = async ($: EngineInterface): Promise<Picker> => ({ ...EMPTY_PICKER, ...(await read($, picker)) })
40
41// Project colors, handed out in this order the first time a folder is seen.
42const COLORS = ['🟦', '🟪', '🟩', '🟧', '🟥', '🟨', '🟫']
43
44// The kinds of work the classifier picks from, and the emoji each one gets.
45const WORK: Record<string, string> = {
46 'video or motion graphics': '🎬',
47 'design or images': '🎨',
48 'music or audio': '🎵',
49 'plugins, mods or integrations': '🔌',
50 'coding or app building': '💻',
51 'writing or documents': '📝',
52 'research or learning': '📚',
53 'data or analysis': '📊',
54 'setup or installation': '⚙️',
55 'experiment or something else': '🧪',
56}
57const OTHER_WORK = '🧪'
58
59// The most emoji a tag holds, so a long sentence can't crowd out the title.
60const MOST_EMOJI = 4
61
62const HELP = [
63 'Tags are small pictures (emoji) at the start of a session\'s name in the sidebar, like 🟦🎬. They help you spot sessions at a glance.',
64 '',
65 'Type words, and they turn into emoji:',
66 ' /tag red fire → 🟥🔥',
67 ' /tag yellow space → 🟨🪐',
68 ' /tag blue video → 🟦🎬',
69 ' /tag green done → 🟩✅',
70 '',
71 'Or press buttons instead of typing:',
72 ' /tag pick → opens a picker beside the chat',
73 ' /tag pick ocean → opens it with ocean ideas',
74 'In the picker, type any theme (like 80s arcade or Halloween) and press Return for a row of matching emoji. Press "More ideas like these" for different ones.',
75 '',
76 'Choose the shape of your colors:',
77 ' /tag shape circles → 🔴🟠🟡🟢🔵🟣',
78 ' /tag shape hearts → ❤️🧡💛💚💙💜',
79 ' /tag shape squares → 🟥🟧🟨🟩🟦🟪 (the default)',
80 ' /tag red circle → 🔴, for just this once',
81 '',
82 'Give a whole project one color:',
83 ' /tag project red → every session in this folder gets 🟥 from now on',
84 '',
85 'Let Claude choose:',
86 ' /tag → suggests a tag for this session',
87 ' /tag reset → removes the tag, and Claude picks again after the next reply',
88 ' /tag clear → removes the tag and stops tagging this session',
89 '',
90 'Turn automatic tags off or on:',
91 ' /tag auto off → sessions are no longer tagged on their own (/tag still works)',
92 ' /tag auto on → turns automatic tags back on',
93 '',
94 'Remove every tag at once:',
95 ' /tag remove all → shows what will change and asks you to confirm',
96 '',
97 'Colors you can type: red, orange, yellow, green, blue, purple, brown, black, white.',
98 'Other words work too, like pizza, rocket, music or cat. If a word isn\'t on the built-in list, Claude finds a matching emoji.',
99 '',
100 'Already know emoji? Type them directly: /tag 🟪🎨',
101 'To type one on a Mac, press Control + Command + Space, search for a name like "purple square", and press Return.',
102].join('\n')
103const HINT = 'Type /tag help for more ways to tag.'
104const NO_APP = 'Session tags need the Claude desktop app, and this session is not running in it.'
105
106// 'tagged' and 'kept' (it already had emoji) end auto-tagging, as does 'off'.
107type Status = 'tagged' | 'kept' | 'off'
108
109type Rename = { isRenamed: boolean; message: string }
110
111// Typed words read as emoji: the tag, what each word became, what found none.
112type Reading = { tag: string; said: string[]; missed: string[] }
113
114const basename = (path: string): string => path.split('/').filter(Boolean).at(-1) ?? path
115const textOf = (result: McpToolResult): string =>
116 result.content.map(block => block.text ?? '').join('\n')
117const failed = (renamed: Rename): string =>
118 `The app didn't rename the session. ${renamed.message}`.trim()
119
120async function readRecord($: EngineInterface, key: string): Promise<Record<string, string>> {
121 const value = await $.store.get(key)
122
123 return value !== null && typeof value === 'object' ? (value as Record<string, string>) : {}
124}
125
126async function setStatus($: EngineInterface, status: Status): Promise<void> {
127 const id = await $.session.id()
128 await $.store.set('sessions', { ...(await readRecord($, 'sessions')), [id]: status })
129}
130
131// Rejects where the app's tools are not connected (a terminal session).
132async function appTitle($: EngineInterface): Promise<string | undefined> {
133 const result = await $.mcp.call(APP, 'get_session', { session_id: 'self' })
134 if (result.isError) return undefined
135 try {
136 const { title } = JSON.parse(textOf(result)) as { title?: unknown }
137
138 return typeof title === 'string' ? title : undefined
139 } catch {
140 return undefined
141 }
142}
143
144async function rename($: EngineInterface, title: string): Promise<Rename> {
145 const result = await $.mcp.call(APP, 'set_session_title', { session_id: 'self', title })
146
147 return { isRenamed: !result.isError, message: textOf(result) }
148}
149
150async function projectColor($: EngineInterface, root: string): Promise<string> {
151 const colors = await readRecord($, 'colors')
152 const known = colors[root]
153 if (known !== undefined) return known
154 const used = new Set(Object.values(colors))
155 const color =
156 COLORS.find(one => !used.has(one)) ?? COLORS[Object.keys(colors).length % COLORS.length] ?? '🟦'
157 await $.store.set('colors', { ...colors, [root]: color })
158
159 return color
160}
161
162async function workEmoji($: EngineInterface, title: string, root: string): Promise<string> {
163 const messages = await $.session.messages()
164 const first = messages.find(message => message.role === 'user')?.text ?? ''
165 const text = [
166 `Project folder: ${basename(root)}`,
167 `Session title: ${title}`,
168 `First request: ${first.slice(0, 1500)}`,
169 ].join('\n')
170 const label = await $.model.classify(text, Object.keys(WORK)).catch(() => undefined)
171
172 return (label === undefined ? undefined : WORK[label]) ?? OTHER_WORK
173}
174
175// Squares unless the person chose circles or hearts, in the picker or /tag shape.
176async function readShape($: EngineInterface): Promise<Shape> {
177 const shape = await $.store.get('shape')
178
179 return shape === 'circle' || shape === 'heart' ? shape : 'square'
180}
181
182// A project's color is kept as first given and drawn in the shape now in use.
183async function recommend($: EngineInterface, title: string): Promise<{ color: string; work: string }> {
184 const root = await $.session.root()
185 const [color, work, shape] = await Promise.all([projectColor($, root), workEmoji($, title, root), readShape($)])
186
187 return { color: inShape(color, shape), work }
188}
189
190// A word the built-in list doesn't know, asked of a small model.
191async function emojiFor($: EngineInterface, phrase: string): Promise<string | undefined> {
192 const reply = await $.model
193 .complete({
194 model: 'haiku',
195 maxTokens: 20,
196 system: 'You answer with exactly one emoji and nothing else.',
197 prompt: `Which single emoji best stands for "${phrase}"?`,
198 })
199 .catch(() => undefined)
200 if (reply === undefined || !reply.isAnswered) return undefined
201
202 return reply.text.match(EMOJI)?.[0]
203}
204
205async function readTyped($: EngineInterface, text: string): Promise<Reading> {
206 const reading: Reading = { tag: '', said: [], missed: [] }
207 for (const piece of readWords(text, await readShape($))) {
208 if ('emoji' in piece) {
209 reading.tag += piece.emoji
210 if (piece.word !== undefined) reading.said.push(`${piece.word} → ${piece.emoji}`)
211 continue
212 }
213 const found = await emojiFor($, piece.phrase)
214 if (found === undefined) reading.missed.push(piece.phrase)
215 else {
216 reading.tag += found
217 reading.said.push(`${piece.phrase} → ${found}`)
218 }
219 }
220 reading.tag = (reading.tag.match(EMOJI) ?? []).slice(0, MOST_EMOJI).join('')
221
222 return reading
223}
224
225// On unless the person typed /tag auto off; kept across sessions.
226const isAutoOn = async ($: EngineInterface): Promise<boolean> => (await $.store.get('auto')) !== false
227
228// Module variables start over on a reload, which tries the app again.
229let isAppMissing = false
230let isTagging = false
231
232// Once per session, after a turn: a title with no emoji gets the recommended
233// tag; one that already starts with emoji is left as the person made it.
234async function autoTag($: EngineInterface): Promise<void> {
235 if (isAppMissing || isTagging || !(await isAutoOn($))) return
236 const id = await $.session.id()
237 if ((await readRecord($, 'sessions'))[id] !== undefined) return
238 isTagging = true
239 try {
240 const title = await appTitle($)
241 if (title === undefined || title.trim() === '') return
242 if (tagOf(title) !== '') return await setStatus($, 'kept')
243 const { color, work } = await recommend($, title)
244 // Recorded before renaming, so a rename the person declines is not asked again.
245 await setStatus($, 'tagged')
246 const { isRenamed } = await rename($, `${color}${work} ${title}`)
247 if (isRenamed) $.ui.toast(`Tagged this session ${color}${work}. To stop automatic tags, type /tag auto off`)
248 } catch {
249 isAppMissing = true
250 } finally {
251 isTagging = false
252 }
253}
254
255// A theme's ideas, asked of a small model; a refresh avoids the ones on show.
256async function themeIdeas($: EngineInterface, theme: string, avoid: Idea[]): Promise<Idea[]> {
257 const topic = theme === '' ? 'fun, playful things' : theme
258 const skip = avoid.length > 0 ? ` Don't use any of these: ${avoid.map(idea => idea.emoji).join(' ')}.` : ''
259 const reply = await $.model
260 .complete({
261 model: 'haiku',
262 maxTokens: 600,
263 system: 'You suggest emoji. You answer with a JSON array and nothing else.',
264 prompt:
265 `Suggest ${MOST_IDEAS} different emoji for the theme "${topic}", each with a short, fun name of one or two words.${skip}` +
266 ' Answer like [{"name":"Big wave","emoji":"🌊"}].',
267 })
268 .catch(() => undefined)
269 if (reply === undefined || !reply.isAnswered) return []
270
271 return parseIdeas(reply.text, new Set(avoid.map(idea => idea.emoji)))
272}
273
274// Fills the ideas row for a theme, or puts the built-in fun row back for an
275// empty one; the last theme is kept for the next time the picker opens.
276async function showIdeas($: EngineInterface, typed: string, isRefresh: boolean): Promise<void> {
277 const theme = typed.trim().slice(0, 60)
278 const shown = await readPicker($)
279 if (theme === '' && !isRefresh) {
280 await update($, picker, state => ({ ...state, theme: '', ideas: FUN_CHOICES, note: '' }))
281 await $.store.delete('theme')
282
283 return
284 }
285 await update($, picker, state => ({ ...state, note: `Finding ideas for ${theme || 'fun'}…` }))
286 const ideas = await themeIdeas($, theme, isRefresh ? shown.ideas : [])
287 if (ideas.length === 0) {
288 await update($, picker, state => ({
289 ...state,
290 note: `No ideas came back for ${theme || 'fun'}. Try again or type another theme.`,
291 }))
292
293 return
294 }
295 await update($, picker, state => ({ ...state, theme, ideas, note: '' }))
296 await $.store.set('theme', { theme, ideas })
297}
298
299async function storedTheme($: EngineInterface): Promise<{ theme: string; ideas: Idea[] }> {
300 const kept = (await $.store.get('theme')) as { theme?: unknown; ideas?: unknown } | undefined
301 const isKept = typeof kept?.theme === 'string' && Array.isArray(kept.ideas) && kept.ideas.length > 0
302
303 return isKept ? { theme: kept.theme as string, ideas: kept.ideas as Idea[] } : { theme: '', ideas: FUN_CHOICES }
304}
305
306async function openPicker($: EngineInterface, title: string, theme: string): Promise<string> {
307 const { color, icon } = splitTag(tagOf(title))
308 const [kept, shape] = await Promise.all([storedTheme($), readShape($)])
309 await update($, picker, () => ({ color, icon, title: untagged(title), note: '', shape, ...kept }))
310 const opened = await $.ui.open({ id: PICKER, title: 'Tag picker', focus: true })
311 if (!opened.isPlaced) return "The picker can't show here. Try typing words instead, like /tag blue video."
312 if (theme !== '') await showIdeas($, theme, false)
313
314 return theme === ''
315 ? 'The tag picker is open beside the chat. Choose a color and a picture, then press Apply.'
316 : `The tag picker is open with ideas for ${theme}. Choose a color and a picture, then press Apply.`
317}
318
319async function applyPick($: EngineInterface): Promise<void> {
320 const { color, icon, title } = await readPicker($)
321 const tag = `${color}${icon}`
322 if (tag === '') {
323 await update($, picker, state => ({ ...state, note: 'Choose a color or a picture first.' }))
324
325 return
326 }
327 await setStatus($, 'tagged')
328 const renamed = await rename($, `${tag} ${title}`)
329 const note = renamed.isRenamed ? `Done. This session is now ${tag} ${title}.` : failed(renamed)
330 await update($, picker, state => ({ ...state, note }))
331}
332
333async function suggestPick($: EngineInterface): Promise<void> {
334 const { title } = await readPicker($)
335 const { color, work } = await recommend($, title)
336 await update($, picker, state => ({ ...state, color, icon: work, note: 'Suggested. Press Apply to use it.' }))
337}
338
339async function removePick($: EngineInterface): Promise<void> {
340 const { title } = await readPicker($)
341 await setStatus($, 'off')
342 const renamed = await rename($, title)
343 const note = renamed.isRenamed ? 'Tag removed.' : failed(renamed)
344 await update($, picker, state => ({ ...state, color: '', icon: '', note }))
345}
346
347// A shape changes the color row and redraws the color chosen so far in it.
348async function setShape($: EngineInterface, shape: Shape): Promise<void> {
349 await $.store.set('shape', shape)
350 await update($, picker, state => ({
351 ...state,
352 shape,
353 color: state.color === '' ? '' : inShape(state.color, shape),
354 note: '',
355 }))
356}
357
358async function setAuto($: EngineInterface, choice: string): Promise<string> {
359 if (choice === 'off') {
360 await $.store.set('auto', false)
361
362 return 'Automatic tags are off. Sessions keep the tags they have, and /tag still works whenever you want one.\nTo turn them back on, type /tag auto on.'
363 }
364 if (choice === 'on') {
365 await $.store.set('auto', true)
366
367 return 'Automatic tags are on. A session with no tag gets one after its next reply.'
368 }
369
370 return `Automatic tags are ${(await isAutoOn($)) ? 'on' : 'off'}. Type /tag auto off or /tag auto on to change it.`
371}
372
373type Listed = { id: string; title: string }
374
375// Every active session's id and title, this one included (list_sessions leaves it out).
376async function allSessions($: EngineInterface): Promise<Listed[]> {
377 const [listed, self] = await Promise.all([
378 $.mcp.call(APP, 'list_sessions', { limit: 1000 }),
379 $.mcp.call(APP, 'get_session', { session_id: 'self' }),
380 ])
381 const read = (result: McpToolResult): unknown => {
382 try {
383 return result.isError ? undefined : JSON.parse(textOf(result))
384 } catch {
385 return undefined
386 }
387 }
388 const rows = [...(Array.isArray(read(listed)) ? (read(listed) as unknown[]) : []), read(self)]
389
390 return rows.flatMap(row => {
391 const { sessionId, title } = (row ?? {}) as { sessionId?: unknown; title?: unknown }
392
393 return typeof sessionId === 'string' && typeof title === 'string' ? [{ id: sessionId, title }] : []
394 })
395}
396
397// Removes the leading emoji from every active session, after a confirming
398// second run, and starts the mod over: automatic tags off, records forgotten.
399async function removeAll($: EngineInterface, isConfirmed: boolean): Promise<string> {
400 const tagged = (await allSessions($)).filter(
401 session => tagOf(session.title) !== '' && untagged(session.title).trim() !== '',
402 )
403 if (tagged.length === 0) return 'No session has a tag right now.'
404 const count = `${tagged.length} session${tagged.length === 1 ? '' : 's'}`
405 if (!isConfirmed) {
406 return [
407 `This removes the emoji from the start of ${count}, including any you added yourself:`,
408 ...tagged.slice(0, 3).map(session => ` ${session.title} → ${untagged(session.title)}`),
409 ...(tagged.length > 3 ? [` …and ${tagged.length - 3} more`] : []),
410 'It also turns automatic tags off and forgets the project colors.',
411 '',
412 'To go ahead, type /tag remove all yes',
413 ].join('\n')
414 }
415 const missed: string[] = []
416 for (const session of tagged) {
417 const result = await $.mcp.call(APP, 'set_session_title', {
418 session_id: session.id,
419 title: untagged(session.title),
420 })
421 if (result.isError) missed.push(untagged(session.title))
422 }
423 await $.store.set('auto', false)
424 await $.store.delete('sessions')
425 await $.store.delete('colors')
426 const removed = tagged.length - missed.length
427
428 return [
429 `Removed the tags from ${removed} session${removed === 1 ? '' : 's'}. Automatic tags are off.`,
430 ...(missed.length > 0 ? [`The app didn't rename: ${missed.join(', ')}`] : []),
431 'To start fresh later, type /tag auto on.',
432 ].join('\n')
433}
434
435async function runTag($: EngineInterface, args: string): Promise<string> {
436 const [first = '', ...rest] = args.split(/\s+/).filter(Boolean)
437 const command = first.toLowerCase()
438 const next = (rest[0] ?? '').toLowerCase()
439 if (command === 'help' || command === '?') return HELP
440 if (command === 'auto') return setAuto($, next)
441 if ((command === 'remove' || command === 'clear') && next === 'all') {
442 return removeAll($, (rest[1] ?? '').toLowerCase() === 'yes').catch(() => NO_APP)
443 }
444 const title = await appTitle($).catch(() => undefined)
445 if (title === undefined) return NO_APP
446 const bare = untagged(title)
447
448 if (command === 'pick' || command === 'picker' || command === 'choose') {
449 return openPicker($, title, rest.join(' '))
450 }
451
452 if (command === 'clear') {
453 await setStatus($, 'off')
454 const renamed = await rename($, bare)
455
456 return renamed.isRenamed ? 'Tag removed. This session stays untagged unless you run /tag again.' : failed(renamed)
457 }
458
459 if (command === 'reset') {
460 // Forgetting the session's status lets autoTag treat it as never tagged.
461 const id = await $.session.id()
462 const sessions = await readRecord($, 'sessions')
463 delete sessions[id]
464 await $.store.set('sessions', sessions)
465 const renamed = await rename($, bare)
466
467 return renamed.isRenamed ? 'Tag removed. A new one is picked after the next reply.' : failed(renamed)
468 }
469
470 if (command === 'shape' || command === 'shapes') {
471 const shape = shapeNamed(rest.join(' '))
472 if (shape === undefined) return `Choose squares, circles or hearts, like /tag shape circles.\n\n${HINT}`
473 await $.store.set('shape', shape)
474 const sample = colorChoices(shape).map(choice => choice.emoji).join('')
475 const name = SHAPES.find(one => one.shape === shape)?.name.toLowerCase() ?? shape
476 const { color, icon } = splitTag(tagOf(title))
477 if (color === '') return `Colors are ${name} now: ${sample}`
478 const tag = `${inShape(color, shape)}${icon}`
479 const renamed = await rename($, `${tag} ${bare}`)
480
481 return renamed.isRenamed ? `Colors are ${name} now: ${sample}\nThis session is tagged ${tag}.` : failed(renamed)
482 }
483
484 if (command === 'project') {
485 const words = rest.join(' ')
486 const color = words === '' ? undefined : (await readTyped($, words)).tag.match(EMOJI)?.[0]
487 if (color === undefined) return `Name a color, like /tag project red.\n\n${HINT}`
488 const root = await $.session.root()
489 const colors = await readRecord($, 'colors')
490 const before = colors[root]
491 await $.store.set('colors', { ...colors, [root]: color })
492 // The picture after a color, in whatever shape it was drawn, is kept.
493 const current = splitTag(tagOf(title))
494 const work = current.color !== '' && current.icon !== '' ? current.icon : await workEmoji($, bare, root)
495 await setStatus($, 'tagged')
496 const renamed = await rename($, `${color}${work} ${bare}`)
497
498 return renamed.isRenamed
499 ? `Sessions in ${basename(root)} now get ${color}. This one is tagged ${color}${work}.`
500 : failed(renamed)
501 }
502
503 const reading: Reading =
504 args === ''
505 ? { tag: Object.values(await recommend($, bare)).join(''), said: [], missed: [] }
506 : await readTyped($, args)
507 if (reading.tag === '') {
508 return `I couldn't find emoji for "${args}". Try other words, or /tag pick to choose with buttons.\n\n${HINT}`
509 }
510 await setStatus($, 'tagged')
511 const renamed = await rename($, `${reading.tag} ${bare}`)
512 if (!renamed.isRenamed) return failed(renamed)
513
514 return [
515 `Tagged this session ${reading.tag}.`,
516 ...(reading.said.length > 0 ? [reading.said.join(', ')] : []),
517 ...(reading.missed.length > 0 ? [`No emoji found for: ${reading.missed.join(', ')}`] : []),
518 '',
519 HINT,
520 ].join('\n')
521}
522
523export const register: Register = on => {
524 on('session.start', async ($, e, next) => {
525 await $.command.register({
526 name: 'tag',
527 description: 'Color-tag this session in the sidebar (try /tag help)',
528 argumentHint: 'help | pick | <words or emoji> | project <color> | auto off | remove all',
529 })
530
531 return next(e)
532 })
533
534 on('command.run', { command: 'tag' }, async ($, e) => ({ text: await runTag($, e.args.trim()) }))
535
536 on('turn.complete', async ($, e, next) => {
537 const done = await next(e)
538 // Not awaited: a rename the app asks the person to approve must not hold the turn.
539 if (e.agentId === undefined && !e.isAborted) void autoTag($)
540
541 return done
542 })
543
544 // Spacing is in cells, a row down being a line: sections stand two lines apart
545 // and wrapped buttons one, closer on the terminal, where every line counts.
546 on('ui.render', { component: 'Pane', requestId: PICKER }, async ($, e) => {
547 const elements = $.ui.resolve(e)
548 const { Box, Button, Text } = elements
549 // The mobile app draws no text field: there a theme comes from /tag pick <theme>.
550 const Input = 'Input' in elements ? elements.Input : undefined
551 const state = await readPicker($)
552 const isTerminal = e.surface === 'terminal'
553 const space = (terminal: number, graphical: number): number => (isTerminal ? terminal : graphical)
554 const tag = `${state.color}${state.icon}`
555 const preview = tag === '' ? state.title : `${tag} ${state.title}`
556 const choose = (field: 'color' | 'icon', emoji: string) => () =>
557 void update($, picker, current => ({ ...current, [field]: current[field] === emoji ? '' : emoji, note: '' }))
558 // A pane takes no text sizes or weights on any surface: bold and dim carry the hierarchy.
559 const heading = (text: string) => <Text bold>{text}</Text>
560 const caption = (text: string) => <Text dimColor>{text}</Text>
561 // Three looks, read top to bottom: choices are plain buttons (a check marks the
562 // chosen one), side actions are `plain`, and Apply alone is primary, at the end.
563 // `row` names each button's key, as the work and ideas rows may share a name.
564 const buttons = (field: 'color' | 'icon', row: string, list: Idea[]) => (
565 <Box flexDirection="row" flexWrap="wrap" columnGap={1} rowGap={space(0, 1)}>
566 {list.map(choice => (
567 <Button
568 key={`${row}-${choice.name}`}
569 label={`${state[field] === choice.emoji ? '✓ ' : ''}${choice.emoji} ${choice.name}`}
570 onPress={choose(field, choice.emoji)}
571 />
572 ))}
573 </Box>
574 )
575
576 return (
577 <Box flexDirection="column" gap={space(1, 2)} padding={space(0, 1)}>
578 <Box flexDirection="column" gap={space(0, 1)} padding={1} borderStyle="round" borderDimColor>
579 {caption('Preview')}
580 <Text bold>{preview}</Text>
581 <Box flexDirection="row" flexWrap="wrap" columnGap={2} rowGap={space(0, 1)}>
582 <Button key="suggest" label="Suggest one" plain onPress={() => void suggestPick($)} />
583 <Button key="remove" label="Remove tag" plain onPress={() => void removePick($)} />
584 </Box>
585 </Box>
586
587 <Box flexDirection="column" gap={space(0, 1)}>
588 {heading('1. Choose a color')}
589 <Box flexDirection="row" flexWrap="wrap" alignItems="center" columnGap={1} rowGap={space(0, 1)}>
590 {caption('Shape:')}
591 {SHAPES.map(one => (
592 <Button
593 key={`shape-${one.shape}`}
594 label={`${state.shape === one.shape ? '✓ ' : ''}${inShape('🟦', one.shape)} ${one.name}`}
595 onPress={() => void setShape($, one.shape)}
596 />
597 ))}
598 </Box>
599 {buttons('color', 'color', colorChoices(state.shape))}
600 </Box>
601
602 <Box flexDirection="column" gap={space(0, 1)}>
603 {heading('2. Choose a picture')}
604 {buttons('icon', 'work', WORK_CHOICES)}
605 </Box>
606
607 <Box flexDirection="column" gap={space(0, 1)}>
608 {heading(state.theme === '' ? '3. Ideas: just for fun' : `3. Ideas: ${state.theme}`)}
609 {caption('These are pictures too, in place of one from step 2.')}
610 {buttons('icon', 'idea', state.ideas)}
611 {Input === undefined ? (
612 caption('For other ideas, type /tag pick and a theme, like /tag pick ocean.')
613 ) : (
614 <Box flexDirection="column" gap={space(0, 1)}>
615 {caption('Want different ideas? Type a theme and press Return.')}
616 <Input
617 key="theme"
618 placeholder="ocean, 80s arcade, Halloween..."
619 value={state.theme}
620 submitLabel="show ideas"
621 onSubmit={(text: string) => void showIdeas($, text, false)}
622 />
623 </Box>
624 )}
625 <Button key="new-ideas" label="↻ More ideas like these" plain onPress={() => void showIdeas($, state.theme, true)} />
626 </Box>
627
628 <Box flexDirection="column" gap={space(0, 1)}>
629 <Button key="apply" label={tag === '' ? 'Apply' : `Apply ${tag}`} variant="primary" onPress={() => void applyPick($)} />
630 {state.note !== '' && <Text dimColor>{state.note}</Text>}
631 </Box>
632 </Box>
633 )
634 })
635}
636hooks/words.ts 192 lines1import type { Shape } from '../types'
2
3// One emoji: a pictograph or flag letter, its modifiers, and any joined parts.
4const UNIT =
5 '[\\p{Extended_Pictographic}\\u{1F1E6}-\\u{1F1FF}][\\uFE0F\\u{1F3FB}-\\u{1F3FF}]*(?:\\u200D\\p{Extended_Pictographic}\\uFE0F?)*'
6
7// The run of emoji a title starts with, and the space after it: its tag.
8export const LEADING_TAG = new RegExp(`^(?:${UNIT})+\\s*`, 'u')
9export const EMOJI = new RegExp(UNIT, 'gu')
10
11export const untagged = (title: string): string => title.replace(LEADING_TAG, '')
12export const tagOf = (title: string): string => title.slice(0, title.length - untagged(title).length).trim()
13export const isAllEmoji = (text: string): boolean => text !== '' && untagged(text) === ''
14
15export type Choice = { name: string; emoji: string }
16
17// Each color in each shape emoji draws it in. Emoji has no outlined colors,
18// and its diamonds and triangles come in only one or two colors.
19const PALETTE: Record<string, Record<Shape, string>> = {
20 Red: { square: '🟥', circle: '🔴', heart: '❤️' },
21 Orange: { square: '🟧', circle: '🟠', heart: '🧡' },
22 Yellow: { square: '🟨', circle: '🟡', heart: '💛' },
23 Green: { square: '🟩', circle: '🟢', heart: '💚' },
24 Blue: { square: '🟦', circle: '🔵', heart: '💙' },
25 Purple: { square: '🟪', circle: '🟣', heart: '💜' },
26 Brown: { square: '🟫', circle: '🟤', heart: '🤎' },
27 Black: { square: '⬛', circle: '⚫', heart: '🖤' },
28 White: { square: '⬜', circle: '⚪', heart: '🤍' },
29}
30
31export const SHAPES: { shape: Shape; name: string }[] = [
32 { shape: 'square', name: 'Squares' },
33 { shape: 'circle', name: 'Circles' },
34 { shape: 'heart', name: 'Hearts' },
35]
36
37const SHAPE_WORDS: Record<string, Shape> = {
38 square: 'square', squares: 'square', box: 'square', boxes: 'square',
39 circle: 'circle', circles: 'circle', dot: 'circle', dots: 'circle', ball: 'circle',
40 heart: 'heart', hearts: 'heart',
41}
42
43export const shapeNamed = (word: string): Shape | undefined => SHAPE_WORDS[word.trim().toLowerCase()]
44
45// The picker's color row, and the colors /tag knows by name, in one shape.
46export const colorChoices = (shape: Shape): Choice[] =>
47 Object.entries(PALETTE).map(([name, shapes]) => ({ name, emoji: shapes[shape] }))
48
49// The same color in another shape; an emoji that is no palette color is kept.
50export function inShape(emoji: string, shape: Shape): string {
51 const shapes = Object.values(PALETTE).find(one => Object.values(one).includes(emoji))
52
53 return shapes === undefined ? emoji : shapes[shape]
54}
55
56const isColor = (emoji: string): boolean => Object.values(PALETTE).some(one => Object.values(one).includes(emoji))
57
58export const WORK_CHOICES: Choice[] = [
59 { name: 'Video', emoji: '🎬' },
60 { name: 'Design', emoji: '🎨' },
61 { name: 'Music', emoji: '🎵' },
62 { name: 'Plugin', emoji: '🔌' },
63 { name: 'Code', emoji: '💻' },
64 { name: 'Writing', emoji: '📝' },
65 { name: 'Research', emoji: '📚' },
66 { name: 'Data', emoji: '📊' },
67 { name: 'Setup', emoji: '⚙️' },
68 { name: 'Experiment', emoji: '🧪' },
69]
70
71export const FUN_CHOICES: Choice[] = [
72 { name: 'Fire', emoji: '🔥' },
73 { name: 'Star', emoji: '⭐' },
74 { name: 'Rocket', emoji: '🚀' },
75 { name: 'Space', emoji: '🪐' },
76 { name: 'Idea', emoji: '💡' },
77 { name: 'Done', emoji: '✅' },
78 { name: 'In progress', emoji: '🚧' },
79 { name: 'Urgent', emoji: '🚨' },
80 { name: 'Party', emoji: '🎉' },
81 { name: 'Heart', emoji: '❤️' },
82 { name: 'Rainbow', emoji: '🌈' },
83 { name: 'Pizza', emoji: '🍕' },
84]
85
86// Color words name a palette color, drawn in whichever shape is in use.
87const COLOR_WORDS: Record<string, string> = {
88 ...Object.fromEntries(Object.keys(PALETTE).map(name => [name.toLowerCase(), name])),
89 violet: 'Purple',
90 gray: 'Black',
91 grey: 'Black',
92}
93
94// Words people are likely to type; anything else is asked of a small model.
95const WORDS: Record<string, string> = {
96 ...Object.fromEntries(
97 [...WORK_CHOICES, ...FUN_CHOICES].map(choice => [choice.name.toLowerCase(), choice.emoji]),
98 ),
99 film: '🎬', movie: '🎬', clip: '🎬', edit: '🎬', animation: '🎬', motion: '🎬',
100 art: '🎨', image: '🎨', images: '🎨', graphic: '🎨', graphics: '🎨', photo: '📷', camera: '📷',
101 song: '🎵', audio: '🎵', sound: '🎵', guitar: '🎸', rock: '🎸', metal: '🎸', podcast: '🎙️', voice: '🎙️',
102 plugins: '🔌', mod: '🔌', mods: '🔌', integration: '🔌',
103 coding: '💻', app: '📱', phone: '📱', build: '🛠️', tool: '🛠️', tools: '🛠️', fix: '🔧',
104 write: '📝', doc: '📝', docs: '📝', notes: '📝', book: '📖',
105 learn: '📚', learning: '📚', study: '📚', course: '📚',
106 chart: '📊', analysis: '📊', numbers: '📊',
107 install: '⚙️', config: '⚙️', settings: '⚙️',
108 test: '🧪', lab: '🧪',
109 hot: '🔥', favorite: '⭐', launch: '🚀', ship: '🚀', planet: '🪐',
110 finished: '✅', complete: '✅', wip: '🚧', progress: '🚧', important: '❗', waiting: '⏳', later: '⏳',
111 blocked: '⛔', bug: '🐛', review: '👀', goal: '🎯', target: '🎯', todo: '📌', pin: '📌',
112 love: '❤️', sun: '☀️', moon: '🌙', money: '💰', cash: '💰', gift: '🎁', game: '🎮', games: '🎮',
113 travel: '✈️', trip: '✈️', home: '🏠', work: '💼', office: '💼', client: '🤝', ai: '🤖', robot: '🤖',
114 magic: '✨', sparkle: '✨', brain: '🧠', fast: '⚡', lightning: '⚡', ghost: '👻', crown: '👑',
115 gem: '💎', diamond: '💎', plant: '🌱', tree: '🌳', flower: '🌸', ocean: '🌊', wave: '🌊',
116 mountain: '⛰️', mail: '📧', email: '📧', calendar: '📅', lock: '🔒', secret: '🔒', key: '🔑',
117 trophy: '🏆', win: '🏆', medicine: '💊', pill: '💊', health: '💊', casino: '🎰', vegas: '🎰',
118 coffee: '☕', cat: '🐱', dog: '🐶', pink: '🩷',
119}
120
121// A word or emoji the person typed, already an emoji, or a phrase to look up.
122export type Piece = { emoji: string; word?: string } | { phrase: string }
123
124const wordOf = (token: string): string => token.toLowerCase().replace(/[^\p{L}\p{N}-]/gu, '')
125
126// Splits typed words into emoji in order; neighboring unknown words stay one
127// phrase ("outer space"), so the lookup sees what the person meant. A color
128// takes `shape`, or the shape word after it ("red circle", "blue heart").
129export function readWords(text: string, shape: Shape): Piece[] {
130 const pieces: Piece[] = []
131 const tokens = text.split(/\s+/).filter(Boolean)
132 for (let i = 0; i < tokens.length; i++) {
133 const token = tokens[i] ?? ''
134 const word = wordOf(token)
135 const color = COLOR_WORDS[word]
136 const last = pieces.at(-1)
137 if (isAllEmoji(token)) pieces.push({ emoji: token })
138 else if (color !== undefined) {
139 const named = shapeNamed(wordOf(tokens[i + 1] ?? ''))
140 if (named !== undefined) i++
141 pieces.push({ emoji: PALETTE[color]?.[named ?? shape] ?? '', word: named === undefined ? word : `${word} ${named}` })
142 } else if (WORDS[word] !== undefined) pieces.push({ emoji: WORDS[word] ?? '', word })
143 else if (word === '') continue
144 else if (last !== undefined && 'phrase' in last) last.phrase = `${last.phrase} ${word}`
145 else pieces.push({ phrase: word })
146 }
147
148 return pieces
149}
150
151// The most ideas a theme fills the picker's row with.
152export const MOST_IDEAS = 12
153
154// Reads the model's JSON list of ideas, keeping one emoji per idea and
155// dropping repeats and anything in `avoid` (the ideas a refresh replaces).
156export function parseIdeas(text: string, avoid: ReadonlySet<string>): Choice[] {
157 const start = text.indexOf('[')
158 const end = text.lastIndexOf(']')
159 if (start < 0 || end <= start) return []
160 let raw: unknown
161 try {
162 raw = JSON.parse(text.slice(start, end + 1))
163 } catch {
164 return []
165 }
166 if (!Array.isArray(raw)) return []
167 const ideas: Choice[] = []
168 const seen = new Set<string>()
169 for (const item of raw) {
170 if (item === null || typeof item !== 'object') continue
171 const { name, emoji } = item as { name?: unknown; emoji?: unknown }
172 if (typeof name !== 'string' || typeof emoji !== 'string') continue
173 const one = emoji.trim().match(EMOJI)?.[0]
174 const label = name.trim().slice(0, 24)
175 const id = label.toLowerCase()
176 if (one === undefined || label === '' || avoid.has(one) || seen.has(one) || seen.has(id)) continue
177 seen.add(one)
178 seen.add(id)
179 ideas.push({ name: label, emoji: one })
180 }
181
182 return ideas.slice(0, MOST_IDEAS)
183}
184
185// Splits a tag into its first emoji, when it is a palette color in any shape,
186// and the rest, so the picker can show what a session already has.
187export function splitTag(tag: string): { color: string; icon: string } {
188 const first = tag.match(EMOJI)?.[0] ?? ''
189
190 return isColor(first) ? { color: first, icon: tag.slice(first.length) } : { color: '', icon: tag }
191}
192types/index.d.ts 24 lines1// One button in the picker: a short name and its emoji.
2export type Idea = { name: string; emoji: string }
3
4// The shape every color is drawn in, a preference kept across sessions.
5export type Shape = 'square' | 'circle' | 'heart'
6
7// What the picker pane shows: the choices so far, the session's bare title,
8// and the theme its ideas row was filled for ('' is the built-in fun row).
9export type Picker = {
10 color: string
11 icon: string
12 title: string
13 note: string
14 theme: string
15 ideas: Idea[]
16 shape: Shape
17}
18
19declare module 'claude-code' {
20 interface PluginState {
21 'session-tags': { picker: Picker }
22 }
23}
24