Names and colors each Claude Code session when it starts (folder, branch, instance number, machine, model, start time), so many sessions are easy to tell…

A Claude Code mod that names and colors every session when it starts, so a desktop full of sessions (and the Remote Control list on your phone) is easy to tell apart.
/rename [Opus 5.5] hess-laundry/fix/backfill #2 · fix the backfill script @ LUIS-DESKTOP Wed Oct 7th, 2026 9:05 am
/color pink
/nametag <preset>./nametag topic fix the backfill script puts what you are working on in the name./color colors, and moves to the next free one if another live session has it./model, or a ! cd you type into another folder, the name is updated. A /rename you type yourself stops that for the session.--resume or /relaunch gets its number and color back. A session it never tagged (an older resume, or one already running when you install the mod) keeps its name and color until you run /nametag force./plugin install session-nametag --marketplace lperezmo/session-nametag
Mods need CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1.
#2 only shows when a second session is open in the same folder, and the branch only shows inside git.
| Preset | Example |
|---|---|
compact | hess-laundry #2 |
branch | hess-laundry/fix/backfill #2 |
status | 🧺 hess-laundry/fix/backfill #2 · fix the backfill script |
host | hess-laundry/fix/backfill #2 @ LUIS-DESKTOP |
model | [Opus 5.5] hess-laundry/fix/backfill #2 @ LUIS-DESKTOP |
timed | [Opus 5.5] hess-laundry/fix/backfill #2 @ LUIS-DESKTOP Wed 9:05 am |
full (default) | [Opus 5.5] hess-laundry/fix/backfill #2 · fix the backfill script @ LUIS-DESKTOP Wed Oct 7th, 2026 9:05 am |
/nametag template {folder}{/branch}{ #nth}{ · topic}
/nametag help lists every token.
| Token | Example |
|---|---|
model | Opus 5.5 |
family | Opus |
folder | hess-laundry (the repository, so worktrees and subfolders share it) |
dir | the folder the session started in |
remote | lperezmo/hess-laundry |
branch | fix/backfill |
nth | 2 (hidden for the first session in a folder) |
num | 1 (always shown) |
host | LUIS-DESKTOP |
topic | fix the backfill script (set with /nametag topic) |
codename | brisk-otter (fixed for the session) |
sigil | 🧺 (an emoji per folder) |
todaycount | 7 (the 7th session started today on this machine) |
datetime | Wed Oct 7th, 2026 9:05 am |
date | Oct 7th, 2026 |
day | Wed |
time | 9:05 am |
updated | 2:14 pm, or Thu 2:14 pm on a later day |
updateddatetime | Thu Oct 8th, 2026 11:00 am |
updateddate | Oct 8th, 2026 |
The date tokens are when the session started and never move, even when a branch switch renames it. The updated tokens are when the name last changed. Each {...} group is dropped when one of its tokens is empty, so {/branch} disappears outside git.
| Command | What it does |
|---|---|
/nametag | This session's tag and the other live sessions |
/nametag presets | Preview every preset for this session |
/nametag <preset> | Use a preset from now on and rename this session |
/nametag default | Back to the default preset (full); reset works too |
/nametag template <text> | Use your own template |
/nametag save <name> [text] | Keep the template in use (or the one given) under a name |
/nametag <name> | Switch to a saved template |
/nametag delete <name> | Forget a saved template |
/nametag topic <text> | Set what this session is about; off clears it |
/nametag sigil <emoji> | Pick this folder's emoji; auto goes back to the one it was given |
/nametag force | Tag this session for the folder you are in now (after a cd), even a resumed or renamed one |
/nametag color <c> | auto (per folder), off, or a fixed color |
/nametag off [here] | Stop tagging new sessions, everywhere or in this folder |
/nametag on [here] | Start again |
To switch it off for a whole project, team included, set NAMETAG_OFF=1 in the project's .claude/settings.json env.
Nothing leaves your machine. The mod makes no network calls and sends no data anywhere; everything below stays local.
/rename and /color, in this session only, when the session starts, when the name changes (branch switch, /model, topic, a /nametag command) and never otherwise. Each leaves one short line in the transcript. A mod cannot set the name silently: the built-in security mod keeps classic hook events such as SessionStart away from installed mods.git..git/HEAD (the branch) and, only when your template uses remote, .git/config (the origin URL); the host name (COMPUTERNAME, HOSTNAME, /etc/hostname, or on macOS /Library/Preferences/SystemConfiguration/preferences.plist); the NAMETAG_OFF variable; the session's folder, model and start time. It never reads your prompts or the conversation: topic is only what you type after /nametag topic. No tokens, keys or credentials.session.start registers /nametag and tags the session; turn.start only marks that a turn is running (the prompt is not read), so a cd Claude makes is not followed; turn.complete re-checks git after each turn; every 2 seconds it looks at the shell's folder to follow a ! cd you type; command.run handles /nametag, notices a manual /rename or /color and refreshes after /model; session.end keeps the name across /clear and /relaunch.MIT
hooks/register.ts 917 lines1/**
2 * session-nametag: names and colors each Claude Code session when it starts,
3 * so a desktop full of sessions (and the Remote Control list on a phone) can
4 * be told apart at a glance.
5 *
6 * - The name comes from a template (`/nametag <preset>` or `/nametag
7 * template ...`) and is set by running /rename. After each turn and each
8 * /model it is worked out again, and renamed only when it changed (a branch
9 * switch, a new model). A /rename typed by the person stops that.
10 * - The color is set by running /color. A folder hashes to one of the eight
11 * colors, moving to the next free one when another live session holds it.
12 * - Live sessions keep an entry in the shared store with a heartbeat; that is
13 * where the per-folder instance number and the taken colors come from.
14 * - A resumed session the mod tagged before (a /relaunch, a plain --resume)
15 * gets its number and color back and keeps updating. A resumed session it
16 * never tagged keeps its name. /nametag force renames either.
17 *
18 * Why /rename and not the SessionStart hook's `sessionTitle`: the built-in
19 * security mod passes every classic hook event past user-installed mods, so
20 * a mod cannot answer it. /rename and /color each leave one short line.
21 *
22 * Every `$.noun.verb(...)` is written literally; the loader inventories them.
23 */
24
25import type { EngineInterface, On } from 'claude-code'
26
27import {
28 asConfig,
29 asLive,
30 asSeen,
31 assign,
32 baseName,
33 CONFIG_KEY,
34 folderKey,
35 HEARTBEAT_MS,
36 isAlive,
37 isPreset,
38 isWithin,
39 LIVE_PREFIX,
40 MAX_SAVED,
41 modelLabel,
42 mustYield,
43 parseArgs,
44 PRESET_ORDER,
45 PRESETS,
46 render,
47 resolveTemplate,
48 SEEN_PREFIX,
49 SEEN_TTL_MS,
50 USAGE,
51 type Config,
52 type LiveEntry,
53 type TagValues,
54 type Token,
55 usedTokens,
56} from './tag'
57import { branchFromHead, codenameFor, dayKey, gitDirFromFile, macHostName, originFromConfig, remoteSlug, shorten, sigilFor } from './extras'
58
59const COMMAND_NAME = 'nametag'
60
61/** The first tag waits for the prompt to mount, then retries while it is busy. */
62const START_DELAY_MS = 800
63const RUN_RETRIES = 20
64const RUN_RETRY_MS = 500
65
66/** `$.command.run` inside a hook that holds a turn or command is refused, so it waits a beat. */
67const DEFER_MS = 300
68
69/** This session's live entry; null when the mod is not tagging it. */
70let me: LiveEntry | null = null
71
72let isHeartbeat = false
73
74/** How often the shell's folder is looked at, to follow a `!cd` you type. */
75const FOLLOW_MS = 2000
76
77/** The shell's folder when last looked at; null until the first look. */
78let shellAt: string | null = null
79
80/** True while a main-loop turn runs: a cd then is Claude's, not yours. */
81let isTurn = false
82
83/** The host and the start time never change mid-session, so they are read once. */
84let fixed: { host: string; startedAt: number } | null = null
85
86async function readConfig($: EngineInterface): Promise<Config> {
87 return asConfig(await $.store.get(CONFIG_KEY))
88}
89
90async function save($: EngineInterface, entry: LiveEntry) {
91 await $.store.set(`${LIVE_PREFIX}${entry.id}`, entry)
92}
93
94/**
95 * Every other live session's entry. Stale ones (a crash, a killed terminal)
96 * are deleted on the way, and so are remembered sessions past their time.
97 *
98 * @param $ the engine interface
99 * @param selfId this session's id, left out of the answer
100 */
101async function others($: EngineInterface, selfId: string): Promise<LiveEntry[]> {
102 const now = await $.clock.now()
103 const keys = await $.store.keys()
104 const live: LiveEntry[] = []
105
106 for (const key of keys) {
107 if (key.startsWith(LIVE_PREFIX)) {
108 const entry = asLive(await $.store.get(key))
109
110 if (!entry || !isAlive(entry, now)) {
111 await $.store.delete(key)
112 } else if (entry.id !== selfId) {
113 live.push(entry)
114 }
115 } else if (key.startsWith(SEEN_PREFIX)) {
116 const seen = asSeen(await $.store.get(key))
117
118 if (!seen || now - seen.at > SEEN_TTL_MS) {
119 await $.store.delete(key)
120 }
121 } else if (key.startsWith('day.') && key !== dayKey(now)) {
122 await $.store.delete(key)
123 }
124 }
125
126 return live
127}
128
129/**
130 * The machine's name: the environment first, then /etc/hostname, then the
131 * macOS system configuration; empty when none has it, and the host group hides.
132 *
133 * @param $ the engine interface
134 */
135async function hostName($: EngineInterface): Promise<string> {
136 const fromEnv = (await $.env.get('COMPUTERNAME')) ?? (await $.env.get('HOSTNAME'))
137
138 if (fromEnv) {
139 return fromEnv.trim()
140 }
141
142 try {
143 const text = await $.fs.read('/etc/hostname', { as: 'text' })
144
145 if (typeof text === 'string' && text.trim()) {
146 return text.trim()
147 }
148 } catch {
149 // Not Linux, or not readable.
150 }
151
152 // macOS keeps the name in the system configuration file.
153 const plist = await readText($, '/Library/Preferences/SystemConfiguration/preferences.plist')
154
155 return macHostName(plist)
156}
157
158/** A git repository as its files show it: its top folder and its git folder. */
159type Repo = { top: string; gitDir: string }
160
161/**
162 * A file's text, or empty when it is missing or not a file.
163 *
164 * @param $ the engine interface
165 * @param path the file
166 */
167async function readText($: EngineInterface, path: string): Promise<string> {
168 try {
169 const text = await $.fs.read(path, { as: 'text' })
170
171 return typeof text === 'string' ? text : ''
172 } catch {
173 return ''
174 }
175}
176
177/**
178 * The git repository holding a folder, found by walking up to the nearest
179 * `.git`; null outside git. Reads files only: no git program runs.
180 *
181 * @param $ the engine interface
182 * @param folder the folder
183 */
184async function findRepo($: EngineInterface, folder: string): Promise<Repo | null> {
185 let dir = folder.replace(/[\\/]+$/, '')
186
187 while (dir) {
188 const dotGit = dir + '/.git'
189
190 if (await $.fs.exists(dotGit)) {
191 // A folder in a plain clone; a file pointing elsewhere in a worktree.
192 const text = await readText($, dotGit)
193 const gitDir = text ? gitDirFromFile(text, dir) : dotGit
194
195 return gitDir ? { top: dir, gitDir } : null
196 }
197
198 const up = dir.replace(/[\\/][^\\/]*$/, '')
199
200 if (up === dir) {
201 break
202 }
203
204 dir = up
205 }
206
207 return null
208}
209
210/**
211 * The branch checked out, from the repository's HEAD file.
212 *
213 * @param $ the engine interface
214 * @param repo the repository
215 */
216async function readBranch($: EngineInterface, repo: Repo): Promise<string> {
217 return branchFromHead(await readText($, repo.gitDir + '/HEAD'))
218}
219
220/**
221 * The origin remote's URL, from the repository's config file (a worktree's
222 * config lives in the main repository, named by its commondir file).
223 *
224 * @param $ the engine interface
225 * @param repo the repository
226 */
227async function readOrigin($: EngineInterface, repo: Repo): Promise<string> {
228 const common = (await readText($, repo.gitDir + '/commondir')).trim()
229 const shared = !common ? repo.gitDir : /^([a-z]:)?[\\/]/i.test(common) ? common : repo.gitDir + '/' + common
230
231 return originFromConfig(await readText($, shared + '/config'))
232}
233
234/**
235 * What the template is filled from, for this session right now, and the key
236 * of its folder (the repository's main folder, so worktrees and subfolders
237 * count as the project they belong to).
238 *
239 * @param $ the engine interface
240 * @param n the instance number
241 * @param at the folder to name it after; absent, the folder /nametag force
242 * pinned, else the folder the session started in
243 * @param isAll work out every token, not only the ones the template uses
244 * (the presets preview)
245 */
246async function values($: EngineInterface, n: number, at?: string, isAll = false): Promise<{ v: TagValues; key: string }> {
247 const config = await readConfig($)
248 const used = usedTokens(resolveTemplate(config.template, config.saved))
249 const wants = (...tokens: Token[]) => isAll || tokens.some((t) => used.has(t))
250 const root = at ?? me?.root ?? (await $.session.root())
251 const repo = await $.session.repo()
252 // The repository follows the shell's current folder, which moves when a
253 // tool cds somewhere; only one that holds the session's own folder counts.
254 const isShellRepo = repo !== null && isWithin(root, repo.root)
255 // The session's own repository, from its files.
256 const found = await findRepo($, root)
257 const project = isShellRepo && repo ? repo.root : (found?.top ?? root)
258
259 if (!fixed) {
260 fixed = { host: await hostName($), startedAt: (await $.session.usage()).startedAt }
261 }
262
263 const key = folderKey(project)
264 const branch = found && wants('branch') ? await readBranch($, found) : ''
265 let remote = ''
266
267 if (found && wants('remote')) {
268 // The engine's remote is for the shell's repository; trust it only when that is this one.
269 const url = repo && folderKey(repo.root) === key && repo.remote ? repo.remote : await readOrigin($, found)
270
271 remote = url ? remoteSlug(url) : ''
272 }
273
274 const v: TagValues = {
275 model: modelLabel(await $.session.model()),
276 folder: baseName(project),
277 dir: baseName(root),
278 branch,
279 remote,
280 n,
281 host: fixed.host,
282 startedAt: fixed.startedAt,
283 updatedAt: me?.updatedAt ?? fixed.startedAt,
284 topic: me?.topic ?? '',
285 codename: me?.codename ?? codenameFor(await $.session.id()),
286 sigil: sigilFor(key, config.sigils),
287 today: me?.today ?? 0,
288 }
289
290 return { v, key }
291}
292
293/**
294 * Whether the mod should leave this session alone: switched off, off for the
295 * folder, or off through NAMETAG_OFF (a project's settings `env` or the shell).
296 *
297 * @param $ the engine interface
298 * @param config the person's choices
299 * @param key the folder key
300 */
301async function isOff($: EngineInterface, config: Config, key: string): Promise<boolean> {
302 const env = (await $.env.get('NAMETAG_OFF')) ?? ''
303
304 return !config.isOn || config.offFolders.includes(key) || (env !== '' && env !== '0' && env.toLowerCase() !== 'false')
305}
306
307/**
308 * Runs /rename and then /color (either may be left out) once nothing holds
309 * the prompt, retrying while the session is still mounting or busy.
310 *
311 * @param $ the engine interface
312 * @param title the new name, or null to leave it
313 * @param color the new color, or null to leave it
314 * @param delayMs the wait before the first try
315 * @param tries attempts left
316 */
317function runSoon($: EngineInterface, title: string | null, color: string | null, delayMs = DEFER_MS, tries = RUN_RETRIES) {
318 if (!title && !color) {
319 return
320 }
321
322 $.clock.after(delayMs, async () => {
323 try {
324 if (title) {
325 await $.command.run({ command: 'rename', args: title })
326 }
327
328 if (color) {
329 await $.command.run({ command: 'color', args: color })
330 }
331 } catch (error) {
332 if (tries > 1) {
333 runSoon($, title, color, RUN_RETRY_MS, tries - 1)
334 } else {
335 $.ui.log(`session-nametag could not tag this session: ${error instanceof Error ? error.message : String(error)}`)
336 }
337 }
338 })
339}
340
341/**
342 * Takes a number and a color for this session, writes its live entry, and
343 * moves to the next number if another session starting at the same moment
344 * took the same one and wins the tie.
345 *
346 * @param $ the engine interface
347 * @param id this session's id
348 * @param key the folder key
349 * @param config the person's choices
350 * @param keep a color to keep instead of picking one (a resume), or undefined
351 * @param preferN the number to keep when it is still free (a resume)
352 */
353async function claim($: EngineInterface, id: string, key: string, config: Config, keep?: string | null, preferN?: number): Promise<LiveEntry> {
354 const now = await $.clock.now()
355 let live = await others($, id)
356 const picked = assign(key, live, config.color)
357 const isFree = preferN !== undefined && !live.some((o) => o.key === key && o.n === preferN)
358 const entry: LiveEntry = { id, key, n: isFree ? preferN : picked.n, color: keep === undefined ? picked.color : keep, at: now, title: null, isManual: false, codename: codenameFor(id) }
359
360 await save($, entry)
361
362 for (let i = 0; i < 5; i++) {
363 live = await others($, id)
364
365 if (!mustYield(entry, live)) {
366 break
367 }
368
369 entry.n = assign(key, live, 'off').n
370 await save($, entry)
371 }
372
373 return entry
374}
375
376/**
377 * Counts this session among the ones started today on this machine.
378 *
379 * @param $ the engine interface
380 */
381async function countToday($: EngineInterface): Promise<number> {
382 const key = dayKey(await $.clock.now())
383 const before = await $.store.get(key)
384 const count = (typeof before === 'number' ? before : 0) + 1
385
386 await $.store.set(key, count)
387
388 return count
389}
390
391/**
392 * Tags the session once it has started. A session with turns already in it
393 * was resumed or forked: one this mod tagged before gets its number and color
394 * back; any other keeps its name and only gets a color.
395 *
396 * @param $ the engine interface
397 */
398async function tagAtStart($: EngineInterface) {
399 const config = await readConfig($)
400 const id = await $.session.id()
401 const { key } = await values($, 1)
402
403 if (await isOff($, config, key)) {
404 return
405 }
406
407 const seen = asSeen(await $.store.get(`${SEEN_PREFIX}${id}`))
408 // A resume (a /relaunch) keeps its place in today's count; anything else takes the next one.
409 const today = seen?.today ?? (await countToday($))
410
411 if (seen) {
412 me = await claim($, id, key, config, seen.color, seen.n)
413 me = { ...me, today, ...(seen.codename ? { codename: seen.codename } : {}), ...(seen.topic ? { topic: seen.topic } : {}), ...(seen.isTopicSet ? { isTopicSet: true } : {}), title: seen.title, isManual: seen.isManual, ...(seen.updatedAt ? { updatedAt: seen.updatedAt } : {}), ...(seen.root ? { root: seen.root } : {}) }
414
415 const { v } = await values($, me.n)
416 let title = me.isManual ? null : render(resolveTemplate(config.template, config.saved), v)
417 const isRename = title !== null && title !== seen.title
418
419 if (title && isRename) {
420 const now = await $.clock.now()
421
422 title = render(resolveTemplate(config.template, config.saved), { ...v, updatedAt: now })
423 me = { ...me, title, updatedAt: now }
424 }
425
426 await save($, me)
427 runSoon($, isRename ? title : null, me.color, START_DELAY_MS)
428
429 return
430 }
431
432 if ((await $.session.turns()) > 0) {
433 // Resumed, forked, or the mod was just installed into a running session:
434 // its name and color may be the person's own, and neither can be read, so
435 // leave both. It still holds a number; /nametag force tags it.
436 me = await claim($, id, key, config, null)
437 me = { ...me, isManual: true, today }
438 await save($, me)
439
440 return
441 }
442
443 me = { ...(await claim($, id, key, config)), today }
444
445 const { v } = await values($, me.n)
446 const title = render(resolveTemplate(config.template, config.saved), v)
447
448 me = { ...me, title }
449 await save($, me)
450 runSoon($, title, me.color, START_DELAY_MS)
451}
452
453/**
454 * Works the name out again and renames when it changed: a branch switch, a
455 * /model, a new template.
456 *
457 * @param $ the engine interface
458 */
459async function refresh($: EngineInterface) {
460 if (!me || me.isManual || !me.title) {
461 return
462 }
463
464 const config = await readConfig($)
465 const { v } = await values($, me.n)
466 const title = render(resolveTemplate(config.template, config.saved), v)
467
468 if (title === me.title) {
469 return
470 }
471
472 // Something real changed: that is the moment the `updated` token shows.
473 const now = await $.clock.now()
474 const updated = render(resolveTemplate(config.template, config.saved), { ...v, updatedAt: now })
475
476 me = { ...me, title: updated, updatedAt: now }
477 await save($, me)
478 runSoon($, updated, null)
479}
480
481/**
482 * Follows a `!cd` you typed: when the shell's folder moved while no turn ran,
483 * the session is named for the new folder, as /nametag force would. A cd
484 * Claude runs during a turn is taken in when the turn ends and never renames.
485 *
486 * @param $ the engine interface
487 */
488async function followShell($: EngineInterface) {
489 if (isTurn) {
490 return
491 }
492
493 const cwd = await $.session.cwd()
494
495 if (shellAt === null || cwd === shellAt) {
496 shellAt = cwd
497
498 return
499 }
500
501 shellAt = cwd
502
503 if (!me || me.isManual || !me.title) {
504 return
505 }
506
507 const config = await readConfig($)
508 const { key } = await values($, me.n, cwd)
509
510 if (await isOff($, config, key)) {
511 return
512 }
513
514 const colorBefore = me.color
515
516 if (me.key !== key) {
517 const picked = assign(key, await others($, await $.session.id()), config.color)
518
519 me = { ...me, key, n: picked.n, color: me.color && config.color !== 'auto' ? me.color : picked.color }
520 }
521
522 me = { ...me, root: cwd }
523 await save($, me)
524 await refresh($)
525
526 if (me.color && me.color !== colorBefore) {
527 runSoon($, null, me.color)
528 }
529}
530
531/**
532 * Names and colors this session now, whatever its state: /nametag force and
533 * a preset change use it.
534 *
535 * @param $ the engine interface
536 */
537async function applyNow($: EngineInterface, isPin = false): Promise<string> {
538 const id = await $.session.id()
539 const config = await readConfig($)
540 const colorBefore = me?.color ?? null
541
542 if (isPin) {
543 // /nametag force: follow the shell to wherever it is now, and keep the
544 // session there; later re-checks use this folder, not the starting one.
545 const here = await $.session.cwd()
546 const { key } = await values($, 1, here)
547
548 if (!me || me.key !== key) {
549 const live = await others($, id)
550 const picked = assign(key, live, config.color)
551 const color = me?.color && config.color !== 'auto' ? me.color : picked.color
552
553 me = me ? { ...me, key, n: picked.n, color } : await claim($, id, key, config)
554 }
555
556 me = { ...me, root: here }
557 }
558
559 if (!me) {
560 const { key } = await values($, 1)
561
562 me = await claim($, id, key, config)
563 }
564
565 if (!me.color && config.color !== 'off') {
566 me = { ...me, color: assign(me.key, await others($, id), config.color).color }
567 }
568
569 const now = await $.clock.now()
570 const { v } = await values($, me.n)
571 const title = render(resolveTemplate(config.template, config.saved), { ...v, updatedAt: now })
572
573 me = { ...me, title, isManual: false, at: now, updatedAt: now }
574 await save($, me)
575 // A preset or template change keeps the color; only force, or a color that
576 // actually changed, runs /color again.
577 runSoon($, title, isPin || me.color !== colorBefore ? me.color : null)
578
579 return title
580}
581
582/**
583 * Forgets this session's live entry and remembers its number, name and color
584 * for a later resume.
585 *
586 * @param $ the engine interface
587 * @param entry the session's entry
588 */
589async function retire($: EngineInterface, entry: LiveEntry) {
590 await $.store.delete(`${LIVE_PREFIX}${entry.id}`)
591 await $.store.set(`${SEEN_PREFIX}${entry.id}`, { id: entry.id, n: entry.n, color: entry.color, title: entry.title, isManual: entry.isManual, at: await $.clock.now(), updatedAt: entry.updatedAt, root: entry.root, topic: entry.topic, isTopicSet: entry.isTopicSet, codename: entry.codename, today: entry.today })
592}
593
594/**
595 * After a /clear the process goes on under a new id and the color is reset:
596 * carry the entry over and set the name and color again.
597 *
598 * @param $ the engine interface
599 * @param before the entry from before the clear
600 */
601async function afterClear($: EngineInterface, before: LiveEntry) {
602 const id = await $.session.id()
603
604 me = { ...before, id, at: await $.clock.now() }
605 await save($, me)
606 runSoon($, me.isManual ? null : me.title, me.color)
607}
608
609/**
610 * The answer to a bare /nametag: this session's tag and the live sessions.
611 *
612 * @param $ the engine interface
613 */
614async function status($: EngineInterface): Promise<string> {
615 const config = await readConfig($)
616 const id = await $.session.id()
617 const live = await others($, id)
618 const template = isPreset(config.template)
619 ? `preset "${config.template}"`
620 : config.saved[config.template] !== undefined
621 ? `saved "${config.template}" (${config.saved[config.template] ?? ''})`
622 : `template ${config.template}`
623 const lines = [
624 me ? `This session: ${me.title ?? '(name left as is)'}${me.color ? `, ${me.color}` : ''}${me.isManual ? ', name kept as you set it' : ''}` : 'This session is not tagged. /nametag force tags it.',
625 `Naming: ${template}. Color: ${config.color}.${config.isOn ? '' : ' Off for new sessions.'}${config.offFolders.length ? ` Off in ${config.offFolders.length} folder(s).` : ''}`,
626 ]
627
628 if (live.length) {
629 lines.push('Other live sessions:')
630
631 for (const o of live) {
632 lines.push(` ${o.title ?? baseName(o.key)}${o.color ? ` (${o.color})` : ''}`)
633 }
634 }
635
636 return lines.join('\n')
637}
638
639export function register(on: On) {
640 on('session.start', async ($, e, next) => {
641 const result = await next(e)
642
643 if (!e.isInteractive) {
644 return result
645 }
646
647 await $.command.register({
648 name: COMMAND_NAME,
649 description: 'Name and color this session: presets, topic, templates, force, color, off',
650 argumentHint: '[preset|default|topic|template|save|force|color|sigil|off|on|presets|help]',
651 })
652
653 if (!isHeartbeat) {
654 isHeartbeat = true
655 $.clock.every(HEARTBEAT_MS, async () => {
656 if (me) {
657 me = { ...me, at: await $.clock.now() }
658 await save($, me)
659 }
660 })
661 $.clock.every(FOLLOW_MS, () => followShell($))
662 }
663
664 // A hot reload starts the module over mid-session: pick the entry back up
665 // instead of tagging again.
666 const existing = asLive(await $.store.get(`${LIVE_PREFIX}${await $.session.id()}`))
667
668 if (existing) {
669 me = existing
670
671 return result
672 }
673
674 $.clock.after(0, () => {
675 tagAtStart($).catch((error: unknown) => {
676 $.ui.log(`session-nametag could not tag this session: ${error instanceof Error ? error.message : String(error)}`)
677 })
678 })
679
680 return result
681 })
682
683 // A folder change found as a turn starts happened before it: a `!cd` you
684 // typed. Then the turn is marked, so a cd Claude makes in it is not
685 // followed. The prompt itself is never read.
686 on('turn.start', async ($, e, next) => {
687 if (!isTurn) {
688 await followShell($)
689 }
690
691 isTurn = true
692
693 return next(e)
694 })
695
696 on('turn.complete', async ($, e, next) => {
697 const result = await next(e)
698
699 if (!e.agentId) {
700 isTurn = false
701 shellAt = await $.session.cwd()
702 await refresh($)
703 }
704
705 return result
706 })
707
708 on('command.run', async ($, e, next) => {
709 const isPlugin = e.origin?.kind === 'plugin'
710
711 if (e.command === 'rename' && !isPlugin && me) {
712 me = { ...me, isManual: true }
713 await save($, me)
714 }
715
716 if (e.command === 'color' && !isPlugin && me) {
717 me = { ...me, color: null }
718 await save($, me)
719 }
720
721 if (e.command === 'model') {
722 const result = await next(e)
723
724 $.clock.after(DEFER_MS, () => {
725 refresh($).catch(() => undefined)
726 })
727
728 return result
729 }
730
731 if (e.command !== COMMAND_NAME) {
732 return next(e)
733 }
734
735 const parsed = parseArgs(e.args)
736
737 switch (parsed.kind) {
738 case 'show':
739 return { text: await status($) }
740 case 'help':
741 return { text: USAGE }
742 case 'error':
743 return { text: parsed.text }
744 case 'presets': {
745 const { v } = await values($, me?.n ?? 1, undefined, true)
746 const config = await readConfig($)
747 const names = [...PRESET_ORDER, ...Object.keys(config.saved).sort()]
748 const width = Math.max(8, ...names.map((name) => name.length))
749 const lines = names.map((name) => `${name === config.template ? '>' : ' '} ${name.padEnd(width)} ${render(resolveTemplate(name, config.saved), v)}`)
750 const custom = isPreset(config.template) || config.saved[config.template] !== undefined ? [] : [`> ${'(yours)'.padEnd(width)} ${render(config.template, v)} /nametag save <name> keeps it`]
751
752 return { text: ['Presets for this session (> is in use; /nametag <name> picks one):', ...lines, ...custom].join('\n') }
753 }
754 case 'preset':
755 case 'template': {
756 const config = await readConfig($)
757 const template = parsed.kind === 'preset' ? parsed.name : parsed.template
758
759 await $.store.set(CONFIG_KEY, { ...config, template })
760 const title = await applyNow($)
761
762 return { text: `New sessions will be named like this one: ${title}` }
763 }
764 case 'named': {
765 const config = await readConfig($)
766
767 if (config.saved[parsed.name] === undefined) {
768 return { text: `No preset or saved template called "${parsed.name}". /nametag presets lists them; /nametag help lists the options.` }
769 }
770
771 await $.store.set(CONFIG_KEY, { ...config, template: parsed.name })
772 const title = await applyNow($)
773
774 return { text: `Using "${parsed.name}": ${title}` }
775 }
776 case 'save': {
777 const config = await readConfig($)
778 const text = parsed.template ?? resolveTemplate(config.template, config.saved)
779 const isNew = config.saved[parsed.name] === undefined
780
781 if (isNew && Object.keys(config.saved).length >= MAX_SAVED) {
782 return { text: `You have ${MAX_SAVED} saved templates already; /nametag delete <name> makes room.` }
783 }
784
785 await $.store.set(CONFIG_KEY, { ...config, saved: { ...config.saved, [parsed.name]: text }, template: parsed.name })
786
787 if (parsed.template) {
788 const title = await applyNow($)
789
790 return { text: `Saved "${parsed.name}" and using it: ${title}` }
791 }
792
793 return { text: `${isNew ? 'Saved' : 'Updated'} "${parsed.name}": ${text}. /nametag ${parsed.name} brings it back.` }
794 }
795 case 'topic': {
796 if (!me) {
797 return { text: 'This session is not tagged. /nametag force tags it.' }
798 }
799
800 if (parsed.mode === 'show') {
801 return { text: me.topic ? `Topic: ${me.topic}` : 'No topic yet. /nametag topic <text> sets one.' }
802 }
803
804 me = parsed.mode === 'set'
805 ? { ...me, topic: shorten(parsed.text, 40), isTopicSet: true }
806 : { ...me, topic: '', isTopicSet: true }
807 await save($, me)
808 await refresh($)
809
810 return { text: parsed.mode === 'set' ? `Topic: ${me.topic}` : 'No topic for this session.' }
811 }
812 case 'sigil': {
813 const config = await readConfig($)
814 const { key } = await values($, me?.n ?? 1)
815 const sigils = { ...config.sigils }
816
817 if (parsed.emoji) {
818 sigils[key] = parsed.emoji
819 } else {
820 delete sigils[key]
821 }
822
823 await $.store.set(CONFIG_KEY, { ...config, sigils })
824 await refresh($)
825
826 return { text: `This folder's emoji: ${sigilFor(key, sigils)}` }
827 }
828 case 'delete': {
829 const config = await readConfig($)
830
831 if (config.saved[parsed.name] === undefined) {
832 return { text: `No saved template called "${parsed.name}".` }
833 }
834
835 const saved = { ...config.saved }
836 const text = saved[parsed.name] ?? ''
837
838 delete saved[parsed.name]
839
840 // Deleting the one in use keeps its text as the template, so nothing renames.
841 const template = config.template === parsed.name ? text : config.template
842
843 await $.store.set(CONFIG_KEY, { ...config, saved, template })
844
845 return { text: `Deleted "${parsed.name}".` }
846 }
847 case 'apply': {
848 const title = await applyNow($, true)
849
850 return { text: `Tagged: ${title}` }
851 }
852 case 'color': {
853 const config = await readConfig($)
854
855 await $.store.set(CONFIG_KEY, { ...config, color: parsed.color })
856
857 if (me && parsed.color !== 'off') {
858 const live = await others($, me.id)
859 const color = assign(me.key, live, parsed.color).color
860
861 me = { ...me, color }
862 await save($, me)
863 runSoon($, null, color)
864
865 return { text: `Color: ${parsed.color}${color && color !== parsed.color ? ` (${color} for this session)` : ''}.` }
866 }
867
868 if (me && parsed.color === 'off') {
869 me = { ...me, color: null }
870 await save($, me)
871 }
872
873 return { text: `Color: ${parsed.color}. New sessions follow it.` }
874 }
875 case 'power': {
876 const config = await readConfig($)
877
878 if (parsed.isHere) {
879 const { key } = await values($, 1)
880 const offFolders = parsed.isOn ? config.offFolders.filter((k) => k !== key) : [...new Set([...config.offFolders, key])]
881
882 await $.store.set(CONFIG_KEY, { ...config, offFolders })
883
884 return { text: `New sessions in this folder will ${parsed.isOn ? 'be tagged again' : 'be left alone'}.` }
885 }
886
887 await $.store.set(CONFIG_KEY, { ...config, isOn: parsed.isOn })
888
889 return { text: parsed.isOn ? 'New sessions will be tagged again.' : 'New sessions will be left alone. /nametag on turns it back on.' }
890 }
891 }
892 })
893 // A failure here must never cost the person their command.
894 .catch(($, e, next) => next(e))
895
896 on('session.end', async ($, e, next) => {
897 const result = await next(e)
898
899 if (me) {
900 const before = me
901
902 me = null
903
904 if (e.reason === 'clear') {
905 await $.store.delete(`${LIVE_PREFIX}${before.id}`)
906 $.clock.after(DEFER_MS, () => {
907 afterClear($, before).catch(() => undefined)
908 })
909 } else {
910 await retire($, before)
911 }
912 }
913
914 return result
915 })
916}
917hooks/tag.ts 886 lines1/**
2 * Pure helpers for session-nametag: templates and presets, the date and model
3 * labels, color and instance-number picking, the live-session registry rules
4 * and the /nametag argument parser. Nothing here takes `$`, so the tests run
5 * it directly.
6 */
7
8/** The eight colors `/color` accepts, in the order a folder's hash walks them. */
9export const COLORS = ['red', 'blue', 'green', 'yellow', 'purple', 'orange', 'pink', 'cyan'] as const
10
11export type ColorName = (typeof COLORS)[number]
12
13/** Named templates, from compact to detailed. `full` is the default. */
14export const PRESET_ORDER = ['compact', 'branch', 'status', 'host', 'model', 'timed', 'full'] as const
15
16export type PresetName = (typeof PRESET_ORDER)[number]
17
18export const PRESETS: Record<PresetName, string> = {
19 compact: '{folder}{ #nth}',
20 branch: '{folder}{/branch}{ #nth}',
21 status: '{sigil }{folder}{/branch}{ #nth}{ · topic}',
22 host: '{folder}{/branch}{ #nth}{ @ host}',
23 model: '{[model] }{folder}{/branch}{ #nth}{ @ host}',
24 timed: '{[model] }{folder}{/branch}{ #nth}{ @ host}{ day time}',
25 full: '{[model] }{folder}{/branch}{ #nth}{ · topic}{ @ host}{ datetime}',
26}
27
28/**
29 * Whether a name is one of the presets.
30 *
31 * @param name a word the person typed or the config holds
32 */
33export function isPreset(name: string): name is PresetName {
34 return (PRESET_ORDER as readonly string[]).includes(name)
35}
36
37export const DEFAULT_PRESET = 'full'
38
39/** Longest title the mod sets; the session list truncates long ones anyway. */
40export const MAX_TITLE = 120
41
42/** A live entry older than this is a session that died without saying so. */
43export const STALE_MS = 3 * 60_000
44
45/** How often a session refreshes its own live entry. */
46export const HEARTBEAT_MS = 60_000
47
48/** How long a session's remembered name and color outlive it, for resume. */
49export const SEEN_TTL_MS = 30 * 24 * 60 * 60_000
50
51export const LIVE_PREFIX = 'live.'
52export const SEEN_PREFIX = 'seen.'
53export const CONFIG_KEY = 'config'
54
55/** What a template's tokens are filled from. Empty strings drop their group. */
56export type TagValues = {
57 model: string
58 folder: string
59 dir: string
60 branch: string
61 /** The instance number among live sessions in the same folder, from 1. */
62 n: number
63 host: string
64 /** When the session first started, in epoch milliseconds. */
65 startedAt: number
66 /** When the name last changed for a reason other than this time itself. */
67 updatedAt: number
68 /** What the session is about, set by /nametag topic. */
69 topic?: string
70 /** The origin remote as owner/name. */
71 remote?: string
72 /** Two words that stay with the session for life. */
73 codename?: string
74 /** The folder's emoji. */
75 sigil?: string
76 /** This session's place among the sessions opened today on this machine. */
77 today?: number
78}
79
80/** The token names a template may use. */
81export const TOKENS = [
82 'model', 'family', 'folder', 'dir', 'remote', 'branch', 'num', 'nth', 'host',
83 'topic', 'codename', 'sigil', 'todaycount',
84 'datetime', 'date', 'day', 'time', 'updated', 'updateddatetime', 'updateddate',
85] as const
86
87export type Token = (typeof TOKENS)[number]
88
89/** What each token is, for /nametag help and the typeahead. */
90export const TOKEN_HELP: Record<Token, string> = {
91 model: 'model and version, e.g. Opus 5.5',
92 family: 'model without the version, e.g. Opus',
93 folder: 'the repository (worktrees and subfolders share it)',
94 dir: 'the folder the session started in',
95 branch: 'git branch; the group hides outside git',
96 remote: 'the origin remote, e.g. lperezmo/hess-trading',
97 nth: 'instance number; hidden for the first session in a folder',
98 topic: 'what the session is about, set with /nametag topic <text>',
99 codename: 'two words that stay with the session, e.g. brisk-otter',
100 sigil: 'the folder emoji (/nametag sigil <emoji> picks one)',
101 todaycount: 'which session this is today on this machine, e.g. 7',
102 num: 'instance number, always shown',
103 host: 'this machine\'s name',
104 datetime: 'session start, e.g. Wed Oct 7th, 2026 9:05 am',
105 date: 'session start date, e.g. Oct 7th, 2026',
106 day: 'session start weekday, e.g. Wed',
107 time: 'session start time, e.g. 9:05 am',
108 updated: 'when the name last changed, e.g. 2:14 pm (Thu 2:14 pm on a later day)',
109 updateddatetime: 'when the name last changed, e.g. Thu Oct 8th, 2026 11:00 am',
110 updateddate: 'the day the name last changed, e.g. Oct 8th, 2026',
111}
112
113/** Groups with their usual punctuation, offered first in the typeahead. */
114export const SNIPPETS: readonly { text: string; token: Token }[] = [
115 { text: '{[model] }', token: 'model' },
116 { text: '{[family] }', token: 'family' },
117 { text: '{/branch}', token: 'branch' },
118 { text: '{ #nth}', token: 'nth' },
119 { text: '{ · topic}', token: 'topic' },
120 { text: '{sigil }', token: 'sigil' },
121 { text: '{ #todaycount today}', token: 'todaycount' },
122 { text: '{ @ host}', token: 'host' },
123 { text: '{ · updated}', token: 'updated' },
124]
125
126const TOKEN_RE = new RegExp('(?<![A-Za-z])(' + TOKENS.join('|') + ')(?![A-Za-z])', 'g')
127
128const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
129const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
130
131/**
132 * The English ordinal of a day of the month: 1st, 2nd, 3rd, 4th, 11th, 22nd.
133 *
134 * @param day the day of the month
135 */
136export function ordinal(day: number): string {
137 const tens = day % 100
138
139 if (tens >= 11 && tens <= 13) {
140 return `${day}th`
141 }
142
143 const suffix = ['th', 'st', 'nd', 'rd'][day % 10] ?? 'th'
144
145 return `${day}${day % 10 > 3 ? 'th' : suffix}`
146}
147
148/**
149 * The clock time as `9:00 am`.
150 *
151 * @param d the moment, read in local time
152 */
153export function clockTime(d: Date): string {
154 const h = d.getHours()
155 const m = String(d.getMinutes()).padStart(2, '0')
156
157 return `${h % 12 === 0 ? 12 : h % 12}:${m} ${h < 12 ? 'am' : 'pm'}`
158}
159
160/**
161 * The parts the date tokens render, from one moment in local time.
162 *
163 * @param ms epoch milliseconds
164 */
165export function dateParts(ms: number): { datetime: string; date: string; day: string; time: string } {
166 const d = new Date(ms)
167 const day = DAYS[d.getDay()] ?? ''
168 const date = `${MONTHS[d.getMonth()] ?? ''} ${ordinal(d.getDate())}, ${d.getFullYear()}`
169 const time = clockTime(d)
170
171 return { datetime: `${day} ${date} ${time}`, date, day, time }
172}
173
174/**
175 * When the name last changed: the clock time on the day the session started
176 * (`2:14 pm`), the weekday too on a later day (`Thu 2:14 pm`).
177 *
178 * @param startedAt when the session started
179 * @param updatedAt when the name last changed
180 */
181export function updatedLabel(startedAt: number, updatedAt: number): string {
182 const start = new Date(startedAt)
183 const d = new Date(updatedAt)
184 const isSameDay = start.getFullYear() === d.getFullYear() && start.getMonth() === d.getMonth() && start.getDate() === d.getDate()
185
186 return isSameDay ? clockTime(d) : `${DAYS[d.getDay()] ?? ''} ${clockTime(d)}`
187}
188
189/**
190 * A model id as a person says it: `claude-opus-5-5` reads `Opus 5.5`,
191 * `claude-sonnet-4-5-20250929[1m]` reads `Sonnet 4.5`. Unknown ids pass
192 * through with only the `claude-` prefix and bracket suffix removed.
193 *
194 * @param id the model id or alias the session runs on
195 */
196export function modelLabel(id: string): string {
197 const s = id.trim().toLowerCase().replace(/\[[^\]]*\]/g, '').replace(/^claude-/, '').replace(/-\d{8}$/, '')
198 const match = /^([a-z]+)(?:-(\d+))?(?:-(\d+))?$/.exec(s)
199
200 const name = match?.[1]
201
202 if (!match || !name) {
203 return s
204 }
205
206 const family = name.charAt(0).toUpperCase() + name.slice(1)
207 const version = [match[2], match[3]].filter(Boolean).join('.')
208
209 return version ? `${family} ${version}` : family
210}
211
212/**
213 * The last segment of a path, either separator: `D:\Python\foo` reads
214 * `foo`, a drive root `D:\` reads `D:`.
215 *
216 * @param path an absolute path
217 */
218export function baseName(path: string): string {
219 const parts = path.split(/[\\/]+/).filter(Boolean)
220
221 return parts[parts.length - 1] ?? path
222}
223
224/**
225 * The key two sessions share when they run on the same project: the folder's
226 * path, lowercased with one separator style, since Windows paths are
227 * case-insensitive.
228 *
229 * @param path the repository root or working directory
230 */
231export function folderKey(path: string): string {
232 return path.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
233}
234
235/**
236 * The tokens a template uses, so only those values are worked out (each git
237 * call costs a little on every turn).
238 *
239 * @param template the template text
240 */
241export function usedTokens(template: string): Set<Token> {
242 const used = new Set<Token>()
243
244 for (const group of template.matchAll(/\{([^{}]*)\}/g)) {
245 for (const m of (group[1] ?? '').matchAll(new RegExp(TOKEN_RE.source, 'g'))) {
246 used.add(m[1] as Token)
247 }
248 }
249
250 return used
251}
252
253/**
254 * Whether a folder is the other one or inside it, either separator, any case.
255 *
256 * @param child the folder that may be inside
257 * @param parent the folder that may hold it
258 */
259export function isWithin(child: string, parent: string): boolean {
260 const c = folderKey(child)
261 const p = folderKey(parent)
262
263 return c === p || c.startsWith(`${p}/`)
264}
265
266/**
267 * Fills a template. Each `{...}` group holds one or more tokens plus any
268 * literal text around them, and the whole group is dropped when one of its
269 * tokens is empty: `{/branch}` vanishes outside git, `{ #n}` vanishes for the
270 * first session in a folder (`num` always shows). Text outside groups is kept.
271 *
272 * @param template the template text
273 * @param v the values
274 */
275export function render(template: string, v: TagValues): string {
276 const when = dateParts(v.startedAt)
277 const value = (token: string): string => {
278 switch (token) {
279 case 'model': return v.model
280 case 'folder': return v.folder
281 case 'dir': return v.dir
282 case 'branch': return v.branch
283 case 'nth': return v.n > 1 ? String(v.n) : ''
284 case 'remote': return v.remote ?? ''
285 case 'topic': return v.topic ?? ''
286 case 'codename': return v.codename ?? ''
287 case 'sigil': return v.sigil ?? ''
288 case 'todaycount': return v.today ? String(v.today) : ''
289 case 'num': return String(v.n)
290 case 'host': return v.host
291 case 'family': return v.model.split(' ')[0] ?? ''
292 case 'datetime': return when.datetime
293 case 'date': return when.date
294 case 'day': return when.day
295 case 'time': return when.time
296 case 'updated': return updatedLabel(v.startedAt, v.updatedAt)
297 case 'updateddatetime': return dateParts(v.updatedAt).datetime
298 case 'updateddate': return dateParts(v.updatedAt).date
299 default: return ''
300 }
301 }
302
303 const out = template.replace(/\{([^{}]*)\}/g, (whole, inner: string) => {
304 let isEmpty = false
305 let isToken = false
306 const filled = inner.replace(TOKEN_RE, (token: string) => {
307 isToken = true
308 const text = value(token)
309
310 if (!text) {
311 isEmpty = true
312 }
313
314 return text
315 })
316
317 if (!isToken) {
318 return whole
319 }
320
321 return isEmpty ? '' : filled
322 })
323
324 const title = out.replace(/\s+/g, ' ').trim()
325
326 return title.length > MAX_TITLE ? `${title.slice(0, MAX_TITLE - 1).trimEnd()}…` : title
327}
328
329/**
330 * A preset's or saved template's text, or the text itself when it names
331 * neither.
332 *
333 * @param template a preset name, a saved name, or a template
334 * @param saved the person's saved templates by name
335 */
336export function resolveTemplate(template: string, saved: Readonly<Record<string, string>> = {}): string {
337 if (isPreset(template)) {
338 return PRESETS[template]
339 }
340
341 return Object.prototype.hasOwnProperty.call(saved, template) ? (saved[template] ?? template) : template
342}
343
344/** Words a saved template may not be called: the presets and the options. */
345export const RESERVED = ['presets', 'list', 'preview', 'template', 'force', 'apply', 'now', 'color', 'colour', 'off', 'on', 'help', 'save', 'delete', 'remove', 'topic', 'sigil', 'default', 'reset'] as const
346
347/** How many saved templates the store keeps. */
348export const MAX_SAVED = 20
349
350/**
351 * Why a name cannot be used for a saved template, or null when it can.
352 *
353 * @param name the name the person typed
354 */
355export function badName(name: string): string | null {
356 if (!/^[a-z0-9][a-z0-9_-]{0,23}$/.test(name)) {
357 return 'Names are 1 to 24 lowercase letters, digits, - or _, starting with a letter or digit.'
358 }
359
360 if (isPreset(name) || (RESERVED as readonly string[]).includes(name)) {
361 return `"${name}" is taken by a built-in preset or option; pick another name.`
362 }
363
364 return null
365}
366
367/**
368 * FNV-1a, 32 bit: a stable small hash for picking a folder's color.
369 *
370 * @param text what to hash
371 */
372export function hash(text: string): number {
373 let h = 0x811c9dc5
374
375 for (let i = 0; i < text.length; i++) {
376 h ^= text.charCodeAt(i)
377 h = Math.imul(h, 0x01000193) >>> 0
378 }
379
380 return h
381}
382
383/**
384 * The folder's own color, or the next one along that no other live session
385 * holds. With all eight taken, the folder's own color repeats.
386 *
387 * @param key the folder key
388 * @param taken colors other live sessions hold
389 */
390export function pickColor(key: string, taken: readonly string[]): ColorName {
391 const start = hash(key) % COLORS.length
392
393 for (let i = 0; i < COLORS.length; i++) {
394 const color = COLORS[(start + i) % COLORS.length]
395
396 if (color && !taken.includes(color)) {
397 return color
398 }
399 }
400
401 return COLORS[start] ?? 'blue'
402}
403
404/**
405 * The lowest instance number from 1 that no other live session in the same
406 * folder holds.
407 *
408 * @param taken numbers other live sessions in the folder hold
409 */
410export function pickNumber(taken: readonly number[]): number {
411 let n = 1
412
413 while (taken.includes(n)) {
414 n++
415 }
416
417 return n
418}
419
420/** One running session as the shared store records it. */
421export type LiveEntry = {
422 id: string
423 key: string
424 n: number
425 /** The color this mod set, or null when it set none. */
426 color: string | null
427 /** The last heartbeat, epoch milliseconds. */
428 at: number
429 /** The title this mod last set, so a manual rename can be told apart. */
430 title: string | null
431 /** True once the person renamed the session themselves. */
432 isManual: boolean
433 /** When the name last changed, for the `updated` token; absent until it does. */
434 updatedAt?: number
435 /** The folder /nametag force pinned the session to; absent, where it started. */
436 root?: string
437 /** What the session is about, set by /nametag topic. */
438 topic?: string
439 /** True once /nametag topic set it by hand (or switched it off). */
440 isTopicSet?: boolean
441 /** Two words that stay with the session for life. */
442 codename?: string
443 /** Its place among the sessions opened that day on this machine. */
444 today?: number
445}
446
447/** A session's name and color, kept after it ends so a resume can restore them. */
448export type SeenEntry = {
449 id: string
450 n: number
451 color: string | null
452 title: string | null
453 isManual: boolean
454 at: number
455 updatedAt?: number
456 root?: string
457 /** What the session is about, set by /nametag topic. */
458 topic?: string
459 /** True once /nametag topic set it by hand (or switched it off). */
460 isTopicSet?: boolean
461 /** Two words that stay with the session for life. */
462 codename?: string
463 /** Its place among the sessions opened that day on this machine. */
464 today?: number
465}
466
467/** The person's choices, shared by every session on the machine. */
468export type Config = {
469 /** A preset name or a custom template. */
470 template: string
471 /** `auto` picks per folder, `off` sets none, a color name pins one. */
472 color: string
473 /** False stops naming new sessions everywhere. */
474 isOn: boolean
475 /** Folder keys where new sessions are left alone. */
476 offFolders: string[]
477 /** The person's own templates by name (`/nametag save`). */
478 saved: Record<string, string>
479 /** Emoji the person picked per folder key (`/nametag sigil`). */
480 sigils: Record<string, string>
481}
482
483export const DEFAULT_CONFIG: Config = { template: DEFAULT_PRESET, color: 'auto', isOn: true, offFolders: [], saved: {}, sigils: {} }
484
485/**
486 * A stored value as a Config, defaults filling whatever is missing or wrong.
487 *
488 * @param raw what the store held
489 */
490export function asConfig(raw: unknown): Config {
491 const o = (raw && typeof raw === 'object' ? raw : {}) as Partial<Config>
492
493 return {
494 template: typeof o.template === 'string' && o.template.trim() ? o.template : DEFAULT_CONFIG.template,
495 color: typeof o.color === 'string' ? o.color : DEFAULT_CONFIG.color,
496 isOn: typeof o.isOn === 'boolean' ? o.isOn : DEFAULT_CONFIG.isOn,
497 offFolders: Array.isArray(o.offFolders) ? o.offFolders.filter((x): x is string => typeof x === 'string') : [],
498 saved: asSaved(o.saved),
499 sigils: asStrings(o.sigils),
500 }
501}
502
503/**
504 * A stored value as saved templates, keeping only well-formed entries.
505 *
506 * @param raw what the store held
507 */
508function asStrings(raw: unknown): Record<string, string> {
509 const out: Record<string, string> = {}
510
511 if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
512 for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
513 if (typeof v === 'string' && v.trim()) {
514 out[k] = v
515 }
516 }
517 }
518
519 return out
520}
521
522function asSaved(raw: unknown): Record<string, string> {
523 const out: Record<string, string> = {}
524
525 if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
526 for (const [name, text] of Object.entries(raw as Record<string, unknown>)) {
527 if (typeof text === 'string' && text.trim() && badName(name) === null) {
528 out[name] = text
529 }
530 }
531 }
532
533 return out
534}
535
536/**
537 * A stored value as a LiveEntry, or null when it is not one.
538 *
539 * @param raw what the store held
540 */
541export function asLive(raw: unknown): LiveEntry | null {
542 if (!raw || typeof raw !== 'object') {
543 return null
544 }
545
546 const o = raw as Partial<LiveEntry>
547
548 if (typeof o.id !== 'string' || typeof o.key !== 'string' || typeof o.n !== 'number' || typeof o.at !== 'number') {
549 return null
550 }
551
552 return {
553 id: o.id,
554 key: o.key,
555 n: o.n,
556 at: o.at,
557 color: typeof o.color === 'string' ? o.color : null,
558 title: typeof o.title === 'string' ? o.title : null,
559 isManual: o.isManual === true,
560 ...(typeof o.updatedAt === 'number' ? { updatedAt: o.updatedAt } : {}),
561 ...(typeof o.root === 'string' ? { root: o.root } : {}),
562 ...(typeof o.topic === 'string' ? { topic: o.topic } : {}),
563 ...(o.isTopicSet === true ? { isTopicSet: true } : {}),
564 ...(typeof o.codename === 'string' ? { codename: o.codename } : {}),
565 ...(typeof o.today === 'number' ? { today: o.today } : {}),
566 }
567}
568
569/**
570 * A stored value as a SeenEntry, or null when it is not one.
571 *
572 * @param raw what the store held
573 */
574export function asSeen(raw: unknown): SeenEntry | null {
575 if (!raw || typeof raw !== 'object') {
576 return null
577 }
578
579 const o = raw as Partial<SeenEntry>
580
581 if (typeof o.id !== 'string' || typeof o.at !== 'number') {
582 return null
583 }
584
585 return {
586 id: o.id,
587 n: typeof o.n === 'number' ? o.n : 1,
588 at: o.at,
589 color: typeof o.color === 'string' ? o.color : null,
590 title: typeof o.title === 'string' ? o.title : null,
591 isManual: o.isManual === true,
592 ...(typeof o.updatedAt === 'number' ? { updatedAt: o.updatedAt } : {}),
593 ...(typeof o.root === 'string' ? { root: o.root } : {}),
594 ...(typeof o.topic === 'string' ? { topic: o.topic } : {}),
595 ...(o.isTopicSet === true ? { isTopicSet: true } : {}),
596 ...(typeof o.codename === 'string' ? { codename: o.codename } : {}),
597 ...(typeof o.today === 'number' ? { today: o.today } : {}),
598 }
599}
600
601/**
602 * Whether a live entry's session still beats.
603 *
604 * @param entry the entry
605 * @param now epoch milliseconds
606 */
607export function isAlive(entry: LiveEntry, now: number): boolean {
608 return now - entry.at < STALE_MS
609}
610
611/**
612 * The number and color a new session should take, given the other live ones.
613 * When two sessions start at the same moment and pick the same number, the
614 * one whose id sorts later moves on, which `settle` decides after both wrote.
615 *
616 * @param key this session's folder key
617 * @param others the other sessions' live entries, stale ones already dropped
618 * @param colorChoice the config's color: auto, off, or a pinned name
619 */
620export function assign(key: string, others: readonly LiveEntry[], colorChoice: string): { n: number; color: string | null } {
621 const n = pickNumber(others.filter((o) => o.key === key).map((o) => o.n))
622 const color = colorChoice === 'off'
623 ? null
624 : (COLORS as readonly string[]).includes(colorChoice)
625 ? colorChoice
626 : pickColor(key, others.map((o) => o.color).filter((c): c is string => c !== null))
627
628 return { n, color }
629}
630
631/**
632 * Whether this session must give up its number because another live session
633 * in the same folder took it too and wins the tie (earlier id).
634 *
635 * @param me this session's entry
636 * @param others the other sessions' live entries
637 */
638export function mustYield(me: LiveEntry, others: readonly LiveEntry[]): boolean {
639 return others.some((o) => o.key === me.key && o.n === me.n && o.id < me.id)
640}
641
642export type Parsed =
643 | { kind: 'show' }
644 | { kind: 'help' }
645 | { kind: 'presets' }
646 | { kind: 'preset'; name: string }
647 | { kind: 'template'; template: string }
648 | { kind: 'apply' }
649 | { kind: 'color'; color: string }
650 | { kind: 'power'; isOn: boolean; isHere: boolean }
651 | { kind: 'save'; name: string; template: string | null }
652 | { kind: 'delete'; name: string }
653 | { kind: 'named'; name: string }
654 | { kind: 'topic'; mode: 'set' | 'off' | 'show'; text: string }
655 | { kind: 'sigil'; emoji: string | null }
656 | { kind: 'error'; text: string }
657
658export const USAGE = [
659 'Usage: /nametag [preset|default|topic|template|save|delete|force|color|sigil|off|on|presets|help]',
660 ' /nametag show this session\'s tag and the live sessions',
661 ' /nametag presets preview every preset for this session',
662 ` /nametag <preset> use a preset from now on: ${PRESET_ORDER.join(', ')}`,
663 ` /nametag default back to the default preset (${DEFAULT_PRESET}); reset works too`,
664 ' /nametag template <text> use your own template, e.g. {folder}{/branch}{ #nth}',
665 ' /nametag save <name> keep the template in use under a name; /nametag <name> brings it back',
666 ' /nametag save <name> <text> save that template under the name and use it',
667 ' /nametag delete <name> forget a saved template',
668 ' /nametag topic <text> set what this session is about (topic off: none)',
669 ' /nametag sigil <emoji> pick the emoji for this folder (sigil auto: back to the one it was given)',
670 ' /nametag force tag this session for the folder the shell is in now, even a resumed or renamed one',
671 ' /nametag color <c> auto (per folder), off, or one of: ' + COLORS.join(', '),
672 ' /nametag off [here] stop naming new sessions (here = only in this folder)',
673 ' /nametag on [here] start again',
674 'Tokens:',
675 ...TOKENS.map((t) => ' ' + t.padEnd(9) + ' ' + TOKEN_HELP[t]),
676 'A {group} with an empty token disappears, so {/branch} hides outside git and { #n} hides for the first session in a folder.',
677].join('\n')
678
679/** The first word after /nametag, with what it does, for the typeahead. */
680export const SUBCOMMANDS: readonly { name: string; description: string }[] = [
681 { name: 'presets', description: 'preview every preset for this session' },
682 ...PRESET_ORDER.map((name) => ({ name, description: `preset: ${PRESETS[name]}` })),
683 { name: 'default', description: `back to the default preset (${DEFAULT_PRESET})` },
684 { name: 'template', description: 'your own template; type { for the tokens' },
685 { name: 'save', description: 'keep the template in use under a name' },
686 { name: 'delete', description: 'forget a saved template' },
687 { name: 'topic', description: 'set what this session is about (off clears it)' },
688 { name: 'sigil', description: 'pick this folder emoji (auto)' },
689 { name: 'force', description: 'tag this session for the folder you are in now' },
690 { name: 'color', description: 'auto, off, or a fixed color' },
691 { name: 'off', description: 'stop tagging new sessions (add "here" for this folder only)' },
692 { name: 'on', description: 'start tagging again (add "here" for this folder only)' },
693 { name: 'help', description: 'commands and tokens' },
694]
695
696export type Suggestion = { text: string; label?: string; description?: string }
697
698const COMMAND_PREFIX = '/nametag '
699
700/**
701 * The typeahead rows for the word at the cursor while /nametag is typed: its
702 * options first, a color or "here" after the option that takes one, and in a
703 * template the tokens as soon as a `{` is typed.
704 *
705 * @param text the whole prompt box
706 * @param token the run of non-space characters that ends at the cursor
707 * @param start where that run begins in `text`
708 * @param saved the person's saved templates by name
709 */
710export function suggest(text: string, token: string, start: number, saved: Readonly<Record<string, string>> = {}): Suggestion[] {
711 if (!text.toLowerCase().startsWith(COMMAND_PREFIX) || start < COMMAND_PREFIX.length) {
712 return []
713 }
714
715 const words = text.slice(COMMAND_PREFIX.length, start).trim().split(/\s+/).filter(Boolean).map((w) => w.toLowerCase())
716 const typed = token.toLowerCase()
717 const brace = token.lastIndexOf('{')
718
719 const isTemplateArg = words[0] === 'template' || (words[0] === 'save' && words.length >= 2)
720
721 if (brace >= 0 && (words.length === 0 || isTemplateArg)) {
722 const before = token.slice(0, brace)
723 const partial = token.slice(brace + 1).toLowerCase()
724 const letters = partial.replace(/[^a-z]/g, '')
725 const rows: Suggestion[] = []
726
727 for (const s of SNIPPETS) {
728 if (s.text.toLowerCase().startsWith(`{${partial}`) || (letters && s.token.startsWith(letters))) {
729 rows.push({ text: before + s.text, label: s.text, description: TOKEN_HELP[s.token] })
730 }
731 }
732
733 // Punctuation typed after the { asks for a group like {/branch}; plain
734 // tokens only follow letters.
735 const isPlain = partial === letters
736
737 for (const t of TOKENS) {
738 if (isPlain && t.startsWith(letters) && !rows.some((r) => r.label === `{${t}}`)) {
739 rows.push({ text: `${before}{${t}}`, label: `{${t}}`, description: TOKEN_HELP[t] })
740 }
741 }
742
743 return rows
744 }
745
746 const mine = Object.keys(saved).sort().map((name) => ({ name, description: `saved: ${saved[name] ?? ''}` }))
747
748 if (words.length === 0) {
749 return [...SUBCOMMANDS, ...mine].filter((c) => c.name.startsWith(typed)).map((c) => ({ text: c.name, description: c.description }))
750 }
751
752 if (words.length === 1 && (words[0] === 'delete' || words[0] === 'remove')) {
753 return mine.filter((c) => c.name.startsWith(typed)).map((c) => ({ text: c.name, description: c.description }))
754 }
755
756 if (words.length === 1 && (words[0] === 'color' || words[0] === 'colour')) {
757 return ['auto', 'off', ...COLORS].filter((c) => c.startsWith(typed)).map((c) => ({
758 text: c,
759 description: c === 'auto' ? 'each folder its own color' : c === 'off' ? 'leave colors alone' : 'every session this color',
760 }))
761 }
762
763 if (words.length === 1 && (words[0] === 'off' || words[0] === 'on') && 'here'.startsWith(typed)) {
764 return [{ text: 'here', description: 'only sessions in this folder' }]
765 }
766
767 return []
768}
769
770/**
771 * Reads what followed `/nametag`.
772 *
773 * @param args the command's arguments as typed
774 */
775export function parseArgs(args: string): Parsed {
776 const text = args.trim()
777 const [first = '', ...rest] = text.split(/\s+/)
778 const word = first.toLowerCase()
779 const tail = text.slice(first.length).trim()
780
781 if (!word) {
782 return { kind: 'show' }
783 }
784
785 if (word === 'help' || word === '?' || word === '--help') {
786 return { kind: 'help' }
787 }
788
789 if (word === 'presets' || word === 'list' || word === 'preview') {
790 return { kind: 'presets' }
791 }
792
793 if (isPreset(word)) {
794 return { kind: 'preset', name: word }
795 }
796
797 if (word === 'default' || word === 'reset') {
798 return { kind: 'preset', name: DEFAULT_PRESET }
799 }
800
801 if (word === 'template') {
802 return tail ? { kind: 'template', template: tail } : { kind: 'error', text: 'Give the template after the word, e.g. /nametag template {folder}{/branch}{ #n}' }
803 }
804
805 if (word === 'force' || word === 'apply' || word === 'now') {
806 return { kind: 'apply' }
807 }
808
809 if (word === 'color' || word === 'colour') {
810 const color = (rest[0] ?? '').toLowerCase()
811
812 if (color === 'auto' || color === 'off' || (COLORS as readonly string[]).includes(color)) {
813 return { kind: 'color', color }
814 }
815
816 return { kind: 'error', text: `Color must be auto, off, or one of: ${COLORS.join(', ')}` }
817 }
818
819 if (word === 'off' || word === 'on') {
820 const where = (rest[0] ?? '').toLowerCase()
821
822 if (where && where !== 'here') {
823 return { kind: 'error', text: `/nametag ${word} takes nothing or "here"` }
824 }
825
826 return { kind: 'power', isOn: word === 'on', isHere: where === 'here' }
827 }
828
829 if (word === 'save') {
830 const name = (rest[0] ?? '').toLowerCase()
831
832 if (!name) {
833 return { kind: 'error', text: 'Give a name, e.g. /nametag save work' }
834 }
835
836 const why = badName(name)
837
838 if (why) {
839 return { kind: 'error', text: why }
840 }
841
842 const template = tail.slice(rest[0]?.length ?? 0).trim()
843
844 return { kind: 'save', name, template: template || null }
845 }
846
847 if (word === 'topic') {
848 const lower = tail.toLowerCase()
849
850 if (!tail) {
851 return { kind: 'topic', mode: 'show', text: '' }
852 }
853
854 if (lower === 'off') {
855 return { kind: 'topic', mode: 'off', text: '' }
856 }
857
858 return { kind: 'topic', mode: 'set', text: tail }
859 }
860
861 if (word === 'sigil') {
862 if (!tail) {
863 return { kind: 'error', text: 'Give an emoji, e.g. /nametag sigil 🦀, or auto' }
864 }
865
866 return { kind: 'sigil', emoji: tail.toLowerCase() === 'auto' ? null : tail }
867 }
868
869 if (word === 'delete' || word === 'remove') {
870 const name = (rest[0] ?? '').toLowerCase()
871
872 return name ? { kind: 'delete', name } : { kind: 'error', text: 'Give the saved name, e.g. /nametag delete work' }
873 }
874
875 if (text.includes('{')) {
876 return { kind: 'template', template: text }
877 }
878
879 // Maybe one of the person's saved templates; the command knows which exist.
880 if (!rest.length && badName(word) === null) {
881 return { kind: 'named', name: word }
882 }
883
884 return { kind: 'error', text: `Unknown option "${first}". ${PRESET_ORDER.join(', ')} are the presets; /nametag help lists the rest.` }
885}
886hooks/extras.ts 173 lines1/**
2 * Pure helpers for the tokens beyond place and time: git files, remote,
3 * codename, sigil and today's count. Nothing here takes `$`.
4 */
5
6import { hash } from './tag'
7
8/**
9 * Cuts text to a length on a word boundary, with an ellipsis when it was cut.
10 *
11 * @param text the text
12 * @param max the most characters to keep
13 */
14export function shorten(text: string, max: number): string {
15 const t = text.replace(/\s+/g, ' ').trim()
16
17 if (t.length <= max) {
18 return t
19 }
20
21 const cut = t.slice(0, max - 1)
22 const space = cut.lastIndexOf(' ')
23
24 return `${(space > max / 2 ? cut.slice(0, space) : cut).trimEnd()}…`
25}
26
27/**
28 * The branch named by a repository's HEAD file: the branch, or the short
29 * commit when detached; empty when HEAD says neither.
30 *
31 * @param head the HEAD file's text
32 */
33export function branchFromHead(head: string): string {
34 const text = head.trim()
35 const ref = /^ref:\s*refs\/heads\/(.+)$/.exec(text)
36
37 if (ref) {
38 return (ref[1] ?? '').trim()
39 }
40
41 return /^[0-9a-f]{7,}$/i.test(text) ? text.slice(0, 7) : ''
42}
43
44/**
45 * Where a `.git` file points: a worktree's or submodule's `.git` is a file
46 * reading `gitdir: <path>`, relative to the folder holding it or absolute.
47 * Empty when the text is not that.
48 *
49 * @param text the `.git` file's text
50 * @param dir the folder holding the `.git` file
51 */
52export function gitDirFromFile(text: string, dir: string): string {
53 const match = /^gitdir:\s*(.+)$/m.exec(text)
54 const target = (match?.[1] ?? '').trim()
55
56 if (!target) {
57 return ''
58 }
59
60 return /^([a-z]:)?[\\/]/i.test(target) ? target : dir + '/' + target
61}
62
63/**
64 * The `origin` remote's URL from a repository's config file; empty when it
65 * has none.
66 *
67 * @param config the config file's text
68 */
69export function originFromConfig(config: string): string {
70 let isOrigin = false
71
72 for (const raw of config.split(/\r?\n/)) {
73 const line = raw.trim()
74
75 if (line.startsWith('[')) {
76 isOrigin = /^\[remote\s+"origin"\]$/.test(line)
77 } else if (isOrigin) {
78 const match = /^url\s*=\s*(.+)$/.exec(line)
79
80 if (match) {
81 return (match[1] ?? '').trim()
82 }
83 }
84 }
85
86 return ''
87}
88
89/**
90 * The Mac's name from its system configuration plist: LocalHostName (the
91 * network name, e.g. Luiss-MacBook-Pro), else ComputerName; empty when the
92 * file is missing or not XML.
93 *
94 * @param plist the preferences.plist text
95 */
96export function macHostName(plist: string): string {
97 for (const name of ['LocalHostName', 'ComputerName']) {
98 const match = new RegExp('<key>' + name + '</key>\\s*<string>([^<]+)</string>').exec(plist)
99
100 if (match) {
101 return (match[1] ?? '').trim()
102 }
103 }
104
105 return ''
106}
107
108/**
109 * A remote URL as owner/name: scp style `git@host:owner/x.git`,
110 * `https://host/owner/x` and `ssh://host/a/owner/x.git` all work.
111 *
112 * @param url the remote's URL
113 */
114export function remoteSlug(url: string): string {
115 const path = url.trim().replace(/\/+$/, '').replace(/\.git$/, '').replace(/^[a-z+]+:\/\/[^/]+\//i, '').replace(/^[^@/]+@[^:]+:/, '')
116 const parts = path.split('/').filter(Boolean)
117
118 return parts.length >= 2 ? parts.slice(-2).join('/') : (parts[0] ?? '')
119}
120
121const ADJECTIVES = [
122 'amber', 'brisk', 'calm', 'clever', 'cosmic', 'crisp', 'daring', 'dusty', 'eager', 'fancy', 'fuzzy', 'gentle',
123 'giddy', 'glossy', 'golden', 'happy', 'hasty', 'humble', 'icy', 'jolly', 'keen', 'lively', 'lucky', 'mellow',
124 'mighty', 'misty', 'nimble', 'noble', 'odd', 'plucky', 'polite', 'proud', 'quick', 'quiet', 'rapid', 'rusty',
125 'shiny', 'silly', 'sleepy', 'snappy', 'sneaky', 'sunny', 'swift', 'tidy', 'tiny', 'vivid', 'witty', 'zesty',
126]
127
128const ANIMALS = [
129 'otter', 'badger', 'beaver', 'bison', 'camel', 'crane', 'dingo', 'eagle', 'ferret', 'finch', 'gecko', 'heron',
130 'ibis', 'koala', 'lemur', 'llama', 'lynx', 'marmot', 'moose', 'newt', 'ocelot', 'orca', 'owl', 'panda',
131 'pelican', 'puffin', 'quokka', 'raven', 'robin', 'salmon', 'seal', 'shrew', 'sloth', 'squid', 'stoat', 'swan',
132 'tapir', 'toucan', 'turtle', 'walrus', 'weasel', 'whale', 'wombat', 'yak', 'zebra', 'magpie', 'mole', 'goose',
133]
134
135/**
136 * Two words picked from the session id, the same every time for that id.
137 *
138 * @param id the session id
139 */
140export function codenameFor(id: string): string {
141 const h = hash(id)
142
143 return `${ADJECTIVES[h % ADJECTIVES.length] ?? 'brisk'}-${ANIMALS[Math.floor(h / ADJECTIVES.length) % ANIMALS.length] ?? 'otter'}`
144}
145
146/** Emoji a folder's hash picks from: plain, single-glyph, no flags or skin tones. */
147export const SIGILS = [
148 '🦀', '🐙', '🦊', '🐢', '🦉', '🐝', '🦋', '🐳', '🦜', '🐸', '🦔', '🐧', '🦦', '🦥', '🐌', '🦩',
149 '🌵', '🍄', '🌻', '🌲', '🍀', '🌊', '🔥', '⚡', '🌙', '⭐', '🪐', '☄️', '🌈', '❄️',
150 '🚂', '🚀', '⛵', '🛸', '🎈', '🎲', '🧩', '🔭', '🧪', '🧭', '🔧', '📦', '🗿', '🏔️', '🍉', '🍋',
151]
152
153/**
154 * The folder's emoji: the one the person set for it, or the one its hash picks.
155 *
156 * @param folder the folder key
157 * @param chosen emoji the person set per folder key
158 */
159export function sigilFor(folder: string, chosen: Readonly<Record<string, string>> = {}): string {
160 return chosen[folder] ?? SIGILS[hash('sigil:' + folder) % SIGILS.length] ?? '📦'
161}
162
163/**
164 * The local calendar day of a moment, as the key today's count is kept under.
165 *
166 * @param ms epoch milliseconds
167 */
168export function dayKey(ms: number): string {
169 const d = new Date(ms)
170
171 return `day.${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
172}
173