A bordered panel above the prompt with the session's essentials — worktree, branch and git state, model, permission mode, context use, cost, turns, age, rate…

A mod (a hooks plugin, not a skill) that keeps the most important facts about a Claude Code session in view: a bordered panel above the prompt, set apart from the chat history.
╭──────────────────────────────────────────────────────────────────╮
│ ◆ scum-map ⎇ feature/637-route-planner ✚3 ↑1 ✦ sonnet plan │
│ ctx ▰▰▰▰▱▱▱▱▱▱ 42% 84k/200k $1.23 7 turns ⏱ 1h35m 5h 23% │
│ ◎ goal: build the route finder │
╰──────────────────────────────────────────────────────────────────╯
✚n, commits ahead ↑n / behind ↓n upstream, model, and permission mode coloured by mode (plan, accept-edits, auto, bypass, default)/header-goal to refresh it from all your requests across the whole session (not just the latest one) (the built-in /goal is a different command)default/plugin install session-header@ai-marketplace
Choose the user scope to have it in every session. It is a terminal (/plugin) install.
claude plugin validate plugins/session-header
claude plugin test plugins/session-headerhooks/register.tsx 401 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { GitInfo } from '../types'
5
6const git = atom({ plugin: 'session-header', key: 'git' } as const, null)
7const firstRequest = atom({ plugin: 'session-header', key: 'firstRequest' } as const, null)
8const goal = atom({ plugin: 'session-header', key: 'goal' } as const, null)
9const modes = atom({ plugin: 'session-header', key: 'modes' } as const, [])
10const isCommandDraft = atom({ plugin: 'session-header', key: 'isCommandDraft' } as const, false)
11
12const MIN_BRANCH = 10
13const MIN_GOAL_COLUMNS = 30
14const FRAME_COLUMNS = 4
15const BAR_CELLS = 10
16const MAX_GOAL = 120
17const SUMMARY_MODEL = 'claude-haiku-5-5'
18const REQUEST_SYSTEM =
19 'Summarise what the user is asking for in one short phrase of at most 12 words. Describe the task itself. No quotes, no trailing period, no preamble.'
20const WORK_SYSTEM =
21 "You are given the user's requests across a whole coding session, oldest first. State the overarching goal of the entire session in one short phrase of at most 12 words: the theme that ties the requests together, not just the latest one. If the work covers several related tasks, name the common thread. No quotes, no trailing period, no preamble."
22const MAX_REQUESTS = 36
23const REQUEST_CLIP = 200
24const NOT_A_REQUEST = /^(<local-command|<command-(name|message)|<system-reminder|<task-notification|\[SYSTEM NOTIFICATION|Base directory for this skill)/
25
26export type Item = {
27 key: string
28 text: string
29 color?: string
30 bg?: string
31 bold?: boolean
32 dim?: boolean
33 drop: number
34 shrinks?: boolean
35}
36
37export const clip = (text: string, width: number): string =>
38 width <= 0 ? '' : text.length <= width ? text : `${text.slice(0, width - 1)}…`
39
40export const clipMiddle = (text: string, width: number): string => {
41 if (width <= 0) return ''
42 if (text.length <= width) return text
43 if (width < 3) return clip(text, width)
44 const head = Math.ceil((width - 1) / 2)
45 const tail = Math.floor((width - 1) / 2)
46
47 return `${text.slice(0, head)}…${text.slice(text.length - tail)}`
48}
49
50const rowWidth = (items: readonly Item[]): number =>
51 items.reduce((sum, item) => sum + item.text.length, 0) + Math.max(0, items.length - 1)
52
53export const fitRow = (items: readonly Item[], width: number): Item[] => {
54 let row = [...items]
55
56 while (rowWidth(row) > width) {
57 const over = rowWidth(row) - width
58 const shrinkable = row.find(item => item.shrinks === true && item.text.length - over >= MIN_BRANCH)
59 if (shrinkable !== undefined) {
60 row = row.map(item =>
61 item === shrinkable ? { ...item, text: clipMiddle(item.text, item.text.length - over) } : item,
62 )
63 continue
64 }
65 const droppable = row.filter(item => item.drop > 0)
66 if (droppable.length === 0) return row.map(item => ({ ...item, text: clip(item.text, width) }))
67 const victim = droppable.reduce((worst, item) => (item.drop > worst.drop ? item : worst))
68 row = row.filter(item => item !== victim)
69 }
70
71 return row
72}
73
74export const bar = (percent: number): string => {
75 const filled = Math.round((Math.min(100, Math.max(0, percent)) / 100) * BAR_CELLS)
76
77 return '▰'.repeat(filled) + '▱'.repeat(BAR_CELLS - filled)
78}
79
80export const compactTokens = (count: number): string =>
81 count >= 1_000_000 ? `${(count / 1_000_000).toFixed(1)}M` : count >= 1000 ? `${Math.round(count / 1000)}k` : `${count}`
82
83export const formatAge = (ms: number): string => {
84 const minutes = Math.max(0, Math.floor(ms / 60_000))
85 if (minutes < 60) return `${minutes}m`
86
87 return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}m`
88}
89
90export const modeColors = (mode: string): { bg: string; color: string } => {
91 const label = mode.toLowerCase()
92 if (label.includes('bypass')) return { bg: 'red', color: 'white' }
93 if (label.includes('plan')) return { bg: 'cyan', color: 'black' }
94 if (label.includes('accept')) return { bg: 'green', color: 'black' }
95 if (label.includes('auto')) return { bg: 'magenta', color: 'white' }
96
97 return { bg: 'gray', color: 'white' }
98}
99
100const limitLabels: Record<string, string> = { five_hour: '5h', seven_day: '7d', spend_limit: 'spend' }
101
102export type HeaderInfo = {
103 git: GitInfo | null
104 model: string
105 mode: string
106 contextPercent: number | null
107 contextTokens: number | null
108 contextWindow: number
109 costUsd: number | null
110 turns: number
111 ageMs: number
112 limits: readonly { kind: string; percentUsed: number }[]
113}
114
115const heat = (percent: number): string => (percent >= 80 ? 'red' : percent >= 60 ? 'yellow' : 'green')
116
117export const identityRow = (info: HeaderInfo): Item[] => {
118 const items: Item[] = []
119 const repo = info.git
120 if (repo !== null) {
121 items.push({ key: 'slug', text: ` ◆ ${repo.slug} `, bg: 'blue', color: 'white', bold: true, drop: 0 })
122 items.push({
123 key: 'branch',
124 text: ` ⎇ ${repo.branch} `,
125 bg: repo.changed > 0 ? 'yellow' : 'green',
126 color: 'black',
127 bold: true,
128 drop: 0,
129 shrinks: true,
130 })
131 if (repo.changed > 0) items.push({ key: 'changed', text: `✚${repo.changed}`, color: 'yellow', bold: true, drop: 2 })
132 if (repo.ahead > 0) items.push({ key: 'ahead', text: `↑${repo.ahead}`, color: 'green', drop: 3 })
133 if (repo.behind > 0) items.push({ key: 'behind', text: `↓${repo.behind}`, color: 'red', drop: 3 })
134 }
135 items.push({ key: 'model', text: ` ✦ ${info.model} `, bg: 'magenta', color: 'white', bold: true, drop: 0 })
136 const colors = modeColors(info.mode)
137 items.push({ key: 'mode', text: ` ${info.mode} `, ...colors, bold: true, drop: 0 })
138
139 return items
140}
141
142export const metricsRow = (info: HeaderInfo): Item[] => {
143 const items: Item[] = []
144 const percent = info.contextPercent
145 items.push({
146 key: 'ctx',
147 text: percent === null ? 'ctx –' : `ctx ${bar(percent)} ${percent}%`,
148 color: percent === null ? undefined : heat(percent),
149 bold: true,
150 drop: 0,
151 })
152 if (info.contextTokens !== null) {
153 items.push({
154 key: 'tokens',
155 text: `${compactTokens(info.contextTokens)}/${compactTokens(info.contextWindow)}`,
156 dim: true,
157 drop: 5,
158 })
159 }
160 if (info.costUsd !== null) items.push({ key: 'cost', text: `$${info.costUsd.toFixed(2)}`, color: 'yellow', bold: true, drop: 2 })
161 items.push({ key: 'turns', text: `${info.turns} ${info.turns === 1 ? 'turn' : 'turns'}`, color: 'cyan', drop: 4 })
162 items.push({ key: 'age', text: `⏱ ${formatAge(info.ageMs)}`, color: 'cyan', drop: 6 })
163 info.limits.forEach((limit, index) => {
164 items.push({
165 key: `limit-${limit.kind}`,
166 text: `${limitLabels[limit.kind] ?? limit.kind} ${Math.round(limit.percentUsed)}%`,
167 color: heat(limit.percentUsed),
168 drop: 7 + index,
169 })
170 })
171
172 return items
173}
174
175export const goalLine = (summary: string | null, columns: number): string | null => {
176 if (columns < MIN_GOAL_COLUMNS) return null
177
178 return clip(summary === null ? '◎ goal: waiting for your first request' : `◎ goal: ${summary}`, columns)
179}
180
181export const innerColumns = (bodyColumns: number): number => Math.max(0, bodyColumns - FRAME_COLUMNS)
182
183export const isCommandText = (text: string): boolean => text.trimStart().startsWith('/')
184
185export const extractRequest = (text: string): string | null => {
186 const trimmed = text.trim()
187 const request = isCommandText(trimmed) ? trimmed.replace(/^\/\S+\s*/, '') : trimmed
188 const collapsed = request.replace(/\s+/g, ' ').trim()
189
190 return collapsed === '' ? null : collapsed
191}
192
193export const cleanSummary = (text: string): string | null => {
194 const line = text.split('\n').map(part => part.trim()).find(part => part !== '') ?? ''
195 const cleaned = line
196 .replace(/^["'`“”‘’]+|["'`“”‘’]+$/g, '')
197 .replace(/^(goal|summary)\s*:\s*/i, '')
198 .replace(/[.。]+$/, '')
199 .trim()
200
201 return cleaned === '' ? null : clip(cleaned, MAX_GOAL)
202}
203
204// At most `count` items in order: the first and last few, with an even sample of what lies between.
205export const spread = <T,>(items: readonly T[], count: number): T[] => {
206 if (items.length <= count) return [...items]
207 const edge = Math.ceil(count / 6)
208 const middle = items.slice(edge, items.length - edge)
209 const sampled = Array.from({ length: count - 2 * edge }, (_, index) => middle[Math.floor((index * middle.length) / (count - 2 * edge))])
210
211 return [...items.slice(0, edge), ...sampled, ...items.slice(items.length - edge)]
212}
213
214// The user's own requests across the whole session, oldest first, so the summary names the session's
215// theme and not the latest turn. Command echoes, skill bodies and system notices are not requests.
216export const workTranscript = (messages: readonly { role: string; text: string }[]): string => {
217 const requests = messages
218 .filter(message => message.role === 'user')
219 .map(message => message.text.replace(/\s+/g, ' ').trim())
220 .filter(text => text !== '' && !NOT_A_REQUEST.test(text))
221 const lines = spread(requests, MAX_REQUESTS).map(text => `- ${clip(text, REQUEST_CLIP)}`)
222
223 return clip(`The user's requests across the whole session, oldest first (${requests.length} in all, a long session is sampled):\n${lines.join('\n')}`, 8000)
224}
225
226const readGit = async ($: EngineInterface): Promise<GitInfo | null> => {
227 const top = await $.process.run(['git', 'rev-parse', '--show-toplevel', '--abbrev-ref', 'HEAD'])
228 if (top.exitCode !== 0) return null
229 const [root = '', branch = ''] = top.stdout.trim().split('\n')
230 const status = await $.process.run(['git', 'status', '--porcelain'])
231 const counts = await $.process.run(['git', 'rev-list', '--left-right', '--count', '@{upstream}...HEAD'])
232 const [behind = 0, ahead = 0] = counts.exitCode === 0 ? counts.stdout.trim().split(/\s+/).map(Number) : []
233
234 return {
235 slug: root.split('/').filter(Boolean).at(-1) ?? '',
236 branch,
237 changed: status.exitCode === 0 ? status.stdout.split('\n').filter(line => line.trim() !== '').length : 0,
238 ahead,
239 behind,
240 at: await $.clock.now(),
241 }
242}
243
244const askForGoal = async ($: EngineInterface, system: string, prompt: string): Promise<string | null> => {
245 try {
246 const result = await $.model.complete({ model: SUMMARY_MODEL, system, prompt, maxTokens: 60, timeoutMs: 15_000 })
247
248 return result.isAnswered ? cleanSummary(result.text) : null
249 } catch {
250 return null
251 }
252}
253
254const summariseRequest = async ($: EngineInterface, request: string) => {
255 const provisional = clip(request, MAX_GOAL)
256 await update($, goal, () => provisional)
257 const summary = await askForGoal($, REQUEST_SYSTEM, clip(request, 4000))
258 if (summary !== null) {
259 await update($, goal, () => summary)
260 }
261}
262
263const summariseWork = async ($: EngineInterface): Promise<string | null> => {
264 const summary = await askForGoal($, WORK_SYSTEM, workTranscript(await $.session.messages()))
265 if (summary !== null) {
266 await update($, goal, () => summary)
267 }
268
269 return summary
270}
271
272const refresh = async ($: EngineInterface) => {
273 try {
274 const info = await readGit($)
275 await update($, git, () => info)
276 } catch {
277 await update($, git, () => null)
278 }
279}
280
281export const register: Register = on => {
282 on('session.start', async ($, e, next) => {
283 await refresh($)
284 await $.command
285 .register({
286 name: 'header-goal',
287 description: 'Update the goal line of the session header from the work done so far',
288 })
289 .catch(() => undefined)
290
291 return next(e)
292 })
293
294 on('command.run', async ($, e, next) => {
295 if (await read($, isCommandDraft)) {
296 await update($, isCommandDraft, () => false)
297 }
298
299 return next(e)
300 })
301
302 on('command.run', { command: 'header-goal' }, async $ => {
303 const summary = await summariseWork($)
304
305 return {
306 text: summary === null ? 'Could not summarise the session; the goal line is unchanged.' : `Goal updated: ${summary}`,
307 }
308 })
309
310 on('prompt.edit', async ($, e, next) => {
311 const result = await next(e)
312 const isCommand = isCommandText(result.text)
313 if (isCommand !== (await read($, isCommandDraft))) {
314 await update($, isCommandDraft, () => isCommand)
315 }
316
317 return result
318 })
319
320 on('prompt.submit', async ($, e, next) => {
321 if (await read($, isCommandDraft)) {
322 await update($, isCommandDraft, () => false)
323 }
324 const request = extractRequest(e.text)
325 if (request !== null && (await read($, firstRequest)) === null) {
326 await update($, firstRequest, () => request)
327 void summariseRequest($, request)
328 }
329 await refresh($)
330
331 return next(e)
332 })
333
334 on('turn.complete', async ($, e, next) => {
335 await refresh($)
336
337 return next(e)
338 })
339
340 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
341 const labels = [...e.props.modes]
342 const current = await read($, modes)
343 if (labels.join('|') !== current.join('|')) {
344 await update($, modes, () => labels)
345 }
346
347 return next(e)
348 })
349
350 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
351 const below = await next(e)
352 if (e.props.hasSurvey || (await read($, isCommandDraft))) return below
353
354 const { Box, Text } = $.ui.resolve(e)
355 const width = innerColumns(e.props.bodyColumns)
356 const usage = await $.session.usage()
357 const info: HeaderInfo = {
358 git: await read($, git),
359 model: await $.session.model(),
360 mode: (await read($, modes)).join(' ').trim() || 'default',
361 contextPercent: usage.context.percent ?? null,
362 contextTokens: usage.context.tokens ?? null,
363 contextWindow: usage.context.window,
364 costUsd: usage.cost?.usd ?? null,
365 turns: await $.session.turns(),
366 ageMs: (await $.clock.now()) - usage.startedAt,
367 limits: usage.rateLimits,
368 }
369 const goalText = goalLine(await read($, goal), width)
370 const rows = [fitRow(identityRow(info), width), fitRow(metricsRow(info), width)]
371
372 return (
373 <Box flexDirection="column">
374 {below}
375 <Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1}>
376 {rows.map((row, index) => (
377 <Box key={`row-${index}`} gap={1}>
378 {row.map(item => (
379 <Text
380 key={item.key}
381 bold={item.bold}
382 dimColor={item.dim}
383 color={item.color}
384 backgroundColor={item.bg}
385 >
386 {item.text}
387 </Text>
388 ))}
389 </Box>
390 ))}
391 {goalText !== null && (
392 <Text italic color="white">
393 {goalText}
394 </Text>
395 )}
396 </Box>
397 </Box>
398 )
399 })
400}
401types/index.d.ts 21 lines1export type GitInfo = {
2 slug: string
3 branch: string
4 changed: number
5 ahead: number
6 behind: number
7 at: number
8}
9
10declare module 'claude-code' {
11 interface PluginState {
12 'session-header': {
13 git: GitInfo | null
14 firstRequest: string | null
15 goal: string | null
16 modes: string[]
17 isCommandDraft: boolean
18 }
19 }
20}
21