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

Names your session for you.
/plugin install name-tag@claude-desk-neighbours
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.
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.
hooks/register.tsx 148 lines1import { 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}
148types/index.d.ts 12 lines1/** 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