SLOPSHOPPER

session-nametag

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…

newcommandtimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-nametag
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /nametag ⎿ session-nametag: This session is not tagged. /nametag force tags it. ⎿ session-nametag: Naming: preset "full". Color: auto. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

session-nametag

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
  • Name from a template: model, folder, git branch, instance number, machine, start time. Change the style with /nametag <preset>.
  • Topic you set: /nametag topic fix the backfill script puts what you are working on in the name.
  • Color per folder: each folder hashes to one of the eight /color colors, and moves to the next free one if another live session has it.
  • Stays current: after a turn that switched branches, a /model, or a ! cd you type into another folder, the name is updated. A /rename you type yourself stops that for the session.
  • Resume aware: a session reopened with --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.

Install

/plugin install session-nametag --marketplace lperezmo/session-nametag

Mods need CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1.

Presets

#2 only shows when a second session is open in the same folder, and the branch only shows inside git.

PresetExample
compacthess-laundry #2
branchhess-laundry/fix/backfill #2
status🧺 hess-laundry/fix/backfill #2 · fix the backfill script
hosthess-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

Your own template

/nametag template {folder}{/branch}{ #nth}{ · topic}

/nametag help lists every token.

TokenExample
modelOpus 5.5
familyOpus
folderhess-laundry (the repository, so worktrees and subfolders share it)
dirthe folder the session started in
remotelperezmo/hess-laundry
branchfix/backfill
nth2 (hidden for the first session in a folder)
num1 (always shown)
hostLUIS-DESKTOP
topicfix the backfill script (set with /nametag topic)
codenamebrisk-otter (fixed for the session)
sigil🧺 (an emoji per folder)
todaycount7 (the 7th session started today on this machine)
datetimeWed Oct 7th, 2026 9:05 am
dateOct 7th, 2026
dayWed
time9:05 am
updated2:14 pm, or Thu 2:14 pm on a later day
updateddatetimeThu Oct 8th, 2026 11:00 am
updateddateOct 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.

Commands

CommandWhat it does
/nametagThis session's tag and the other live sessions
/nametag presetsPreview every preset for this session
/nametag <preset>Use a preset from now on and rename this session
/nametag defaultBack 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 forceTag 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.

What it does on your machine

Nothing leaves your machine. The mod makes no network calls and sends no data anywhere; everything below stays local.

  • Slash commands it runs: /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.
  • Programs it starts: none. Git is read from its files, not by running git.
  • What it reads: the repository's .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.
  • Hooks: 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.
  • What it stores: a small record per session in the mod's own store on this machine: session id, folder path, number, color, name, topic, last heartbeat; plus your settings and saved templates. Ended sessions are forgotten after 30 days.

License

MIT

Source 3 files
hooks/register.ts 917 lines
1/**
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}
917
hooks/tag.ts 886 lines
1/**
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}
886
hooks/extras.ts 173 lines
1/**
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