SLOPSHOPPER

session-tags

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

newpanecommandtoastmodel
★ 1v0.4.0MITupdated 2026-10-23retropunk/claude/mods/session-tags
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-tags
│ ┃ tag-picker ✕ › fix the failing auth test and add an audit log call │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ │ ⏺ Read(src/auth.ts) │ ┃ │ Preview │ ⎿ Read 6 lines │ ┃ │ │ ⏺ Update(src/auth.ts) │ ┃ │ Suggest one Remove tag │ ⎿ Added 2 lines, removed 1 line │ ┃ │ │ ⏺ Bash(bun test) │ ┃ ╰──────────────────────────────────────────╯ ⎿ 3 pass, 1 fail │ ┃ │ ┃ 1. Choose a color ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Shape: [ ✓ 🟦 Squares ] [ 🔵 Circles ] [ 💙 H │ ┃ [ 🟥 Red ] [ 🟧 Orange ] [ 🟨 Yellow ] [ 🟩 Gree ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ 2. Choose a picture › /tag │ ┃ [ 🎬 Video ] [ 🎨 Design ] [ 🎵 Music ] [ 🔌 ⎿ session-tags: Session tags need the Claude desktop app, and this │ ┃ │ ┃ 3. Ideas: just for fun │ ┃ These are pictures too, in place of one from │ ┃ step 2. │ ┃ [ 🔥 Fire ] [ ⭐ Star ] [ 🚀 Rocket ] [ 🪐 Spac │ ┃ Want different ideas? Type a theme and press │ ┃ Return. │ ┃ ocean, 80s arcade, Halloween... ⏎ show ideas │ ┃ ↻ More ideas like these │ ┃ │ ┃ [ Apply ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · tag-picker
╭──────────────────────────────────────────────────────────╮ │ │ │ Preview │ │ │ │ Suggest one Remove tag │ │ │ ╰──────────────────────────────────────────────────────────╯ 1. Choose a color Shape: [ ✓ 🟦 Squares ] [ 🔵 Circles ] [ 💙 Hearts ] [ 🟥 Red ] [ 🟧 Orange ] [ 🟨 Yellow ] [ 🟩 Green ] [ 🟦 Blue ] [ 2. Choose a picture [ 🎬 Video ] [ 🎨 Design ] [ 🎵 Music ] [ 🔌 Plugin ] [ 💻 C 3. Ideas: just for fun These are pictures too, in place of one from step 2. [ 🔥 Fire ] [ ⭐ Star ] [ 🚀 Rocket ] [ 🪐 Space ] [ 💡 Idea ] Want different ideas? Type a theme and press Return. ocean, 80s arcade, Halloween... ⏎ show ideas ↻ More ideas like these [ Apply ]
README

session-tags

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.

Install

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.

Updating

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

What it does

  • After a session's first reply, it adds a tag: a color for the project folder and a picture for the kind of work (🎬 video, 🎨 design, 🔌 plugins, 📝 writing, and more).
  • Every session in the same folder gets the same color.
  • A session whose name already starts with an emoji is left as it is.
  • A pop-up says when it tags. Type /tag auto off to stop automatic tags.

Changing a tag with /tag

Type words, and they turn into emoji:

/tag red fire        → 🟥🔥
/tag yellow space    → 🟨🪐
/tag blue video      → 🟦🎬

Other ways to tag:

CommandWhat it does
/tag helpShows every option, with examples
/tag pickOpens a picker beside the chat: choose a color, a picture, or ideas from a theme, then press Apply
/tag pick oceanOpens the picker with ocean ideas (any theme works)
/tagLets Claude suggest a tag for this session
/tag 🟪🎨Uses exactly the emoji you type
/tag shape circlesDraws colors as circles (also hearts, or squares, the default)
/tag red circleA color in a shape, for just this tag
/tag project redGives every session in this folder that color from now on
/tag resetRemoves the tag; Claude picks a new one after the next reply
/tag clearRemoves the tag and stops tagging this session
/tag auto off / onTurns automatic tags off or on
/tag remove allShows 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.

Good to know

  • Choosing the kind of work, finding an emoji for a word the mod doesn't know, and theme ideas each make one quick call to a small Claude model.
  • If you named a session yourself, the desktop app asks you before the mod renames it.
  • The shape, project colors and last theme are remembered across sessions.

Developing

claude plugin validate . checks the mod, and claude plugin test . runs its tests.

Source 3 files
hooks/register.tsx 636 lines
1import { 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}
636
hooks/words.ts 192 lines
1import 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}
192
types/index.d.ts 24 lines
1// 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