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

セッション名、作業フォルダ、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 .hooks/register.tsx 193 lines1import { 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}
193types/index.d.ts 12 lines1export 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