Names the iTerm2 tab with one or two uppercase words for what the session is about, and gives each session's tab a random colour.

A Claude Code mod that gives every session's iTerm2 tab its own colour and a one- or two-word name, so you can tell five open sessions apart without clicking through them.

| When | What happens to the tab |
|---|---|
| A session starts | It gets a random colour from a palette of twelve, and the name of the folder you started in |
| You send the first prompt | It is renamed to one or two uppercase words for what the prompt is about, for example CHECKOUT TESTS |
| The first prompt is a slash command | The command's name becomes the tab name: /release-notes gives RELEASE NOTES |
You run /tab HOTFIX | The tab is named HOTFIX |
You run /tab with no words | The tab is named again from the last prompts of the conversation |
| You resume a session | It gets back the colour and the name it had |
You run /clear | The colour stays, and the next prompt names the tab again |
| The session ends | The tab goes back to iTerm2's default colour and title |
A tab is named once. Later prompts do not rename it, so the name does not change while you work. Use /tab when the subject changes.
claude plugin marketplace add JCPetrelli/tab-tag
claude plugin install tab-tag@tab-tag
Start a new session in iTerm2 and the tab is coloured.
To remove it:
claude plugin uninstall tab-tag@tab-tag
claude plugin marketplace remove tab-tag
The name comes from one small model call per session: the first 1,500 characters of your first prompt go to the model, and the reply is limited to 16 tokens. The default model is claude-haiku-4-5-20251001.
You can change the model with /plugin configure tab-tag@tab-tag, option Naming model. If you leave that option empty, the mod makes no model call and uses the first two telling words of the prompt (i want to find slow queries in the search page gives SLOW QUERIES).
The mod uses the same fallback when the model does not answer.
| File | Job |
|---|---|
hooks/register.ts | The hooks: colour and name at session.start, naming at prompt.submit, the /tab command, reset at session.end |
hooks/words.ts | The palette, and the rules that turn text into a title |
bin/tab.sh | Writes iTerm2's escape sequences for tab title and tab colour to the session's terminal |
tests/tab.test.ts | Tests for the title rules and for a full session |
Two details:
Colour and name are saved per session ID in the mod's own store. The mod keeps the last 300 sessions.
git clone https://github.com/JCPetrelli/tab-tag
cd tab-tag
claude plugin validate . # manifest and hooks, as the engine reads them
claude plugin test . # tests/*.test.ts
To try your copy in a session, add the folder as a marketplace (claude plugin marketplace add ./tab-tag) and install from it. After an edit, run /reload-plugins in the session.
The demo is a three.js scene in one file, docs/animation/index.html. Open the file in a browser to see it loop. To render the video and the GIF again (needs Google Chrome and ffmpeg):
cd docs/animation
npm install
node render.mjs
hooks/register.ts 163 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { TabColor } from '../types'
5import { lastSegment, pickColor, titleFromCommand, titleFromWords, toTitle } from './words'
6
7const title = atom({ plugin: 'tab-tag', key: 'title' } as const, '')
8const color = atom({ plugin: 'tab-tag', key: 'color' } as const, null)
9const isNamed = atom({ plugin: 'tab-tag', key: 'isNamed' } as const, false)
10
11const SYSTEM =
12 'You label terminal tabs. Reply with ONE or TWO words, uppercase, naming the project or topic of the request. No punctuation, no explanation.'
13const MAX_SAVED = 300
14
15type Saved = { title: string; color: TabColor; isNamed: boolean }
16
17async function tab($: EngineInterface, args: readonly string[]) {
18 const script = `${$.plugin.root}/bin/tab.sh`
19 await $.process.run(['sh', script, ...args], { timeoutMs: 5000 }).catch(() => undefined)
20}
21
22// Claude Code writes its own title as a turn starts and ends, so the tab's is
23// written again at those moments.
24async function paint($: EngineInterface) {
25 const text = await read($, title)
26
27 if (text !== '') {
28 await tab($, ['title', text])
29 }
30}
31
32async function save($: EngineInterface) {
33 const tint = await read($, color)
34
35 if (tint === null) {
36 return
37 }
38
39 const saved: Saved = {
40 title: await read($, title),
41 color: tint,
42 isNamed: await read($, isNamed),
43 }
44 await $.store.set(`tab:${await $.session.id()}`, saved)
45 const keys = (await $.store.keys()).filter(key => key.startsWith('tab:'))
46
47 for (const key of keys.slice(0, Math.max(0, keys.length - MAX_SAVED))) {
48 await $.store.delete(key)
49 }
50}
51
52async function rename($: EngineInterface, text: string) {
53 if (text === '') {
54 return
55 }
56
57 await update($, title, () => text)
58 await update($, isNamed, () => true)
59 await paint($)
60 await save($)
61}
62
63async function summarize($: EngineInterface, prompt: string, model: string) {
64 let text = ''
65
66 if (model !== '') {
67 const reply = await $.model.complete({
68 model,
69 system: SYSTEM,
70 prompt: prompt.slice(0, 1500),
71 maxTokens: 16,
72 })
73 text = reply.isAnswered ? toTitle(reply.text) : ''
74 }
75
76 await rename($, text === '' ? titleFromWords(prompt) : text)
77}
78
79export const register: Register = (on, options) => {
80 const model = String(options.model ?? '')
81
82 on('session.start', async ($, e, next) => {
83 if (!e.isInteractive) {
84 return next(e)
85 }
86
87 await $.command.register({
88 name: 'tab',
89 description: 'Rename this iTerm2 tab: /tab TWO WORDS, or /tab alone to name it again from the conversation',
90 argumentHint: '[words]',
91 })
92 const found = (await $.store.get(`tab:${await $.session.id()}`)) as Saved | undefined
93 const tint = found?.color ?? pickColor(Math.random())
94 const text = found?.title ?? toTitle(lastSegment(e.cwd))
95 await update($, color, () => tint)
96 await update($, title, () => text)
97 await update($, isNamed, () => found?.isNamed ?? false)
98 await tab($, ['color', String(tint.red), String(tint.green), String(tint.blue)])
99 await paint($)
100
101 return next(e)
102 })
103
104 on('prompt.submit', async ($, e, next) => {
105 const entered = await next(e)
106 const isPerson = e.origin.kind === 'composer'
107
108 if (isPerson && !(await read($, isNamed))) {
109 const fromCommand = titleFromCommand(e.text)
110
111 if (fromCommand === null) {
112 $.clock.after(0, () => summarize($, e.text, model))
113 } else {
114 await rename($, fromCommand)
115 }
116 }
117
118 $.clock.after(1500, () => paint($))
119
120 return entered
121 }).catch(($, e, next) => next(e))
122
123 on('turn.complete', async ($, e, next) => {
124 const done = await next(e)
125
126 if (e.agentId === undefined) {
127 await paint($)
128 $.clock.after(1500, () => paint($))
129 }
130
131 return done
132 })
133
134 on('command.run', { command: 'tab' }, async ($, e) => {
135 const asked = toTitle(e.args)
136
137 if (asked !== '') {
138 await rename($, asked)
139
140 return { text: `Tab named ${asked}.` }
141 }
142
143 const prompts = (await $.session.messages())
144 .filter(message => message.role === 'user' && !message.text.startsWith('/'))
145 .slice(-6)
146 .map(message => message.text.slice(0, 300))
147 .join('\n')
148 await summarize($, prompts, model)
149
150 return { text: `Tab named ${await read($, title)}.` }
151 })
152
153 on('session.end', async ($, e, next) => {
154 if (e.reason === 'clear') {
155 await update($, isNamed, () => false)
156 } else {
157 await tab($, ['reset'])
158 }
159
160 return next(e)
161 })
162}
163hooks/words.ts 83 lines1import type { TabColor } from '../types'
2
3const MAX_WORDS = 2
4const MAX_LENGTH = 24
5
6// Commands that say nothing about what the session is for.
7const SILENT_COMMANDS = new Set([
8 'clear', 'compact', 'model', 'config', 'help', 'resume', 'tab', 'fast',
9 'loop', 'status', 'usage', 'context', 'cost', 'init', 'login', 'doctor',
10])
11
12const STOP_WORDS = new Set([
13 'THE', 'AND', 'FOR', 'WITH', 'THAT', 'THIS', 'FROM', 'INTO', 'HAVE', 'WANT',
14 'WOULD', 'COULD', 'SHOULD', 'PLEASE', 'MAKE', 'LETS', 'WHAT', 'WHEN', 'WHERE',
15 'ABOUT', 'SOME', 'YOUR', 'JUST', 'LIKE', 'NEED', 'CAN', 'YOU', 'HOW', 'ARE',
16 'USE', 'ALSO', 'THEN', 'THERE', 'WILL', 'ONLINE', 'FIND', 'CHECK', 'SEARCH',
17])
18
19export const PALETTE: readonly TabColor[] = [
20 { red: 231, green: 76, blue: 60 },
21 { red: 230, green: 126, blue: 34 },
22 { red: 241, green: 196, blue: 15 },
23 { red: 46, green: 204, blue: 113 },
24 { red: 26, green: 188, blue: 156 },
25 { red: 52, green: 152, blue: 219 },
26 { red: 91, green: 110, blue: 225 },
27 { red: 155, green: 89, blue: 182 },
28 { red: 232, green: 67, blue: 147 },
29 { red: 149, green: 165, blue: 166 },
30 { red: 0, green: 184, blue: 212 },
31 { red: 139, green: 195, blue: 74 },
32]
33
34export const pickColor = (random: number): TabColor =>
35 PALETTE[Math.floor(random * PALETTE.length) % PALETTE.length] ?? { red: 52, green: 152, blue: 219 }
36
37// One or two uppercase words of letters and digits: nothing that could carry
38// an escape sequence into the terminal.
39export const toTitle = (text: string): string => {
40 const words = text
41 .toUpperCase()
42 .replace(/[^A-Z0-9]+/g, ' ')
43 .trim()
44 .split(' ')
45 .filter(Boolean)
46 .slice(0, MAX_WORDS)
47 let title = words.join(' ')
48
49 if (title.length > MAX_LENGTH) {
50 title = (words[0] ?? '').slice(0, MAX_LENGTH)
51 }
52
53 return title
54}
55
56// The slash command a prompt runs, when it names what the session is for.
57export const titleFromCommand = (prompt: string): string | null => {
58 const found = /^\s*\/([\w:-]+)/.exec(prompt)
59
60 if (found === null) {
61 return null
62 }
63
64 const name = (found[1] ?? '').split(':').at(-1) ?? ''
65
66 return SILENT_COMMANDS.has(name) ? '' : toTitle(name)
67}
68
69// The fallback when no model answers: the prompt's first two telling words.
70export const titleFromWords = (prompt: string): string => {
71 const words = prompt
72 .toUpperCase()
73 .replace(/HTTPS?:\S+/g, ' ')
74 .replace(/[^A-Z0-9]+/g, ' ')
75 .split(' ')
76 .filter(word => word.length > 2 && !STOP_WORDS.has(word))
77
78 return toTitle(words.slice(0, MAX_WORDS).join(' '))
79}
80
81export const lastSegment = (path: string): string =>
82 path.split('/').filter(Boolean).at(-1) ?? path
83types/index.d.ts 8 lines1export type TabColor = { red: number; green: number; blue: number }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'tab-tag': { title: string; color: TabColor | null; isNamed: boolean }
6 }
7}
8