SLOPSHOPPER

last-prompt-title

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

newmodel
A shopper browsing a rack in a slop shop
README

session-titles

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">

last-prompt-title

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.

  • CLI title: your prompt collapsed to one line and cut to 60 characters, set at once.
  • Desktop sidebar title: a short title Haiku writes from your latest prompt, with your previous three prompts as context so a bare follow-up like "explain" still gets a title naming the code or task it is about, set a few seconds later without holding up the turn. If the model gives no answer within 15 seconds, the sidebar gets the cut prompt instead.

Only prompts you type count. Slash commands, empty prompts and machine-injected turns (task notifications, loop wakeups, SDK calls) leave the title alone.

Install

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

Good to know

  • Needs a Claude Code build with function hooks (classic.UserPromptSubmit, $.model, $.mcp). Developed on 2.1.289; older builds won't load it.
  • The sidebar title is desktop-only. It's set through the desktop app's ccd_session_mgmt server. In a terminal session only the CLI title changes.
  • One extra Haiku call per prompt, billed to your own account the way the session's requests are. It's a small request (effort: low, at most 64 output tokens).
  • What the model sees: the first 1,000 characters of your latest prompt and nothing else. It goes to the same API the session already uses.
  • It overwrites titles you set by hand on your next prompt.

Develop

claude plugin validate plugins/last-prompt-title
claude plugin test plugins/last-prompt-title

License

MIT

Source 2 files
hooks/register.ts 35 lines
1import 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}
35
hooks/title.ts 43 lines
1const 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