SLOPSHOPPER

Better Rename

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

statusmodeltimer
A shopper browsing a rack in a slop shop
README

better-rename

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.
  • If anything in the mod fails, the built-in /rename runs instead.

Type /ren and Tab: there is only one /rename, and the menu says the name is optional.

Install

/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.

Examples

  1. Name a long session. After an hour of work, run /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.
  2. Name it yourself. Run /rename release-notes-draft. The mod steps aside and the built-in renames the session at once; no model call is made.
  3. Switch the format or language. Open /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ı.

Options

OptionValuesDefaultWhat it sets
languageen, trenLanguage of the suggested names and the mod's messages
modelhaiku, sonnethaikuModel that suggests the names
format (Rename format)Title, kebab-caseTitleHow 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.

What the model sees

The session is condensed before it is sent, so a long session stays cheap while its beginning is never dropped:

  1. Compaction summaries, whole. After /compact the earlier history lives in one summary; it is sent in full.
  2. Every earlier prompt you typed, in short. Prompts are usually short, so most go whole. A long one (a pasted file, a log) keeps its first 450 and last 150 characters.
  3. The last 8 turns in full: each prompt (up to 3,000 characters) with the assistant's reply (up to 2,000) and the names of the tools it used.

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.

Hooks this mod adds

The mod is one TypeScript hooks module (hooks/register.ts). It hooks two events and changes nothing else:

HookWhat it doesWhat it changes
command.describe for renameRewrites the description and argument hint /rename shows in the typeahead, so the menu says the name is optionalThe menu text of /rename only
command.run for renameWith 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 commandNothing in the original command; the built-in does the renaming

Commands it runs

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.

Cost and privacy

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.

What it reads

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.

Support and security

Development

claude plugin validate plugins/better-rename
claude plugin test plugins/better-rename
Source 2 files
hooks/register.ts 81 lines
1import 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}
81
hooks/lib.ts 277 lines
1import 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