SLOPSHOPPER

terminal-tab-title

Names the terminal tab Claude Code runs in from your first prompt: tmux, zellij, screen, herdr, WezTerm, kitty, Konsole, ConEmu, plus OSC titles for iTerm2…

newcommandpromptmodelprocess
v0.2.0MITupdated 2026-10-06mtami/terminal-tab-title
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · terminal-tab-title
› fix the failing auth test and add an audit log call ● terminal-tab-title: Could not rename the tab: Claude Code already sets this terminal’s title; set CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1 to let this plugin name it instead. ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /tab-title ⎿ terminal-tab-title: Claude Code already sets this terminal’s title; set CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1 to let this plugin na ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

terminal-tab-title

Names the terminal tab Claude Code runs in. On your first prompt of a session, a quick Haiku call turns it into a 2–4 word title such as CSV Export Button, and the plugin renames the tab Claude is running in. It renames only that tab, never whichever tab happens to be focused.

/tab-title <name> sets the name yourself. /tab-title with no argument shows which method it would use in the current terminal.

Supported terminals

The plugin checks multiplexers first, because they own the tab you see and the outer terminal's variables leak into them.

Where Claude runsHow the tab is renamedNeeds
tmux (also byobu)tmux rename-window -t $TMUX_PANE (also turns off automatic-rename for that window)–
zellij ≥ 0.44zellij action list-panes --json → rename-tab --tab-idOlder zellij can only rename the focused tab, so the plugin skips it
GNU screenscreen -S $STY -p $WINDOW -X title–
herdrherdr tab rename $HERDR_TAB_ID–
WezTermwezterm cli set-tab-title --pane-id $WEZTERM_PANEUnder WSL, forward WEZTERM_PANE through WSLENV
kittykitten @ --to $KITTY_LISTEN_ON set-tab-title --match window_id:…allow_remote_control socket-only and listen_on unix:@kitty in kitty.conf
KonsoleD-Bus setTabTitleFormat (qdbus6/qdbus)–
ConEmu / cmderConEmuC -GuiMacro Rename 0 …–
iTerm2, Terminal.app, Windows Terminal, Ghostty, VS Code, GNOME Terminal, Warp, Alacritty, …OSC title escape sequence (OSC 1 on macOS terminals, OSC 30 on Konsole, OSC 0 elsewhere)CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1 (see below)

About OSC terminals

Claude Code already writes its own auto-generated topic title into the terminal title. For terminals that show that title on the tab, you get a tab title without this plugin. The plugin's OSC write would be overwritten anyway, so the plugin leaves those terminals alone by default. To have this plugin name the tab instead, turn Claude Code's title off in ~/.claude/settings.json:

{ "env": { "CLAUDE_CODE_DISABLE_TERMINAL_TITLE": "1" } }

Some terminals need their own setting before they show application titles: Windows Terminal profiles need suppressApplicationTitle left off, and VS Code needs ${sequence} in terminal.integrated.tabs.title. The OSC write needs sh and ps, so on native Windows (outside WSL) only the command-based terminals above work.

Install

/plugin install terminal-tab-title --marketplace mtami/terminal-tab-title

Or from your shell:

claude plugin marketplace add mtami/terminal-tab-title
claude plugin install terminal-tab-title@terminal-tab-title

What has been verified

On Linux (WSL2, Ubuntu): tmux 3.6, screen 4.09, herdr 0.8, kitty 0.48 (remote control over a socket), zellij 0.45 (list-panes + rename-tab --tab-id), and the OSC path, each against the real program. tmux and the OSC path were also checked end to end in a live Claude Code session. WezTerm, Konsole, ConEmu, macOS and native Windows are implemented from each tool's documentation and covered by unit tests, but haven't been run against the real terminal yet. Reports are welcome.

License

MIT

Source 3 files
hooks/register.ts 132 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PromptOrigin, Register } from 'claude-code'
3
4import { OSC_WRITER, cleanLabel, plan, zellijTabId } from './backends'
5import type { Attempt, Env } from './backends'
6
7const isNamed = atom({ plugin: 'terminal-tab-title', key: 'isNamed' } as const, false)
8
9const SYSTEM =
10  'You name terminal tabs. Reply with a 2-4 word title (Title Case, no quotes, ' +
11  'no trailing punctuation) summarising the task in the user message. Reply with the title only.'
12
13const TIMEOUT = { timeoutMs: 5000 }
14
15// The engine stamps every session prompt; an unstamped one (the test kit) counts as typed.
16function isFromPerson(origin: PromptOrigin | undefined): boolean {
17  return (
18    origin === undefined ||
19    origin.kind === 'composer' ||
20    origin.kind === 'bridge' ||
21    (origin.kind === 'plugin' && 'asUser' in origin && origin.asUser === true)
22  )
23}
24
25async function readEnv($: EngineInterface): Promise<Env> {
26  const [
27    HERDR_TAB_ID, HERDR_BIN_PATH, TMUX, TMUX_PANE, ZELLIJ, ZELLIJ_PANE_ID, STY, WINDOW,
28    WEZTERM_PANE, KITTY_WINDOW_ID, KITTY_LISTEN_ON, KONSOLE_DBUS_SERVICE, KONSOLE_DBUS_SESSION,
29    ConEmuPID, TERM_PROGRAM, TERM, WT_SESSION, WSL_DISTRO_NAME, CLAUDE_CODE_DISABLE_TERMINAL_TITLE,
30  ] = await Promise.all([
31    $.env.get('HERDR_TAB_ID'), $.env.get('HERDR_BIN_PATH'), $.env.get('TMUX'), $.env.get('TMUX_PANE'),
32    $.env.get('ZELLIJ'), $.env.get('ZELLIJ_PANE_ID'), $.env.get('STY'), $.env.get('WINDOW'),
33    $.env.get('WEZTERM_PANE'), $.env.get('KITTY_WINDOW_ID'), $.env.get('KITTY_LISTEN_ON'),
34    $.env.get('KONSOLE_DBUS_SERVICE'), $.env.get('KONSOLE_DBUS_SESSION'), $.env.get('ConEmuPID'),
35    $.env.get('TERM_PROGRAM'), $.env.get('TERM'), $.env.get('WT_SESSION'), $.env.get('WSL_DISTRO_NAME'),
36    $.env.get('CLAUDE_CODE_DISABLE_TERMINAL_TITLE'),
37  ])
38
39  return {
40    HERDR_TAB_ID, HERDR_BIN_PATH, TMUX, TMUX_PANE, ZELLIJ, ZELLIJ_PANE_ID, STY, WINDOW,
41    WEZTERM_PANE, KITTY_WINDOW_ID, KITTY_LISTEN_ON, KONSOLE_DBUS_SERVICE, KONSOLE_DBUS_SESSION,
42    ConEmuPID, TERM_PROGRAM, TERM, WT_SESSION, WSL_DISTRO_NAME, CLAUDE_CODE_DISABLE_TERMINAL_TITLE,
43  }
44}
45
46async function tryAttempt($: EngineInterface, attempt: Attempt, label: string): Promise<boolean> {
47  if ('run' in attempt) {
48    const ran = await $.process.run(attempt.run, TIMEOUT)
49
50    return ran.exitCode === 0
51  }
52  if ('zellijPane' in attempt) {
53    const listed = await $.process.run(['zellij', 'action', 'list-panes', '--json', '--tab'], TIMEOUT)
54    // Before 0.44 there is no list-panes and rename-tab only hits the focused tab: leave it alone.
55    const tabId = listed.exitCode === 0 ? zellijTabId(listed.stdout, attempt.zellijPane) : undefined
56    if (tabId === undefined) return false
57    const ran = await $.process.run(['zellij', 'action', 'rename-tab', '--tab-id', String(tabId), label], TIMEOUT)
58
59    return ran.exitCode === 0
60  }
61  const ran = await $.process.run(['sh', '-c', OSC_WRITER, 'terminal-tab-title', attempt.osc], TIMEOUT)
62
63  return ran.exitCode === 0
64}
65
66// Resolves the backend that renamed the tab, or why none did.
67async function renameTab($: EngineInterface, label: string): Promise<{ backend?: string; reason?: string }> {
68  const { attempts, note } = plan(await readEnv($), label)
69  const tried: string[] = []
70  for (const attempt of attempts) {
71    const isDone = await tryAttempt($, attempt, label).catch(() => false)
72    if (isDone) return { backend: attempt.backend }
73    tried.push(attempt.backend)
74  }
75
76  return { reason: note ?? (tried.length > 0 ? `${[...new Set(tried)].join(', ')} refused it` : 'no supported terminal found') }
77}
78
79async function nameFromPrompt($: EngineInterface, text: string): Promise<void> {
80  const reply = await $.model.complete({ model: 'haiku', system: SYSTEM, prompt: text.slice(0, 4000), maxTokens: 30 })
81  const label = reply.isAnswered ? cleanLabel(reply.text) : ''
82  if (!label) {
83    await update($, isNamed, () => false)
84    return
85  }
86
87  const result = await renameTab($, label)
88  if (!result.backend) {
89    // Nothing here to rename; stay quiet and don't ask the model again this session.
90    $.ui.log(`Could not rename the tab: ${result.reason}.`)
91  }
92}
93
94export const register: Register = on => {
95  on('session.start', async ($, e, next) => {
96    await $.command.register({
97      name: 'tab-title',
98      description: 'Rename this terminal tab (/tab-title <name>), or show how it would be renamed',
99    })
100
101    return next(e)
102  })
103
104  on('command.run', { command: 'tab-title' }, async ($, e) => {
105    const label = cleanLabel(e.args)
106    if (!label) {
107      const { attempts, note } = plan(await readEnv($), 'Example')
108      const backends = [...new Set(attempts.map(one => one.backend))]
109
110      return { text: backends.length > 0 ? `Would rename via ${backends.join(' → ')}.` : `${note ?? 'No supported terminal found'}.` }
111    }
112
113    await update($, isNamed, () => true)
114    const result = await renameTab($, label)
115
116    return { text: result.backend ? `Tab renamed to “${label}” via ${result.backend}.` : `Could not rename the tab: ${result.reason}.` }
117  })
118
119  on('prompt.submit', async ($, e, next) => {
120    const result = await next(e)
121
122    const text = e.text.trim()
123    const isCandidate = isFromPerson(e.origin) && text !== '' && !text.startsWith('/')
124    if (!isCandidate || (await read($, isNamed))) return result
125
126    await update($, isNamed, () => true)
127    nameFromPrompt($, text).catch(() => update($, isNamed, () => false))
128
129    return result
130  }).catch(($, e, next) => next(e))
131}
132
hooks/backends.ts 148 lines
1// Works out how to rename the tab this process runs in, from its environment alone.
2// Pure: no `$`, so every terminal's detection is covered by plain tests.
3
4export type Env = {
5  HERDR_TAB_ID?: string
6  HERDR_BIN_PATH?: string
7  TMUX?: string
8  TMUX_PANE?: string
9  ZELLIJ?: string
10  ZELLIJ_PANE_ID?: string
11  STY?: string
12  WINDOW?: string
13  WEZTERM_PANE?: string
14  KITTY_WINDOW_ID?: string
15  KITTY_LISTEN_ON?: string
16  KONSOLE_DBUS_SERVICE?: string
17  KONSOLE_DBUS_SESSION?: string
18  ConEmuPID?: string
19  TERM_PROGRAM?: string
20  TERM?: string
21  WT_SESSION?: string
22  WSL_DISTRO_NAME?: string
23  CLAUDE_CODE_DISABLE_TERMINAL_TITLE?: string
24}
25
26// One way to rename the tab; the runner tries a plan's attempts in order until one succeeds.
27export type Attempt =
28  | { backend: string; run: string[] }
29  | { backend: 'zellij'; zellijPane: number }
30  | { backend: string; osc: string }
31
32export type Plan = { attempts: Attempt[]; note?: string }
33
34const ESC = '\u001b'
35const BEL = '\u0007'
36
37export function cleanLabel(raw: string): string {
38  return (raw.split('\n')[0] ?? '')
39    .replace(/[\u0000-\u001f\u007f]/g, '')
40    .replace(/["'`*_#%]/g, '')
41    .replace(/[.:;!?]+$/, '')
42    .trim()
43    .slice(0, 32)
44    .trim()
45}
46
47export function oscSequence(code: 0 | 1 | 2 | 30, label: string): string {
48  return `${ESC}]${code};${label}${BEL}`
49}
50
51// OSC 1 is the tab (not window) title on macOS terminals; Konsole's tab title is OSC 30.
52function oscCodeFor(env: Env): 0 | 1 | 30 {
53  if (env.TERM_PROGRAM === 'iTerm.app' || env.TERM_PROGRAM === 'Apple_Terminal') return 1
54  if (env.KONSOLE_DBUS_SESSION) return 30
55  return 0
56}
57
58function oscName(env: Env): string {
59  if (env.WT_SESSION) return 'windows-terminal'
60  if (env.TERM_PROGRAM) return env.TERM_PROGRAM.toLowerCase()
61  return 'osc'
62}
63
64function isOn(value: string | undefined): boolean {
65  return value !== undefined && value !== '' && value !== '0' && value.toLowerCase() !== 'false'
66}
67
68export function plan(env: Env, label: string): Plan {
69  // Multiplexers first: they own the tab the person sees, and the outer terminal's variables leak into them.
70  if (env.HERDR_TAB_ID) {
71    return { attempts: [{ backend: 'herdr', run: [env.HERDR_BIN_PATH || 'herdr', 'tab', 'rename', env.HERDR_TAB_ID, label] }] }
72  }
73  if (env.TMUX && env.TMUX_PANE) {
74    // rename-window also turns automatic-rename off for that window, so the name sticks.
75    return { attempts: [{ backend: 'tmux', run: ['tmux', 'rename-window', '-t', env.TMUX_PANE, label] }] }
76  }
77  if (env.ZELLIJ !== undefined && env.ZELLIJ_PANE_ID && /^\d+$/.test(env.ZELLIJ_PANE_ID)) {
78    return { attempts: [{ backend: 'zellij', zellijPane: Number(env.ZELLIJ_PANE_ID) }] }
79  }
80  if (env.STY) {
81    return { attempts: [{ backend: 'screen', run: ['screen', '-S', env.STY, '-p', env.WINDOW || '0', '-X', 'title', label] }] }
82  }
83
84  const attempts: Attempt[] = []
85  if (env.WEZTERM_PANE) {
86    const wezterm = env.WSL_DISTRO_NAME ? 'wezterm.exe' : 'wezterm'
87    attempts.push({ backend: 'wezterm', run: [wezterm, 'cli', 'set-tab-title', '--pane-id', env.WEZTERM_PANE, label] })
88  }
89  if (env.KITTY_WINDOW_ID && env.KITTY_LISTEN_ON) {
90    const match = `window_id:${env.KITTY_WINDOW_ID}`
91    attempts.push({ backend: 'kitty', run: ['kitten', '@', '--to', env.KITTY_LISTEN_ON, 'set-tab-title', '--match', match, label] })
92    attempts.push({ backend: 'kitty', run: ['kitty', '@', '--to', env.KITTY_LISTEN_ON, 'set-tab-title', '--match', match, label] })
93  }
94  if (env.KONSOLE_DBUS_SERVICE && env.KONSOLE_DBUS_SESSION) {
95    for (const qdbus of ['qdbus6', 'qdbus']) {
96      const target = [qdbus, env.KONSOLE_DBUS_SERVICE, env.KONSOLE_DBUS_SESSION, 'org.kde.konsole.Session.setTabTitleFormat']
97      attempts.push({ backend: 'konsole', run: [...target, '0', label] })
98    }
99  }
100  if (env.ConEmuPID) {
101    attempts.push({ backend: 'conemu', run: ['ConEmuC', '-GuiMacro', 'Rename', '0', label] })
102  }
103
104  // An OSC title is what Claude Code itself writes, so it only lasts once Claude Code stops writing it.
105  if (isOn(env.CLAUDE_CODE_DISABLE_TERMINAL_TITLE)) {
106    attempts.push({ backend: oscName(env), osc: oscSequence(oscCodeFor(env), label) })
107    return { attempts }
108  }
109
110  return {
111    attempts,
112    note:
113      attempts.length > 0
114        ? undefined
115        : 'Claude Code already sets this terminal’s title; set CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1 to let this plugin name it instead',
116  }
117}
118
119type ZellijPane = { id?: unknown; is_plugin?: unknown; tab_id?: unknown }
120
121// `zellij action list-panes --json --tab` (0.44+): plugin and terminal panes share ids, so skip plugins.
122export function zellijTabId(listJson: string, paneId: number): number | undefined {
123  let panes: unknown
124  try {
125    panes = JSON.parse(listJson)
126  } catch {
127    return undefined
128  }
129  if (!Array.isArray(panes)) return undefined
130  const pane = (panes as ZellijPane[]).find(one => one.id === paneId && one.is_plugin === false)
131
132  return typeof pane?.tab_id === 'number' ? pane.tab_id : undefined
133}
134
135// Children of Claude Code have no controlling tty, so walk up to the first ancestor that has one
136// (Claude Code itself) and write the sequence to that device. POSIX sh + ps: Linux, macOS, WSL.
137export const OSC_WRITER = [
138  'p=$PPID',
139  'while [ -n "$p" ] && [ "$p" -gt 1 ]; do',
140  '  t=$(ps -o tty= -p "$p" 2>/dev/null | tr -d " ")',
141  '  case "$t" in',
142  '    ""|"?"|"??") p=$(ps -o ppid= -p "$p" 2>/dev/null | tr -d " ") ;;',
143  '    *) printf "%s" "$1" > "/dev/$t"; exit $? ;;',
144  '  esac',
145  'done',
146  'exit 3',
147].join('\n')
148
types/index.d.ts 8 lines
1export type TabTitleBackend = string
2
3declare module 'claude-code' {
4  interface PluginState {
5    'terminal-tab-title': { isNamed: boolean }
6  }
7}
8