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…

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.
The plugin checks multiplexers first, because they own the tab you see and the outer terminal's variables leak into them.
| Where Claude runs | How the tab is renamed | Needs |
|---|---|---|
| tmux (also byobu) | tmux rename-window -t $TMUX_PANE (also turns off automatic-rename for that window) | – |
| zellij ≥ 0.44 | zellij action list-panes --json → rename-tab --tab-id | Older zellij can only rename the focused tab, so the plugin skips it |
| GNU screen | screen -S $STY -p $WINDOW -X title | – |
| herdr | herdr tab rename $HERDR_TAB_ID | – |
| WezTerm | wezterm cli set-tab-title --pane-id $WEZTERM_PANE | Under WSL, forward WEZTERM_PANE through WSLENV |
| kitty | kitten @ --to $KITTY_LISTEN_ON set-tab-title --match window_id:… | allow_remote_control socket-only and listen_on unix:@kitty in kitty.conf |
| Konsole | D-Bus setTabTitleFormat (qdbus6/qdbus) | – |
| ConEmu / cmder | ConEmuC -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) |
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.
/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
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.
MIT
hooks/register.ts 132 lines1import { 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}
132hooks/backends.ts 148 lines1// 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')
148types/index.d.ts 8 lines1export type TabTitleBackend = string
2
3declare module 'claude-code' {
4 interface PluginState {
5 'terminal-tab-title': { isNamed: boolean }
6 }
7}
8