SLOPSHOPPER

term-bar

Terminal footer buttons: recent conversations (click to resume), model picker and effort picker.

newbandspinnertoastpromptprocess
v1.0.3MITupdated 2026-10-25hyun8935181-ux/claude-code-mods/term-bar
A shopper browsing a rack in a slop shop
README

claude-code-mods

Two small Claude Code mods (hooks-module plugins) that make the session easier to read and drive.

ModWhat it doesWhere it shows
context-meterA color-coded context window meter right above the promptTerminal and VS Code extension
term-barFooter buttons: 🕐 recent conversations, model picker, effort pickerTerminal only

Requires Claude Code 2.1.287 or newer (claude --version).

context-meter

Context 48.3k / 1m (5%) · 918.7k free · claude-sonnet-5-5
██▌█████▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
● System prompt 4.4k  ● System tools 33.6k  ● MCP tools 818  ● Memory files 129
● Skills 6.7k  ● Messages 1.9k  ● Autocompact buffer 33.0k  ● Free space 918.7k
                                                                              ▼
  • One bar segment and legend entry per /context category, each with a fixed color, so a category that drops out never shifts the others.
  • The title turns to the warning color at 80% or more.
  • Refreshes after every turn, on every prompt and right after /model.
  • ▼ slides it shut (only ▲ stays), /ctx toggles it.
  • Draws together with other plugins' bands (progress bars and the like) instead of replacing them.
  • Read-only: it measures with the local summary estimate, so it sends no network requests, writes no files and never touches tool calls. Numbers can differ slightly from /context, which counts with the token-count API.

term-bar (terminal only)

[ 🕐 ] [ Sonnet ▾ ] [ medium ▾ ]
  • 🕐 expands this folder's last 7 conversations above the prompt: the conversation's title (or its first message when it has none yet) and how long ago. Click one to resume it; empty conversations are left out. ✕ or 🕐 again closes it.
  • Model ▾ (Haiku → Sonnet → Opus → Fable, stronger to the right; Fable needs the Max plan) / effort ▾ expand the choices in place; the current one is highlighted.
  • The VS Code extension already has all three, so term-bar draws nothing there.

Notes:

  • The conversation list is read with PowerShell, so 🕐 is Windows only. The model and effort buttons work anywhere.
  • Changing the model or effort runs /model or /effort, which leave a line in the transcript and also save your choice as the default for new sessions.

Install

claude plugin marketplace add hyun8935181-ux/claude-code-mods
claude plugin install context-meter@claude-code-mods --scope user
claude plugin install term-bar@claude-code-mods --scope user

Then run /reload-plugins inside Claude Code (or start a new session). Install either one on its own; they do not depend on each other.

To update later: claude plugin marketplace update claude-code-mods, then /reload-plugins.

Develop

claude plugin validate context-meter
claude plugin test context-meter

Each mod is a hooks/register.tsx module plus a types/index.d.ts state contract. See the plugin-authoring docs in Claude Code for the API.

License

MIT

Source 4 files
hooks/register.tsx 206 lines
1import { atom, read, update } from 'claude-code'
2import type { Engine, Next, Register, RenderInput } from 'claude-code'
3
4import type { History, Pick } from '../types'
5import { EFFORTS, MODELS, aliasOf, label } from './cycle'
6import { ago, listCommand, parseList, pickRows } from './sessions'
7
8const model = atom({ plugin: 'term-bar', key: 'model' } as const, '', { shape: 'model-1' })
9const effort = atom({ plugin: 'term-bar', key: 'effort' } as const, '', { shape: 'effort-1' })
10const picking = atom({ plugin: 'term-bar', key: 'picking' } as const, null as Pick, { shape: 'pick-1' })
11const history = atom({ plugin: 'term-bar', key: 'history' } as const, { state: 'closed' } as History, {
12  shape: 'history-2',
13})
14
15// 🕐: opens the list in the band above the prompt (loading...), then fills in this folder's
16// last 7 conversations, empty ones left out. A pane would dock as a sidebar on a wide terminal,
17// so the band it is. Pressing again closes it.
18const toggleHistory = async ($: Engine) => {
19  if ((await read($, history)).state !== 'closed') return update($, history, () => ({ state: 'closed' }))
20  await update($, history, () => ({ state: 'loading' }))
21  try {
22    const cwd = await $.session.cwd()
23    const { exitCode, stdout, stderr } = await $.process.run(listCommand(cwd))
24    if (exitCode !== 0) throw new Error(stderr.trim() || `exit ${exitCode}`)
25    const current = await $.session.id()
26    const rows = pickRows(parseList(stdout), current)
27    const now = await $.clock.now()
28    await update($, history, () => ({ state: 'ok', rows, now, current }))
29  } catch (err) {
30    await update($, history, () => ({ state: 'error', text: String(err).slice(0, 200) }))
31  }
32}
33
34// A /config row, when there is one, changes the setting quietly; otherwise the slash command
35// runs (and leaves a line in the transcript).
36const rowOf = async ($: Engine, re: RegExp) => (await $.config.list()).find(r => re.test(r.key))
37const MODEL_ROW = /^model$/i
38const EFFORT_ROW = /effort/i
39// The engine exposes no way to read the effort, so the last one picked is kept in the
40// cross-session store (/effort also saves it as the default for new sessions).
41const EFFORT_KEY = 'effort'
42
43const syncModel = async ($: Engine) => {
44  const m = aliasOf(await $.session.model())
45  await update($, model, () => m)
46}
47
48// Tries the /config row, then the slash command. True on success.
49const apply = async ($: Engine, row: RegExp, command: string, value: string) => {
50  const r = await rowOf($, row)
51  if (r) {
52    const { deny } = await $.config.set({ key: r.key, value })
53    if (!deny) return true
54  }
55  if ((await $.command.list()).some(c => c.name === command)) {
56    await $.command.run({ command, args: value })
57    return true
58  }
59  $.ui.toast(`No way to change ${command} found in this version`)
60  return false
61}
62
63// The mode labels at the bottom right (next to [ Progress ]). The VS Code extension already
64// has all of this, so it draws on the terminal only. A press expands the choices in the same
65// row: this slot is one row high, so a menu popping upward would be clipped.
66const draw = async ($: Engine, e: RenderInput<"SessionMode">, next: Next<"ui.render">, isWide: boolean) => {
67  if (e.surface !== 'terminal') return next(e)
68
69  const below = await next(e)
70  const { Box, Button } = $.ui.resolve(e)
71  // Right after /clear the session values are empty: read the model from the engine and the
72  // effort from the last pick (store) on the spot.
73  const m = (await read($, model)) || aliasOf(await $.session.model())
74  const ef = (await read($, effort)) || String((await $.store.get(EFFORT_KEY)) ?? '')
75  const kind = await read($, picking)
76  // Taking the terminal's width keeps the buttons on the bottom row instead of next to the status line.
77  const row = isWide
78    ? { gap: 1, width: Math.max(20, (e.viewport?.columns ?? 80) - 4), justifyContent: 'flex-end' as const }
79    : { gap: 1 }
80
81  const choose = async (v: string) => {
82    await update($, picking, () => null)
83    if (kind === 'effort') {
84      if (await apply($, EFFORT_ROW, 'effort', v)) {
85        await update($, effort, () => v)
86        await $.store.set(EFFORT_KEY, v)
87      }
88    } else if (await apply($, MODEL_ROW, 'model', v)) {
89      // Read back the real model rather than the pick: a model the plan lacks (Fable on Pro) is refused and kept.
90      await syncModel($)
91    }
92  }
93
94  if (kind) {
95    const list = kind === 'effort' ? EFFORTS : MODELS
96    const cur = kind === 'effort' ? ef : m
97    return (
98      <Box {...row}>
99        {below}
100        {list.map(v => (
101          <Button
102            key={`pick-${v}`}
103            variant={v === cur ? 'primary' : undefined}
104            label={kind === 'effort' ? v : label(v)}
105            onPress={() => choose(v)}
106          />
107        ))}
108        <Button key="pick-close" label="✕" onPress={() => update($, picking, () => null)} />
109      </Box>
110    )
111  }
112
113  return (
114    <Box {...row}>
115      {below}
116      <Button key="history" label="🕐" onPress={() => toggleHistory($)} />
117      <Button key="model" label={`${label(m || 'model')} ▾`} onPress={() => update($, picking, () => 'model')} />
118      <Button key="effort" label={`${ef || 'effort'} ▾`} onPress={() => update($, picking, () => 'effort')} />
119    </Box>
120  )
121}
122
123
124export const register: Register = on => {
125  on('session.start', async ($, e, next) => {
126    await syncModel($)
127    const r = await rowOf($, EFFORT_ROW)
128    if (typeof r?.value === 'string') await update($, effort, () => r.value as string)
129
130    return next(e)
131  })
132
133  // Catches changes made another way (typing /model) whenever a prompt is sent.
134  on('prompt.submit', ($, e, next) => {
135    void syncModel($)
136
137    return next(e)
138  })
139
140  // 🕐 list: drawn in the band above the prompt, other plugins' band below it. A press resumes
141  // that conversation (/resume).
142  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
143    const hist = await read($, history) // never name it h: that is the JSX factory
144    if (e.surface !== 'terminal' || e.props.hasSurvey || hist.state === 'closed') return next(e)
145
146    const below = await next(e)
147    const { Box, Button, Text } = $.ui.resolve(e)
148    const close = () => update($, history, () => ({ state: 'closed' }))
149    const resume = async (id: string) => {
150      await close()
151      await $.command.run({ command: 'resume', args: id })
152    }
153
154    const body =
155      hist.state === 'loading' ? (
156        <Text dimColor>Loading…</Text>
157      ) : hist.state === 'error' ? (
158        <Text color="error">Could not read the list: {hist.text}</Text>
159      ) : hist.rows.length === 0 ? (
160        <Text dimColor>No conversations in this folder yet.</Text>
161      ) : (
162        <Box flexDirection="column">
163          {hist.rows.map(r =>
164            r.id === hist.current ? (
165              // A Text takes no key: the Box carries it.
166              <Box key={r.id}>
167                <Text dimColor wrap="truncate-end">
168                  ● {r.title} · current
169                </Text>
170              </Box>
171            ) : (
172              <Button key={r.id} plain onPress={() => resume(r.id)}>
173                {r.title} <Text dimColor>{ago(r.mtime, hist.now)}</Text>
174              </Button>
175            ),
176          )}
177        </Box>
178      )
179
180    // The same rule above and below frames the list.
181    const rule = (
182      <Text dimColor wrap="truncate-end">
183        {'─'.repeat(Math.max(10, e.props.bodyColumns))}
184      </Text>
185    )
186
187    return (
188      <Box flexDirection="column">
189        {rule}
190        <Box justifyContent="space-between">
191          <Text weight="bold">Recent conversations</Text>
192          <Button key="history-close" plain label="✕" onPress={close} />
193        </Box>
194        {body}
195        {rule}
196        {below}
197      </Box>
198    )
199  })
200
201  // If the width is refused, draw again without it so the buttons never vanish.
202  on('ui.render', { component: 'SessionMode' }, ($, e, next) => draw($, e, next, true)).catch(($, e, next) =>
203    draw($, e, next, false),
204  )
205}
206
hooks/cycle.ts 12 lines
1// fable is available on the Max plan; on other plans /model answers with a refusal line.
2export const MODELS = ['haiku', 'sonnet', 'opus', 'fable']
3export const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max']
4
5// The next value in the list; the first one for a value not in it.
6export const nextOf = (list: readonly string[], cur: string) => list[(list.indexOf(cur) + 1) % list.length]
7
8// A full model id (claude-opus-5-5) to its alias (opus). Unknown names pass through.
9export const aliasOf = (model: string) => MODELS.find(m => model.toLowerCase().includes(m)) ?? model
10
11export const label = (s: string) => s.charAt(0).toUpperCase() + s.slice(1)
12
hooks/sessions.ts 63 lines
1// title '' means an empty conversation (no message was ever sent)
2export type SessionRow = { id: string; title: string; mtime: number }
3
4// The folder Claude Code keeps a project's transcripts in: the working directory with every
5// character that is not a letter or digit turned into '-'.
6export const projectDir = (cwd: string) => cwd.replace(/[^A-Za-z0-9]/g, '-')
7
8// One PowerShell run lists this folder's n newest transcripts with each one's last aiTitle and
9// first user message. Searching the file finds those lines however large the transcript is
10// (a mod's own file read stops at 4 MiB). Messages starting with '<' are slash-command records.
11// Double quotes in the patterns are written \x22: they are stripped on the way to PowerShell.
12const STR = '((?:[^\\x22\\\\]|\\\\.)*)'
13export const listCommand = (cwd: string, n = 15) => [
14  'powershell',
15  '-NoProfile',
16  '-Command',
17  `[Console]::OutputEncoding=[Text.Encoding]::UTF8; ` +
18    `$d=Join-Path $env:USERPROFILE '.claude\\projects\\${projectDir(cwd)}'; ` +
19    `Get-ChildItem $d -Filter *.jsonl | Sort-Object LastWriteTime -Descending | Select-Object -First ${n} | ForEach-Object { ` +
20    `$h = Select-String -Path $_.FullName -Encoding UTF8 -Pattern '\\x22aiTitle\\x22:\\x22${STR}\\x22' | Select-Object -Last 1; ` +
21    `$p = Select-String -Path $_.FullName -Encoding UTF8 -Pattern '\\x22role\\x22:\\x22user\\x22,\\x22content\\x22:\\x22${STR}\\x22' | ` +
22    `Where-Object { $_.Matches[0].Groups[1].Value -notmatch '^<' } | Select-Object -First 1; ` +
23    `[pscustomobject]@{ id = $_.BaseName; t = $(if ($h) { $h.Matches[0].Groups[1].Value } else { '' }); ` +
24    `f = $(if ($p) { $p.Matches[0].Groups[1].Value } else { '' }); ` +
25    `m = [int64](($_.LastWriteTimeUtc - [datetime]'1970-01-01').TotalMilliseconds) } } | ConvertTo-Json -Compress`,
26]
27
28// Unescapes \" \n and the like inside a JSON string; a broken one passes through as is.
29const unescape = (s: string) => {
30  try {
31    return JSON.parse(`"${s}"`) as string
32  } catch {
33    return s
34  }
35}
36
37// PowerShell's output (JSON: an object for one, an array for several) to rows.
38// Without a title yet, the first message's first line stands in (50 characters). Neither: empty.
39export const parseList = (stdout: string): SessionRow[] => {
40  const text = stdout.trim()
41  if (!text) return []
42  type Raw = { id: string; t: string; f?: string; m: number }
43  const raw = JSON.parse(text) as Raw | Raw[]
44  return (Array.isArray(raw) ? raw : [raw]).map(r => {
45    const first = unescape(r.f ?? '').split('\n')[0].trim()
46    const title = unescape(r.t) || (first.length > 50 ? `${first.slice(0, 50)}…` : first)
47    return { id: r.id, title, mtime: r.m }
48  })
49}
50
51// The rows to show: empty conversations dropped, the current one kept, the newest n.
52export const pickRows = (rows: SessionRow[], current: string, n = 7) =>
53  rows.filter(r => r.title !== '' || r.id === current).slice(0, n)
54
55// Minutes / hours / days ago
56export const ago = (mtime: number, now: number) => {
57  const s = Math.max(0, Math.round((now - mtime) / 1000))
58  if (s < 60) return 'now'
59  if (s < 3600) return `${Math.floor(s / 60)}m`
60  if (s < 86400) return `${Math.floor(s / 3600)}h`
61  return `${Math.floor(s / 86400)}d`
62}
63
types/index.d.ts 15 lines
1export type Pick = 'model' | 'effort' | null
2
3export type History =
4  | { state: 'closed' }
5  | { state: 'loading' }
6  | { state: 'ok'; rows: { id: string; title: string; mtime: number }[]; now: number; current: string }
7  | { state: 'error'; text: string }
8
9declare module 'claude-code' {
10  interface PluginState {
11    // model, effort: the values the buttons show. picking: which choices are expanded. history: the 🕐 list
12    'term-bar': { model: Shaped<string>; effort: Shaped<string>; picking: Shaped<Pick>; history: Shaped<History> }
13  }
14}
15