SLOPSHOPPER

name-tag

🏷️ Names your session for you: after a few prompts it gives an untitled session a short title, and leaves one you named yourself alone.

newspinnermodel
A shopper browsing a rack in a slop shop
Preview Β· a replayed session in a sandbox
claude Β· ~/work/app Β· name-tag
β€Ί 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 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── β€Ί ⟨Claude Code's own drawing⟩ 🏷️ 1/3

Draws

Prompt hint
⟨Claude Code's own drawing⟩ 🏷️ 1/3
README

🏷️ Name Tag

Names your session for you.

/plugin install name-tag@claude-desk-neighbours

How it works

Once you've sent 3 prompts, Name Tag asks Haiku for a title of 3 to 6 words, based on your first few messages. It sets that as the session's title on your next prompt, because a hook can only set a title in a prompt's result.

It names a session once. If you've already named it yourself with /rename, it does nothing, and it ignores turns that subagents run.

The status item

A small item joins the status row under the prompt while it works: 🏷️ 1/3 counts your prompts, 🏷️ naming… shows while the title is being written, and 🏷️ named shows for one prompt after the title is set, then goes. It's plain text with nothing to click, and it never shows in a session you've already named.

Change the number of prompts in /config, or with /plugin configure name-tag@claude-desk-neighbours.

The one model call sends your first three prompts, cut to 300 characters each, to Haiku on your account.

Source 2 files
hooks/register.tsx 148 lines
1import { atom, read } from 'claude-code'
2import type { EngineInterface, Register, RenderElement, RenderNode } from 'claude-code'
3
4import type { NameTagStage, NameTagStatus } from '../types'
5
6const STATUS = { plugin: 'name-tag', key: 'status' } as const
7const status = atom(STATUS, { stage: 'hidden', turns: 0, goal: 3 })
8
9const SYSTEM =
10  'You name coding sessions. Reply with one title of 3 to 6 words that says what the work is. No quotes, no trailing punctuation.'
11const EXCERPTS = 3
12const EXCERPT_LENGTH = 300
13const TITLE_LENGTH = 60
14
15// A reload or /clear starts these over; `session_title` on the next prompt keeps a named session named.
16let isNamed = false
17let isNaming = false
18let isAnnounced = false
19let pending: string | null = null
20let generation = 0
21
22export function cleanTitle(reply: string): string | null {
23  const title = (reply.split('\n')[0] ?? '').replace(/^["'`\s]+|["'`\s.]+$/g, '').replace(/\s+/g, ' ')
24  return title === '' ? null : title.slice(0, TITLE_LENGTH)
25}
26
27export function labelOf({ stage, turns, goal }: NameTagStatus): string | null {
28  if (stage === 'counting') return `🏷️ ${turns}/${goal}`
29  if (stage === 'naming') return '🏷️ naming…'
30  if (stage === 'named') return '🏷️ named'
31  return null
32}
33
34// The hint line under the prompt is shared. Each Desk Neighbours mod adds one item to a row keyed
35// `desk-hint` after the engine's own hint; items are kept in order by their keys, so the row reads
36// the same alone or together, in any load order.
37const DESK = 'desk-hint'
38
39function keyOf(node: RenderNode | undefined): string {
40  if (typeof node !== 'object' || node === null || !('props' in node)) return ''
41  const key = (node.props as Record<string, unknown> | undefined)?.key
42  return typeof key === 'string' ? key : ''
43}
44
45function joinDesk(below: RenderElement, mine: RenderElement, wrap: (children: RenderNode[]) => RenderElement): RenderElement {
46  if (below.type !== 'Box' || keyOf(below) !== DESK) return wrap([below, mine])
47  const [line, ...items] = below.children ?? []
48  const sorted = [...items.filter(n => keyOf(n) !== keyOf(mine)), mine].sort((a, b) => keyOf(a).localeCompare(keyOf(b)))
49  return { ...below, children: line === undefined ? sorted : [line, ...sorted] }
50}
51
52async function nameSession($: EngineInterface, asked: number): Promise<void> {
53  const messages = await $.session.messages()
54  const prompts = messages
55    .filter(m => m.role === 'user' && m.text.trim() !== '')
56    .slice(0, EXCERPTS)
57    .map(m => `"""\n${m.text.trim().slice(0, EXCERPT_LENGTH)}\n"""`)
58  if (prompts.length === 0) return
59  const reply = await $.model.complete({
60    model: 'haiku',
61    effort: 'low',
62    maxTokens: 40,
63    timeoutMs: 15000,
64    system: SYSTEM,
65    prompt: `The session's first messages:\n${prompts.join('\n')}`,
66  })
67  if (!reply.isAnswered || asked !== generation) return
68  pending = cleanTitle(reply.text)
69}
70
71async function show($: EngineInterface, stage: NameTagStage, turns: number, goal: number): Promise<void> {
72  await $.state.set(STATUS, { stage, turns, goal }).catch(() => undefined)
73}
74
75export const register: Register = (on, options) => {
76  const goal = Math.max(1, Number(options.afterPrompts ?? 3))
77  on('turn.complete', async ($, e, next) => {
78    const result = await next(e)
79    if (isNamed || isNaming || pending !== null || e.agentId !== undefined) return result
80    const turns = await $.session.turns()
81    if (turns < goal) {
82      await show($, 'counting', turns, goal)
83      return result
84    }
85    isNaming = true
86    await show($, 'naming', turns, goal)
87    void nameSession($, generation)
88      .catch(() => undefined)
89      .finally(() => {
90        isNaming = false
91        if (pending === null && !isNamed) void show($, 'counting', turns, goal)
92      })
93    return result
94  })
95
96  // A title can only be set in a prompt's (or a session start's) result, so it lands on the prompt after the one that made it.
97  on('classic.UserPromptSubmit', async ($, e, next) => {
98    const result = await next(e)
99    if (e.session_title !== undefined) {
100      isNamed = true
101      isAnnounced = false
102      pending = null
103      await show($, 'hidden', 0, goal)
104      return result
105    }
106    if (isAnnounced) {
107      isAnnounced = false
108      await show($, 'hidden', 0, goal)
109    }
110    if (pending === null || (e.source !== undefined && e.source !== 'user')) return result
111    const sessionTitle = pending
112    pending = null
113    isNamed = true
114    isAnnounced = true
115    await show($, 'named', 0, goal)
116    return { ...result, sessionTitle }
117  })
118
119  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
120    const label = labelOf(await read($, status))
121    const below = await next(e)
122    if (label === null) return below
123    const { Box, Text } = $.ui.resolve(e)
124    // A Text drops its key, and the row is ordered by key, so the Text rides in a keyed Box.
125    const mine = (
126      <Box key="desk-8-name-tag">
127        <Text dimColor>{label}</Text>
128      </Box>
129    )
130    return joinDesk(below, mine, children => (
131      <Box key={DESK} flexDirection="row" columnGap={2}>
132        {children}
133      </Box>
134    ))
135  })
136
137  on('session.end', async ($, e, next) => {
138    if (e.reason === 'clear' || e.reason === 'resume') {
139      generation += 1
140      isNamed = false
141      isAnnounced = false
142      pending = null
143      await show($, 'hidden', 0, goal)
144    }
145    return next(e)
146  })
147}
148
types/index.d.ts 12 lines
1/** Where a session is on its way to a title: nothing to show, counting prompts, waiting on the model, or just named. */
2export type NameTagStage = 'hidden' | 'counting' | 'naming' | 'named'
3
4/** What the hint item draws: `turns` of `goal` prompts sent while counting. */
5export type NameTagStatus = { stage: NameTagStage; turns: number; goal: number }
6
7declare module 'claude-code' {
8  interface PluginState {
9    'name-tag': { status: NameTagStatus }
10  }
11}
12