Tell your Claude Code sessions apart. /note pins a context comment to a session and /note-color gives it a color: the comment shows in a colored band above the…

A Claude Code mod that pins a context comment and a color to a session, so you can tell your sessions apart at a glance.
Screenshot: the band above the prompt, in orange.

Screenshot: a session in the sidebar with its red square and comment.

/note <text> — Set the comment/note — Show the current comment/note clear — Remove the comment and restore the sidebar title/note -time (or /note - time) — Toggle a time line in the band, such as note 14:02 · last cmd 14:35: when the comment was set and when you last submitted a prompt (the date is added when it is not today)/note-color <color> — Color the band and add a matching square to the sidebar title: red, green, blue, yellow, orange, purple, pink, teal, gray, common CSS names like wheat, or a hex value like #2b4a6f/note-color clear — Remove the colorComments, colors and the time line setting are saved per session and come back when you reopen it.
Run these two commands in a terminal. They install the mod for every Claude Code session, in the terminal and in the desktop app's Code tab:
claude plugin marketplace add avishalom/session-note
claude plugin install session-note@session-note --scope user
Restart Claude Code, then try /note hello and /note-color teal.
To update later:
claude plugin update session-note@session-note
hooks/register.tsx 267 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Note } from '../types'
5
6const note = atom({ plugin: 'session-note', key: 'note' } as const, null)
7// When the last prompt was submitted. Kept in memory only: it is refreshed by the
8// next prompt, so it is not worth persisting.
9const lastCmd = atom({ plugin: 'session-note', key: 'lastCmd' } as const, null)
10
11const SERVER = 'ccd_session_mgmt'
12const MARK = ' · 📝 '
13const EMPTY: Note = { text: '', color: null, baseTitle: null, noteAt: null, showTime: false }
14
15// Short names mapped to clear, saturated shades; textOn picks black or white
16// text for each so the band stays readable.
17const NAMED: Record<string, string> = {
18 red: '#e5484d', orange: '#f76b15', yellow: '#ffd60a', green: '#30a46c',
19 blue: '#0090ff', purple: '#8e4ec6', pink: '#e93d82', teal: '#12a594',
20 gray: '#8b8d98', grey: '#8b8d98',
21}
22
23// Other common CSS color names, kept as given but resolved to hex for the
24// text contrast and the square emoji.
25const CSS: Record<string, string> = {
26 black: '#000000', white: '#ffffff', silver: '#c0c0c0', maroon: '#800000',
27 navy: '#000080', olive: '#808000', lime: '#00ff00', aqua: '#00ffff',
28 cyan: '#00ffff', fuchsia: '#ff00ff', magenta: '#ff00ff', brown: '#a52a2a',
29 wheat: '#f5deb3', tan: '#d2b48c', beige: '#f5f5dc', ivory: '#fffff0',
30 khaki: '#f0e68c', gold: '#ffd700', goldenrod: '#daa520', coral: '#ff7f50',
31 salmon: '#fa8072', tomato: '#ff6347', crimson: '#dc143c', orchid: '#da70d6',
32 violet: '#ee82ee', plum: '#dda0dd', lavender: '#e6e6fa', indigo: '#4b0082',
33 turquoise: '#40e0d0', skyblue: '#87ceeb', steelblue: '#4682b4',
34 royalblue: '#4169e1', slategray: '#708090', chocolate: '#d2691e',
35 sienna: '#a0522d', peru: '#cd853f', mint: '#98ff98', mintcream: '#f5fffa',
36 forestgreen: '#228b22', seagreen: '#2e8b57', darkgreen: '#006400',
37 hotpink: '#ff69b4', lightpink: '#ffb6c1', peachpuff: '#ffdab9',
38}
39
40const toColor = (raw: string): string => NAMED[raw.toLowerCase()] ?? raw
41
42const toHex = (color: string): string | null => {
43 const named = CSS[color.toLowerCase()]
44 if (named) return named
45 const m = /^#?([0-9a-f]{6}|[0-9a-f]{3})$/i.exec(color)
46 if (!m || !m[1]) return null
47 const h = m[1]
48 return '#' + (h.length === 3 ? h.split('').map(c => c + c).join('') : h)
49}
50
51const rgb = (hex: string): [number, number, number] => {
52 const n = parseInt(hex.slice(1), 16)
53 return [n >> 16, (n >> 8) & 255, n & 255]
54}
55
56// Picks readable text for the band's background.
57const textOn = (bg: string): string => {
58 const hex = toHex(bg)
59 if (!hex) return 'white'
60 const [r, g, b] = rgb(hex)
61 return 0.299 * r + 0.587 * g + 0.114 * b > 150 ? 'black' : 'white'
62}
63
64const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
65const pad = (v: number): string => String(v).padStart(2, '0')
66
67// Local time as HH:MM, with the date in front when it is not today.
68const fmtTime = (ms: number, now: number): string => {
69 const d = new Date(ms)
70 const t = new Date(now)
71 const clock = `${pad(d.getHours())}:${pad(d.getMinutes())}`
72 const today = d.getFullYear() === t.getFullYear() && d.getMonth() === t.getMonth() && d.getDate() === t.getDate()
73 return today ? clock : `${MONTHS[d.getMonth()]} ${d.getDate()} ${clock}`
74}
75
76// "note 14:02 · last cmd 14:35". A comment set before the time line existed has
77// no recorded time, so its part is left out rather than shown as unknown.
78export const timeLine = (n: Note, lastCmdAt: number | null, now: number): string => {
79 const parts: string[] = []
80 if (n.text && n.noteAt) parts.push(`note ${fmtTime(n.noteAt, now)}`)
81 parts.push(`last cmd ${lastCmdAt ? fmtTime(lastCmdAt, now) : 'none yet'}`)
82 return parts.join(' · ')
83}
84
85const SQUARES = ['🟥', '🟧', '🟨', '🟩', '🟦', '🟪', '🟫', '⬛', '⬜']
86
87// The square emoji nearest the color, by hue, for the sidebar title.
88export const squareFor = (color: string | null): string | null => {
89 const hex = color ? toHex(color) : null
90 if (!hex) return null
91 const [r, g, b] = rgb(hex).map(v => v / 255) as [number, number, number]
92 const max = Math.max(r, g, b)
93 const min = Math.min(r, g, b)
94 const l = (max + min) / 2
95 const d = max - min
96 const s = d === 0 ? 0 : d / (1 - Math.abs(2 * l - 1))
97 if (s < 0.15 || d < 0.06) return l < 0.6 ? '⬛' : '⬜'
98 let h = max === r ? ((g - b) / d) % 6 : max === g ? (b - r) / d + 2 : (r - g) / d + 4
99 h = (h * 60 + 360) % 360
100 if (h >= 10 && h < 45 && l < 0.35) return '🟫'
101 if (h < 10 || h >= 330) return '🟥'
102 if (h < 45) return '🟧'
103 if (h < 70) return '🟨'
104 if (h < 170) return '🟩'
105 if (h < 260) return '🟦'
106 return '🟪'
107}
108
109const stripDecor = (title: string): string => {
110 let base = title.split(MARK)[0] ?? title
111 for (const sq of SQUARES) {
112 if (base.startsWith(sq + ' ')) base = base.slice(sq.length + 1)
113 }
114 return base
115}
116
117// Prompts that did not come from the person: they don't count as "last cmd".
118const AUTOMATED = new Set(['task-notification', 'scheduled-trigger', 'peer', 'plugin'])
119
120export const countsAsCommand = (kind: unknown): boolean => !AUTOMATED.has(String(kind ?? ''))
121
122// "/note - time", "/note -time" and "/note --time" all toggle the time line.
123export const isTimeToggle = (arg: string): boolean => /^-{1,2}\s*time$/i.test(arg.trim())
124
125const lastKey = async ($: EngineInterface) => `last:${await $.session.id()}`
126
127const storeKey = async ($: EngineInterface) => `note:${await $.session.id()}`
128
129const save = async ($: EngineInterface, next: Note) => {
130 const value = next.text || next.color || next.showTime ? next : null
131 await update($, note, () => value)
132 if (value) await $.store.set(await storeKey($), value)
133 else await $.store.delete(await storeKey($))
134}
135
136const currentTitle = async ($: EngineInterface): Promise<string | null> => {
137 try {
138 const res = await $.mcp.call(SERVER, 'get_session', { session_id: 'self' })
139 if (res.isError) return null
140 const block = res.content.find(b => b.type === 'text')
141 const data = JSON.parse(block && 'text' in block ? String(block.text) : '{}')
142 return typeof data.title === 'string' ? data.title : null
143 } catch {
144 return null
145 }
146}
147
148// Writes "<square> <base title> · 📝 <comment>" to the sidebar, or restores the base.
149const syncTitle = async ($: EngineInterface, n: Note): Promise<string> => {
150 const square = squareFor(n.color)
151 const isDecorated = Boolean(n.text || square)
152 const fromApp = n.baseTitle ? null : await currentTitle($)
153 const base = n.baseTitle ?? (fromApp === null ? null : stripDecor(fromApp))
154 if (!base) return 'Sidebar title not updated: the app session server is not reachable.'
155 const title = `${square ? square + ' ' : ''}${base}${n.text ? MARK + n.text : ''}`
156 try {
157 const res = await $.mcp.call(SERVER, 'set_session_title', { session_id: 'self', title })
158 if (res.isError) return 'Sidebar title not updated: the app declined the rename.'
159 } catch {
160 return 'Sidebar title not updated: the app session server is not reachable.'
161 }
162 const keep = isDecorated ? base : null
163 if (n.baseTitle !== keep) await save($, { ...n, baseTitle: keep })
164 return isDecorated ? 'Sidebar title updated.' : 'Sidebar title restored.'
165}
166
167export const register: Register = on => {
168 on('session.start', async ($, e, next) => {
169 await $.command.register({
170 name: 'note',
171 description: 'Set this session\'s context comment (no text shows it, "clear" removes it, "- time" toggles a time line)',
172 argumentHint: '[text | clear | - time]',
173 immediate: true,
174 })
175 await $.command.register({
176 name: 'note-color',
177 description: 'Set this session\'s color: a name (red, blue, teal, wheat...) or hex, or "clear"',
178 argumentHint: '[color | clear]',
179 immediate: true,
180 })
181 const saved = (await $.store.get(await storeKey($))) as Note | undefined
182 if (saved) await update($, note, () => saved)
183 const last = (await $.store.get(await lastKey($))) as number | undefined
184 if (typeof last === 'number') await update($, lastCmd, () => last)
185
186 return next(e)
187 })
188
189 // Remembers when the user last submitted a prompt, for the time line. The
190 // desktop app's prompts arrive as 'sdk', so count every origin but the
191 // automated ones.
192 on('prompt.submit', async ($, e, next) => {
193 const ran = await next(e)
194 if (countsAsCommand(e.origin?.kind)) {
195 try {
196 const at = Date.now()
197 await update($, lastCmd, () => at)
198 await $.store.set(await lastKey($), at)
199 } catch {
200 // the time line is a convenience; never fail the prompt over it
201 }
202 }
203 return ran
204 })
205
206 on('command.run', { command: 'note' }, async ($, e) => {
207 const arg = e.args.trim()
208 const cur = (await read($, note)) ?? EMPTY
209 if (!arg) {
210 return { text: cur.text ? `Comment: ${cur.text}` : 'No comment set. Use /note <text> (or /note - time for a time line).' }
211 }
212 if (isTimeToggle(arg)) {
213 const showTime = !cur.showTime
214 await save($, { ...cur, showTime })
215 return { text: showTime ? 'Time line on.' : 'Time line off.' }
216 }
217 const text = arg.toLowerCase() === 'clear' ? '' : arg
218 const n = { ...cur, text, noteAt: text ? Date.now() : null }
219 await save($, n)
220 const titled = await syncTitle($, n)
221
222 return { text: `${text ? 'Comment set.' : 'Comment cleared.'} ${titled}` }
223 })
224
225 on('command.run', { command: 'note-color' }, async ($, e) => {
226 const arg = e.args.trim()
227 const cur = (await read($, note)) ?? EMPTY
228 if (!arg) {
229 return { text: cur.color ? `Color: ${cur.color}` : 'No color set. Use /note-color <name or #hex>.' }
230 }
231 const color = arg.toLowerCase() === 'clear' ? null : toColor(arg)
232 const n = { ...cur, color }
233 await save($, n)
234 const titled = await syncTitle($, n)
235 const square = squareFor(color)
236
237 return {
238 text: color
239 ? `Session color set to ${color}${square ? ` ${square}` : ''}. ${titled}`
240 : `Session color cleared. ${titled}`,
241 }
242 })
243
244 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
245 const n = await read($, note)
246 if (e.props.hasSurvey || !n || (!n.text && !n.color && !n.showTime)) return next(e)
247 const last = await read($, lastCmd)
248
249 const { Box, Text } = $.ui.resolve(e)
250 const bg = n.color ?? undefined
251 const fg = bg ? textOn(bg) : undefined
252
253 return (
254 <Box backgroundColor={bg} paddingX={1} width={e.props.bodyColumns} flexDirection="column">
255 <Text color={fg} bold wrap="truncate-end">
256 📝 {n.text || '(no comment: /note <text>)'}
257 </Text>
258 {n.showTime && (
259 <Text color={fg} wrap="truncate-end">
260 🕐 {timeLine(n, last, Date.now())}
261 </Text>
262 )}
263 </Box>
264 )
265 })
266}
267types/index.d.ts 16 lines1export type Note = {
2 text: string
3 color: string | null
4 baseTitle: string | null
5 // Epoch ms the comment was last set; absent in state saved before 0.2.7.
6 noteAt?: number | null
7 // Whether the band shows the time line (/note - time).
8 showTime?: boolean
9}
10
11declare module 'claude-code' {
12 interface PluginState {
13 'session-note': { note: Note | null; lastCmd: number | null }
14 }
15}
16