SLOPSHOPPER

session-header

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…

newbandspinnercommandpromptmodel
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-header
› 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 › /header-goal ⎿ session-header: Goal updated: OK ⟨Claude Code's own drawing⟩ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ ◆ app ⎇ ✚2 ✦ claude-opus-5-5 default │ │ ctx ▰▰▰▰▰▱▱▱▱▱ 49% 97k/200k $0.42 1 turn ⏱ 30m 5h 31% │ │ ◎ goal: OK │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ ◆ app ⎇ ✚2 ✦ claude-opus-5-5 default │ │ ctx ▰▰▰▰▰▱▱▱▱▱ 49% 97k/200k $0.42 1 turn ⏱ 30m 5h 31% │ │ ◎ goal: OK │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
README

Session Header

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                                    │
╰──────────────────────────────────────────────────────────────────╯

What it shows

  • Row 1, identity — worktree (folder of the git checkout), branch (green when clean, yellow when there are uncommitted changes), changed files ✚n, commits ahead ↑n / behind ↓n upstream, model, and permission mode coloured by mode (plan, accept-edits, auto, bypass, default)
  • Row 2, metrics — context bar (yellow from 60%, red from 80%), tokens against the window, session cost, turns, session age, and the 5-hour / 7-day rate-limit windows when the plan reports them
  • Row 3, goal — your first plain prompt, or a brief summary of what you asked at the very beginning, written once by a small model and kept for the session. It never follows later prompts and can't be edited by hand; run /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)

Behaviour

  • Narrow terminals — the branch shortens in the middle first, then optional metrics drop (rate limits, age, tokens, turns, cost); worktree, branch, model, mode and the context bar stay
  • Out of the way — hidden while you type a slash command, so the command menu sits right above your prompt, and released again when a command runs or a prompt is sent
  • Plays with other mods — draws beneath whatever other mods put in the band above the prompt (for example clean-view) instead of replacing it
  • Git data refreshes at session start, on each prompt and when a turn finishes
  • Permission mode is read from the footer's mode label; with no label it shows default

Installation

/plugin install session-header@ai-marketplace

Choose the user scope to have it in every session. It is a terminal (/plugin) install.

Development

claude plugin validate plugins/session-header
claude plugin test plugins/session-header
Source 2 files
hooks/register.tsx 401 lines
1import { 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}
401
types/index.d.ts 21 lines
1export 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