SLOPSHOPPER

title-bar

Shows session name, working folder and git branch in the prompt footer and in the terminal window title

newspinnerguardprocesstimer
v0.1.0no licenseupdated 2026-10-05akagaya/claude-code-mods/plugins/title-bar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · title-bar
› 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 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › preview- │ app ⎇ feat/auth-refresh │ ? for shortcuts

Draws

Prompt hint
preview- │ app ⎇ feat/auth-refresh │ ? for shortcuts
README

title-bar

セッション名、作業フォルダ、git ブランチをプロンプト下のフッター左側に表示し、同じ文字列をターミナルのウィンドウタイトルにも設定する、Claude Code の mod です。

session name │ ConsoleProfiles ⎇ main │ ? for shortcuts

セッション名は、/rename で付けた名前を優先します。無ければ自動生成された名前を、それも無ければセッション ID の先頭を使います。detached HEAD は @<commit> と表示します。

ウィンドウタイトルは子プロセスで設定します。Windows では scripts/set-title.ps1 を使い、それ以外では /dev/tty に OSC シーケンスを書き込みます。エンジンにタイトルを設定する手段が無いためです。

導入

claude-code-mods の一部です。

/plugin marketplace add akagaya/claude-code-mods
/plugin install title-bar@claude-code-mods

次に起動した Claude Code のセッションから読み込まれます。

開発

claude plugin validate .
claude plugin test .
Source 2 files
hooks/register.tsx 193 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface as Engine, Register } from 'claude-code'
3
4import type { Bar } from '../types'
5
6const POLL_MS = 5000
7const BRANCH_MARK = '⎇'
8const TITLE_ENV = 'CLAUDE_TITLE_BAR'
9const SEPARATOR = ' │ '
10const SEPARATOR_GAP = ' '
11const SESSION_CELLS = 32
12
13const bar = atom({ plugin: 'title-bar', key: 'bar' } as const, null)
14
15let folder = ''
16let sessionId = ''
17let transcriptPath: string | undefined
18let hookTitle: string | undefined
19let session: string | undefined
20let shown: string | undefined
21
22export const textOf = ({ session, folder, branch }: Bar): string =>
23  branch === undefined
24    ? `${session} - ${folder}`
25    : `${session} - ${folder} ${BRANCH_MARK} ${branch}`
26
27const clip = (text: string): string =>
28  text.length > SESSION_CELLS ? `${text.slice(0, SESSION_CELLS - 1)}…` : text
29
30// A name the person gave (/rename) outranks the one the engine generated.
31export const titlesIn = (transcript: string): { custom?: string; ai?: string } => {
32  const found: { custom?: string; ai?: string } = {}
33
34  for (const line of transcript.split('\n')) {
35    if (!line.includes('-title"')) continue
36
37    try {
38      const row = JSON.parse(line)
39
40      if (row.type === 'custom-title' && typeof row.customTitle === 'string') {
41        found.custom = row.customTitle
42      } else if (row.type === 'ai-title' && typeof row.aiTitle === 'string') {
43        found.ai = row.aiTitle
44      }
45    } catch {
46      // A row still being written: the next refresh reads it whole.
47    }
48  }
49
50  return found
51}
52
53const git = async ($: Engine, ...args: string[]): Promise<string | undefined> => {
54  try {
55    const { exitCode, stdout } = await $.process.run(['git', ...args], { timeoutMs: 3000 })
56
57    return exitCode === 0 ? stdout.trim() : undefined
58  } catch {
59    return undefined
60  }
61}
62
63const branchOf = async ($: Engine): Promise<string | undefined> => {
64  const branch = await git($, 'branch', '--show-current')
65
66  if (branch === undefined) return undefined
67  if (branch !== '') return branch
68
69  // Detached HEAD: no branch name, so show the commit instead.
70  const commit = await git($, 'rev-parse', '--short', 'HEAD')
71
72  return commit === undefined || commit === '' ? undefined : `@${commit}`
73}
74
75const sessionOf = async ($: Engine): Promise<string> => {
76  let titles: { custom?: string; ai?: string } = {}
77
78  try {
79    // Rejects past 4 MiB: a long session keeps the name it last read.
80    if (transcriptPath !== undefined) titles = titlesIn(await $.fs.read(transcriptPath))
81  } catch {
82    if (session !== undefined) return session
83  }
84
85  return titles.custom ?? hookTitle ?? titles.ai ?? sessionId.slice(0, 8)
86}
87
88// The engine has no call for the window title, so a child process sets it.
89const setWindowTitle = async ($: Engine, text: string): Promise<void> => {
90  const init = { env: { [TITLE_ENV]: text }, timeoutMs: 15_000 }
91
92  try {
93    if ((await $.env.get('OS')) === 'Windows_NT') {
94      const script = `${$.plugin.root}/scripts/set-title.ps1`
95
96      await $.process.run(
97        ['powershell.exe', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', script],
98        init,
99      )
100    } else {
101      await $.process.run(['sh', '-c', `printf '\\033]0;%s\\007' "$${TITLE_ENV}" > /dev/tty`], init)
102    }
103  } catch {
104    // The band still shows it.
105  }
106}
107
108const refresh = async ($: Engine, hasNewTitle: boolean): Promise<void> => {
109  const branch = await branchOf($)
110
111  if (hasNewTitle || session === undefined) session = await sessionOf($)
112
113  const next: Bar = { session, folder, branch }
114  const text = textOf(next)
115
116  if (text === shown) return
117
118  shown = text
119  await update($, bar, () => next)
120  void setWindowTitle($, text)
121}
122
123export const register: Register = on => {
124  on('session.start', async ($, e, next) => {
125    const started = await next(e)
126    const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? ''
127    const config = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${home}/.claude`
128
129    folder = e.cwd.split(/[\\/]/).filter(Boolean).pop() ?? e.cwd
130    sessionId = await $.session.id()
131    transcriptPath ??= `${config}/projects/${e.cwd.replace(/[^a-zA-Z0-9]/g, '-')}/${sessionId}.jsonl`
132
133    // The engine's own title (the topic, a spinner while working) would overwrite this one.
134    await $.env.set('CLAUDE_CODE_DISABLE_TERMINAL_TITLE', '1').catch(() => undefined)
135    await refresh($, true)
136    // A checkout made outside the session (another terminal, an IDE) raises no event here.
137    $.clock.every(POLL_MS, () => void refresh($, false))
138
139    return started
140  })
141
142  on('classic.UserPromptSubmit', async ($, e, next) => {
143    transcriptPath = e.transcript_path || transcriptPath
144    hookTitle = e.session_title ?? hookTitle
145    await refresh($, true)
146
147    return next(e)
148  })
149
150  on('turn.complete', async ($, e, next) => {
151    const completed = await next(e)
152    await refresh($, true)
153
154    return completed
155  })
156
157  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
158    const ran = await next(e)
159    await refresh($, false)
160
161    return ran
162  })
163
164  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
165    const title = await read($, bar)
166
167    if (title === null) {
168      return next(e)
169    }
170
171    const { Box, Text } = $.ui.resolve(e)
172    const rule = <Text dimColor>{SEPARATOR}</Text>
173
174    return (
175      <Box>
176        <Text wrap="truncate">
177          <Text bold>{clip(title.session)}</Text>
178          {rule}
179          <Text color="cyan">{title.folder}</Text>
180          {title.branch === undefined ? null : (
181            <Text color="success">
182              {SEPARATOR_GAP}
183              {BRANCH_MARK} {title.branch}
184            </Text>
185          )}
186          {e.props.hint === '' ? null : rule}
187          <Text dimColor>{e.props.hint}</Text>
188        </Text>
189      </Box>
190    )
191  })
192}
193
types/index.d.ts 12 lines
1export type Bar = {
2  session: string
3  folder: string
4  branch?: string
5}
6
7declare module 'claude-code' {
8  interface PluginState {
9    'title-bar': { bar: Bar | null }
10  }
11}
12