Titles each session from the latest user prompt: cut for the CLI, model-written for the sidebar.

A Claude Code plugin marketplace for mods that keep session titles meaningful.
<img src="images/demo.svg" width="760" alt="Each prompt first becomes the session title cut to 60 characters, then Haiku rewrites it as a short title that follows the latest prompt">
Retitles the session every time you send a prompt, so the sidebar tells you what each session is about right now, not what it was about when it started.
Only prompts you type count. Slash commands, empty prompts and machine-injected turns (task notifications, loop wakeups, SDK calls) leave the title alone.
/plugin marketplace add zhangbububu/session-titles
/plugin install last-prompt-title@session-titles
Then start a new session (or restart Claude Code) so it loads.
classic.UserPromptSubmit, $.model, $.mcp). Developed on 2.1.289; older builds won't load it.ccd_session_mgmt server. In a terminal session only the CLI title changes.effort: low, at most 64 output tokens).claude plugin validate plugins/last-prompt-title
claude plugin test plugins/last-prompt-title
hooks/register.ts 35 lines1import type { Register } from 'claude-code'
2import { EARLIER_PROMPTS, titleFrom, titleFromReply, titleRequest } from './title'
3
4export const register: Register = on => {
5 let latest = 0
6 const earlier: string[] = [] // the user's previous titled prompts, oldest first
7
8 on('classic.UserPromptSubmit', async ($, e, next) => {
9 const ran = await next(e)
10 if (e.source !== undefined && e.source !== 'user') return ran
11
12 const sessionTitle = titleFrom(e.prompt)
13 if (sessionTitle === undefined) return ran
14
15 const request = ++latest
16 const ask = titleRequest(e.prompt, earlier)
17 earlier.push(e.prompt)
18 if (earlier.length > EARLIER_PROMPTS) earlier.shift()
19
20 // The sidebar reads ccd_session_mgmt, not the CLI custom-title record.
21 // A terminal session has no such server; the CLI title still applies.
22 // Not awaited: the turn starts with the cut title, the model's lands after.
23 void $.model
24 .complete({ model: 'haiku', effort: 'low', maxTokens: 64, timeoutMs: 15000, ...ask })
25 .then(reply => {
26 if (request !== latest) return // a newer prompt's title is on its way
27 const title = (reply.isAnswered ? titleFromReply(reply.text) : undefined) ?? sessionTitle
28 return $.mcp.call('ccd_session_mgmt', 'set_session_title', { session_id: 'self', title })
29 })
30 .catch(() => undefined)
31
32 return { ...ran, sessionTitle }
33 })
34}
35hooks/title.ts 43 lines1const MAX_LENGTH = 60
2const MAX_PROMPT_CHARS = 1000
3const MAX_EARLIER_CHARS = 300
4
5/** How many of the user's previous prompts go with the latest one, to resolve what a follow-up is about. */
6export const EARLIER_PROMPTS = 3
7
8/** One line of at most MAX_LENGTH characters from a prompt, or undefined if it holds no title. */
9export function titleFrom(prompt: string): string | undefined {
10 const text = prompt.replace(/\s+/g, ' ').trim()
11 if (text === '' || text.startsWith('/')) return undefined
12 return text.length > MAX_LENGTH ? `${text.slice(0, MAX_LENGTH - 1).trimEnd()}…` : text
13}
14
15const SYSTEM =
16 'You name chat sessions from the user\'s latest <message>. Any <earlier> messages are the user\'s previous ' +
17 'messages in the same session: use them only to tell what a short follow-up ("explain", "解释", "继续", "why?") ' +
18 'refers to; title the latest message, not the earlier ones. ' +
19 'Name the concrete subject: the function, identifier, file, feature, error or task at hand, e.g. ' +
20 '"AgentManager.start 去重逻辑", "agent:start 启动流程", "Fix login redirect loop". ' +
21 'Never reply with a generic label that could title any session, such as "代码解释", "代码分析", "问题咨询", ' +
22 '"Code explanation", "Bug fix" or "Question". ' +
23 'Reply with the title only: at most 8 words, or about 20 characters in Chinese (code identifiers may stay whole), ' +
24 'in the language of the latest message, no quotes, no trailing punctuation.'
25
26/** The completion that asks a model to title a session from the user's latest prompt and the few before it. */
27export function titleRequest(prompt: string, earlier: readonly string[] = []): { system: string; prompt: string } {
28 const context = earlier
29 .slice(-EARLIER_PROMPTS)
30 .map(p => `<earlier>\n${p.replace(/\s+/g, ' ').trim().slice(0, MAX_EARLIER_CHARS)}\n</earlier>\n`)
31 .join('')
32 return {
33 system: SYSTEM,
34 prompt: `${context}<message>\n${prompt.slice(0, MAX_PROMPT_CHARS)}\n</message>`,
35 }
36}
37
38/** A model's reply as a title: its first line without wrapping quotes or trailing punctuation. */
39export function titleFromReply(reply: string): string | undefined {
40 const line = reply.trim().split('\n')[0] ?? ''
41 return titleFrom(line.replace(/^["'“‘「《\s]+|["'”’」》。.!!??\s]+$/g, ''))
42}
43