/rename run bare suggests 4 names from the whole session; /rename <name> works as before

A Claude Code mod that makes /rename suggest names from the whole session.
/rename run bare (the name is optional) reads the whole session, asks a model for 4 names, and shows them in a picker. Move with the arrow keys and press Enter: the mod then runs the built-in /rename <your pick> as a command of its own, visible in the transcript. Type your own under Other; a typed name is used exactly as you wrote it./rename my-name works exactly like the built-in./rename runs instead.Type /ren and Tab: there is only one /rename, and the menu says the name is optional.
/plugin install better-rename --marketplace trkaplan/claude-plugins
Answer y to add the marketplace and pick a scope (user scope = every session). Requires a Claude Code build with mods (function hooks); built and tested on 2.1.296.
/rename with no name. The picker shows four names drawn from the whole session, for example Fix login redirect bug, Auth middleware cleanup and tests, Login redirect loop after OAuth callback and Session cookie and redirect fixes. Pick one with the arrow keys and Enter; the session is renamed and /resume lists it under that name./rename release-notes-draft. The mod steps aside and the built-in renames the session at once; no model call is made./config, find better-rename, and set Rename format to kebab-case (or language to tr). The next bare /rename suggests fix-login-redirect-bug style names, or Turkish ones such as Giriş yönlendirme hatası.| Option | Values | Default | What it sets |
|---|---|---|---|
language | en, tr | en | Language of the suggested names and the mod's messages |
model | haiku, sonnet | haiku | Model that suggests the names |
format (Rename format) | Title, kebab-case | Title | How suggestions are written: Title gives a short sentence-case title (Fix webpack build error, Turkish letters kept), kebab-case gives fix-webpack-build-error (ASCII only, no quoting needed for claude --resume) |
Change them in /config or during install.
The session is condensed before it is sent, so a long session stays cheap while its beginning is never dropped:
/compact the earlier history lives in one summary; it is sent in full.Engine wrappers (system reminders, command output) are left out. Above about 30,000 estimated tokens the mod asks before it sends anything.
The reply must be a JSON object of 4 names in the chosen format. Every name is checked: 2 to 10 words, at most 60 characters, one line; generic names like New session and the session's current name are dropped, compared without case or punctuation. If fewer than 2 survive, the model is asked once more; prose is never offered as a name.
The mod is one TypeScript hooks module (hooks/register.ts). It hooks two events and changes nothing else:
| Hook | What it does | What it changes |
|---|---|---|
command.describe for rename | Rewrites the description and argument hint /rename shows in the typeahead, so the menu says the name is optional | The menu text of /rename only |
command.run for rename | With a name: passes the command on unchanged. Bare: builds the digest below, calls the model, shows the picker, and when you pick a name, runs the built-in /rename <name> as a separate command | Nothing in the original command; the built-in does the renaming |
The mod runs exactly one slash command, and only at one moment:
/rename <name> (the built-in), once, right after you pick a name in the picker or type one under Other. It runs as if you had typed it, shows in the transcript, and goes through every hook and setting a typed command does. If you close the picker or cancel at the token warning, nothing runs.It never runs any other command, and never runs one on its own initiative.
If the mod fails at any point, the original /rename runs unchanged. The mod reads nothing but its own three options above (no other settings, files or environment variables), writes nothing to disk, makes no network calls of its own, and touches no permissions. Its only outside call is the model call described below.
Every bare /rename makes one model call (two if the first reply is unusable) with the condensed session described above: your prompts, the assistant's replies and tool names. That includes anything you pasted into a prompt (trimmed as described above) and any compaction summary in full; tool results are not sent. The call goes to the same provider and account your session already uses, and is billed like any other request. With the default haiku model a typical call is a few thousand tokens. /rename <name> sends nothing.
The picker needs an interactive session: in a claude -p run a bare /rename reports that the pick was cancelled and changes nothing.
Only the current session, and only when you run a bare /rename. It never reads other sessions, Claude's memory, project or uploaded files, or anything on disk. What it reads is used for that one model call and then dropped: nothing is logged, stored or kept between runs. The only state it holds is the session's current name, in memory, so new suggestions differ from it.
/rename behaves like the built-in, the mod failed and fell back; run Claude Code with claude --debug and the debug log shows a line starting with better-rename: with the reason. Check that your Claude Code build supports mods.claude plugin validate plugins/better-rename
claude plugin test plugins/better-renamehooks/register.ts 81 lines1import type { ModelCompleteRequest, Register } from 'claude-code'
2
3import { LIMITS, TEXT, digest, estimateTokens, langOf, modelOf, parseNames, prompt, retry, styleOf, system } from './lib'
4
5export const register: Register = (on, options) => {
6 const lang = langOf(options.language)
7 const model = modelOf(options.model)
8 const style = styleOf(options.format)
9 const t = TEXT[lang]
10 // The session's current name, so suggestions differ from it: every name /rename is given.
11 let current: string | undefined
12
13 // The typeahead says the name is optional and what a bare run does.
14 on('command.describe', { command: 'rename' }, async ($, e, next) => {
15 const base = await next(e)
16 return { ...base, description: t.description, argumentHint: t.hint }
17 })
18
19 const call = (text: string): ModelCompleteRequest => ({
20 model,
21 system: system(lang, style),
22 prompt: text,
23 maxTokens: 400,
24 effort: 'low',
25 timeoutMs: 90_000,
26 })
27
28 // /rename <name>: the built-in, untouched. Bare /rename: suggest and pick, then run the
29 // built-in /rename <pick> as a command of its own; the event is never rewritten.
30 on('command.run', { command: 'rename' }, async ($, e, next) => {
31 const given = e.args.trim()
32 if (given) {
33 current = given
34 return next(e)
35 }
36
37 const messages = await $.session.messages()
38 const session = digest(messages)
39 if (!session) return { text: t.empty }
40
41 const request = prompt(session, current)
42 const tokens = estimateTokens(request.length)
43 if (tokens > LIMITS.warnTokens) {
44 const go = await $.ui
45 .ask(t.warn.replace('{tokens}', tokens.toLocaleString('en-US')).replace('{model}', model), { options: [t.go, t.stop] })
46 .catch(() => t.stop)
47 if (go !== t.go) return { text: t.warnCancelled }
48 }
49
50 let names: string[]
51 $.ui.status(t.status)
52 try {
53 const reply = await $.model.complete(call(request))
54 if (!reply.isAnswered) return { text: `${t.failed} (${reply.reason}).` }
55 names = parseNames(reply.text, { current, style })
56 if (names.length < 2) {
57 const again = await $.model.complete(call(`${request}\n\n${retry(style)}`))
58 if (again.isAnswered) names = parseNames(again.text, { current, style })
59 }
60 } finally {
61 $.ui.status(undefined)
62 }
63 if (names.length < 2) return { text: `${t.unparsed}.` }
64
65 let choice: string
66 try {
67 choice = (await $.ui.ask(t.question, { header: t.header, options: names })).trim()
68 } catch (err: unknown) {
69 return { text: `${t.cancelled} (${err instanceof Error ? err.message : String(err)})` }
70 }
71 if (!choice) return { text: t.blank }
72
73 current = choice
74 // After this run ends, so the built-in runs once the session is idle, as if typed.
75 $.clock.after(0, () => {
76 $.command.run({ command: 'rename', args: choice }).catch((err: unknown) => $.ui.log(`better-rename: ${String(err)}`))
77 })
78 return {}
79 }).catch(($, e, next) => next(e)) // on any failure of ours, the built-in /rename runs
80}
81hooks/lib.ts 277 lines1import type { SessionMessage } from 'claude-code'
2
3export type Lang = 'en' | 'tr'
4
5// How the session is condensed for the model (sized for Haiku's 200k window).
6export const LIMITS = {
7 // Last N prompts are sent with their replies.
8 recentTurns: 8,
9 // An earlier prompt: its head and tail survive, a pasted file in between is cut.
10 earlierHead: 450,
11 earlierTail: 150,
12 // A recent prompt and the replies to it.
13 recentPromptHead: 2_400,
14 recentPromptTail: 600,
15 replyHead: 1_200,
16 replyTail: 800,
17 // Above this estimate the person is asked before the call is made.
18 warnTokens: 30_000,
19 // Hard ceiling of the whole digest.
20 maxChars: 400_000,
21}
22
23// A compaction summary stands in for the history before it: it is sent whole.
24const SUMMARY = /^\s*(this session is being continued from a previous conversation|<summary>)/i
25
26// Engine wrappers that are not the person's own words.
27const NOISE = /<(system-reminder|local-command-stdout|local-command-caveat|task-notification)>[\s\S]*?<\/\1>/g
28
29export function clean(text: string): string {
30 return text
31 .replace(NOISE, ' ')
32 .replace(/<command-name>([\s\S]*?)<\/command-name>[\s\S]*?<command-args>([\s\S]*?)<\/command-args>/g, '$1 $2')
33 .replace(/<command-(message|name|args)>[\s\S]*?<\/command-\1>/g, ' ')
34 .replace(/\s+/g, ' ')
35 .trim()
36}
37
38export function headTail(text: string, head: number, tail: number): string {
39 if (text.length <= head + tail + 20) return text
40 const cut = text.length - head - tail
41 return `${text.slice(0, head)} [... ${cut} characters cut ...] ${text.slice(-tail)}`
42}
43
44type Turn = { prompt: string; replies: string[]; tools: string[] }
45
46// Groups the transcript into turns: a prompt the person typed, and what came back until the next one.
47export function turns(messages: readonly SessionMessage[]): { summaries: string[]; turns: Turn[] } {
48 const summaries: string[] = []
49 const out: Turn[] = []
50 for (const m of messages) {
51 if (m.role === 'user') {
52 if (SUMMARY.test(m.text)) {
53 summaries.push(m.text.trim())
54 continue
55 }
56 const text = clean(m.text)
57 if (text) out.push({ prompt: text, replies: [], tools: [] })
58 continue
59 }
60 const last = out.at(-1)
61 if (!last) continue
62 const text = clean(m.text)
63 if (text) last.replies.push(text)
64 for (const use of m.toolUses) if (!last.tools.includes(use.tool)) last.tools.push(use.tool)
65 }
66 return { summaries, turns: out }
67}
68
69// The digest: every compaction summary whole, every earlier prompt in short,
70// the last few prompts with their replies.
71export function digest(messages: readonly SessionMessage[], limits = LIMITS): string {
72 const { summaries, turns: all } = turns(messages)
73 const recentFrom = Math.max(0, all.length - limits.recentTurns)
74
75 const build = (scale: number) => {
76 const parts: string[] = []
77 if (summaries.length) parts.push(`<compaction-summary>\n${summaries.join('\n\n')}\n</compaction-summary>`)
78 const earlier = all.slice(0, recentFrom)
79 if (earlier.length) {
80 const head = Math.max(60, Math.floor(limits.earlierHead * scale))
81 const tail = Math.floor(limits.earlierTail * scale)
82 parts.push(
83 `<earlier-prompts>\n${earlier.map((t, i) => `${i + 1}. ${headTail(t.prompt, head, tail)}`).join('\n')}\n</earlier-prompts>`,
84 )
85 }
86 const recent = all.slice(recentFrom)
87 if (recent.length) {
88 const lines = recent.map(t => {
89 const prompt = headTail(t.prompt, limits.recentPromptHead, limits.recentPromptTail)
90 const reply = headTail(t.replies.join(' '), limits.replyHead, limits.replyTail)
91 const tools = t.tools.length ? `\nTOOLS: ${t.tools.join(', ')}` : ''
92 return `USER: ${prompt}${reply ? `\nASSISTANT: ${reply}` : ''}${tools}`
93 })
94 parts.push(`<recent-turns>\n${lines.join('\n\n')}\n</recent-turns>`)
95 }
96 return parts.join('\n\n')
97 }
98
99 let scale = 1
100 let out = build(scale)
101 while (out.length > limits.maxChars && scale > 0.1) {
102 scale *= 0.7
103 out = build(scale)
104 }
105 return out.length > limits.maxChars ? out.slice(-limits.maxChars) : out
106}
107
108// A rough token estimate: about 3.5 characters per token across English and Turkish text.
109export const estimateTokens = (chars: number) => Math.ceil(chars / 3.5)
110
111export type Style = 'title' | 'kebab'
112
113const FORMAT_RULE: Record<Style, Record<Lang, string>> = {
114 title: {
115 en: '- English, written as a short title in sentence case: words separated by spaces, first word and proper names capitalized, no trailing period.',
116 tr: '- Turkish, written as a short title in sentence case: words separated by spaces, first word and proper names capitalized, Turkish letters kept (ı, ş, ğ, ü, ö, ç), no trailing period.',
117 },
118 kebab: {
119 en: '- English, lowercase, words joined by hyphens (kebab-case), ASCII only.',
120 tr: '- Turkish, lowercase, words joined by hyphens (kebab-case), ASCII only: write Turkish letters plainly (ı->i, ş->s, ğ->g, ü->u, ö->o, ç->c).',
121 },
122}
123
124const EXAMPLE: Record<Style, { good: string; bad: string; reply: string }> = {
125 title: {
126 good: 'Fix webpack build error',
127 bad: 'Fix a build problem',
128 reply: '{"names": ["First name", "Second name", "Third name", "Fourth name"]}',
129 },
130 kebab: {
131 good: 'fix-webpack-build-error',
132 bad: 'fix-a-build-problem',
133 reply: '{"names": ["name-one", "name-two", "name-three", "name-four"]}',
134 },
135}
136
137export function system(lang: Lang, style: Style = 'title'): string {
138 const ex = EXAMPLE[style]
139 return `You suggest names for a Claude Code session, so its owner can find it again later.
140The input holds the whole session: a compaction summary if the session was compacted, every earlier prompt of the user in short, and the last turns in full.
141Rules:
142- Name what the WHOLE session was about, not only its last turns. If it spans several topics, cover the main ones.
143${FORMAT_RULE[style][lang]}
144- Each name 2-7 words, at most 60 characters, searchable and specific.
145- Keep technical terms, tool, product, project and file names (e.g. "${ex.good}", not "${ex.bad}").
146- No generic names ("New session", "Untitled", "Chat", "Misc", "General questions").
147- Never put personal data or secrets in a name (emails, tokens, passwords, keys).
148- If a current name is given, every suggestion must differ from it.
149- Give 4 distinct suggestions: one covering the whole session, the others with different emphasis.
150- Reply with exactly one JSON object and nothing else: ${ex.reply}`
151}
152
153export function prompt(session: string, current?: string): string {
154 const name = current ? `<current-name>${current}</current-name>\n\n` : ''
155 return `${name}<session>\n${session}\n</session>\n\nReply with the JSON object only.`
156}
157
158export const retry = (style: Style = 'title') =>
159 `Your previous reply could not be parsed. Reply with ONLY {"names": [...]} holding 4 ${style === 'kebab' ? 'kebab-case names' : 'short titles'}.`
160
161type Key =
162 | 'empty' | 'status' | 'failed' | 'unparsed' | 'question' | 'header' | 'cancelled' | 'blank'
163 | 'description' | 'hint' | 'warn' | 'go' | 'stop' | 'warnCancelled'
164
165export const TEXT: Record<Lang, Record<Key, string>> = {
166 en: {
167 empty: 'The session has no messages yet, nothing to name.',
168 status: 'rename: preparing suggestions...',
169 failed: 'Could not get name suggestions',
170 unparsed: 'The model did not return usable names',
171 question: 'Which name for this session?',
172 header: 'Name',
173 cancelled: 'Rename cancelled.',
174 blank: 'Empty name, nothing changed.',
175 description: 'Run it bare: suggests 4 names from the whole session. Or give a name to rename directly',
176 hint: '[name] optional',
177 warn: 'Suggesting names will send about {tokens} tokens to {model}. Continue?',
178 go: 'Continue',
179 stop: 'Cancel',
180 warnCancelled: 'Cancelled at the token warning, nothing was sent.',
181 },
182 tr: {
183 empty: 'Oturumda henüz mesaj yok, isim önerilemiyor.',
184 status: 'rename: öneriler hazırlanıyor...',
185 failed: 'İsim önerisi alınamadı',
186 unparsed: 'Model kullanılabilir isim döndürmedi',
187 question: 'Bu oturum için hangi isim?',
188 header: 'İsim',
189 cancelled: 'İsim seçimi iptal edildi.',
190 blank: 'Boş isim, değişiklik yapılmadı.',
191 description: 'Boş çalıştır: oturumun tamamına bakıp 4 isim önerir. İsim verirsen doğrudan o isimle adlandırır',
192 hint: '[isim] opsiyonel',
193 warn: 'İsim önerisi için yaklaşık {tokens} token {model} modeline gönderilecek. Devam edilsin mi?',
194 go: 'Devam',
195 stop: 'İptal',
196 warnCancelled: 'Token uyarısında iptal edildi, hiçbir şey gönderilmedi.',
197 },
198}
199
200export const langOf = (value: unknown): Lang => (value === 'tr' ? 'tr' : 'en')
201export const styleOf = (value: unknown): Style => (value === 'kebab-case' ? 'kebab' : 'title')
202export const modelOf = (value: unknown): 'haiku' | 'sonnet' => (value === 'sonnet' ? 'sonnet' : 'haiku')
203
204const NAME = /^[a-z0-9]+(?:-[a-z0-9]+){1,9}$/
205const GENERIC = new Set(['new-session', 'new-thread', 'untitled', 'untitled-session', 'no-title', 'general-chat', 'misc', 'chat-session'])
206
207// The names in a model reply: the JSON object (or a bare array), else only lines that
208// already look like names (kebab-case, or a listed or quoted title), so a prose sentence
209// never becomes an option. Generic names and the current name are compared by their
210// kebab form, so case and punctuation never make a repeat look new.
211export function parseNames(reply: string, options: { current?: string; max?: number; style?: Style } = {}): string[] {
212 const max = options.max ?? 4
213 const style = options.style ?? 'title'
214 let raw: string[] = []
215 const json = reply.match(/\{[^{}]*\}/)?.[0] ?? reply.match(/\[[^[\]]*\]/)?.[0]
216 if (json) {
217 try {
218 const parsed: unknown = JSON.parse(json)
219 const list: unknown = Array.isArray(parsed) ? parsed : (parsed as { names?: unknown }).names
220 if (Array.isArray(list)) raw = list.filter((x): x is string => typeof x === 'string')
221 } catch {
222 raw = []
223 }
224 }
225 if (raw.length === 0) {
226 raw = reply
227 .split('\n')
228 .map(line => line.trim())
229 .flatMap(line => {
230 const listed = /^(?:[-*]\s+|\d+[.)]\s+)/.test(line) || /^["'`]/.test(line)
231 const bare = line.replace(/^(?:[-*]\s+|\d+[.)]\s+)/, '').replace(/^["'`]+|["'`,]+$/g, '')
232 if (/^[A-Za-z0-9]+(?:-[A-Za-z0-9]+)+$/.test(bare)) return [bare]
233 return style === 'title' && listed && !/[:?!]$/.test(bare) ? [bare] : []
234 })
235 }
236 const current = options.current ? normalize(options.current) : undefined
237 const seen = new Set<string>()
238 const names: string[] = []
239 for (const r of raw) {
240 const key = normalize(r)
241 if (!NAME.test(key) || GENERIC.has(key) || key === current || seen.has(key)) continue
242 const name = style === 'kebab' ? key : title(r)
243 if (!name) continue
244 seen.add(key)
245 names.push(name)
246 }
247 return names.slice(0, max)
248}
249
250// A title as shown: one line, single spaces, no wrapping quotes or trailing period, at
251// most 60 characters cut at a word boundary.
252export function title(name: string): string {
253 let t = name
254 .replace(/[\u0000-\u001f\u007f]/g, ' ')
255 .replace(/\s+/g, ' ')
256 .trim()
257 .replace(/^["'`]+|["'`]+$/g, '')
258 .replace(/[.。]+$/, '')
259 .trim()
260 if (t.length > 60) t = t.slice(0, 61).replace(/\s+\S*$/, '').trim()
261 return t
262}
263
264const TR: Record<string, string> = { ı: 'i', İ: 'i', ş: 's', Ş: 's', ğ: 'g', Ğ: 'g', ü: 'u', Ü: 'u', ö: 'o', Ö: 'o', ç: 'c', Ç: 'c' }
265
266export function normalize(name: string): string {
267 return name
268 .replace(/[ıİşŞğĞüÜöÖçÇ]/g, c => TR[c] ?? c)
269 .normalize('NFKD')
270 .replace(/[̀-ͯ]/g, '')
271 .toLowerCase()
272 .replace(/[^a-z0-9]+/g, '-')
273 .replace(/^-+|-+$/g, '')
274 .slice(0, 60)
275 .replace(/-+$/, '')
276}
277