SLOPSHOPPER

tab-tag

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.

newcommandpromptmodelprocesstimer
v0.1.0MITupdated 2026-10-08JCPetrelli/tab-tag
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tab-tag
› fix the failing auth test and add an audit log call ⏺ 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 ⎿ tab-tag: Tab named OK. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

tab-tag

license: MIT

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.

Five identical tabs get a colour each, then a name from the first prompt

What it does

WhenWhat happens to the tab
A session startsIt gets a random colour from a palette of twelve, and the name of the folder you started in
You send the first promptIt is renamed to one or two uppercase words for what the prompt is about, for example CHECKOUT TESTS
The first prompt is a slash commandThe command's name becomes the tab name: /release-notes gives RELEASE NOTES
You run /tab HOTFIXThe tab is named HOTFIX
You run /tab with no wordsThe tab is named again from the last prompts of the conversation
You resume a sessionIt gets back the colour and the name it had
You run /clearThe colour stays, and the next prompt names the tab again
The session endsThe 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.

Requirements

  • macOS with iTerm2. In any other terminal the mod does nothing.
  • A Claude Code version with mods (plugins of function hooks). Tested with 2.1.294.

Install

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 naming model

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.

How it works

FileJob
hooks/register.tsThe hooks: colour and name at session.start, naming at prompt.submit, the /tab command, reset at session.end
hooks/words.tsThe palette, and the rules that turn text into a title
bin/tab.shWrites iTerm2's escape sequences for tab title and tab colour to the session's terminal
tests/tab.test.tsTests for the title rules and for a full session

Two details:

  • Claude Code writes its own tab title when a turn starts and when it ends. The mod writes its title again at those moments, and once more 1.5 seconds later.
  • A title contains only the letters A to Z, the digits and spaces. A prompt or a model reply cannot put an escape sequence into your terminal through the tab name.

Colour and name are saved per session ID in the mod's own store. The mod keeps the last 300 sessions.

Limits

  • iTerm2 only. Other terminals have different escape sequences for tab colour, or none.
  • The colour is random, so two sessions can get the same one.
  • Another tool that sets the tab colour or title for the same session (for example TabChroma, which colours the tab by Claude's state) will overwrite this one, or be overwritten by it.

Development

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 animation

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

Licence

MIT

Source 3 files
hooks/register.ts 163 lines
1import { 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}
163
hooks/words.ts 83 lines
1import 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
83
types/index.d.ts 8 lines
1export 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