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

Two small Claude Code mods (hooks-module plugins) that make the session easier to read and drive.
| Mod | What it does | Where it shows |
|---|---|---|
| context-meter | A color-coded context window meter right above the prompt | Terminal and VS Code extension |
| term-bar | Footer buttons: 🕐 recent conversations, model picker, effort picker | Terminal only |
Requires Claude Code 2.1.287 or newer (claude --version).
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
▼
/context category, each with a fixed color, so a category that drops out never shifts the others./model.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.[ 🕐 ] [ Sonnet ▾ ] [ medium ▾ ]
Notes:
/model or /effort, which leave a line in the transcript and also save your choice as the default for new sessions.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.
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.
hooks/register.tsx 206 lines1import { 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}
206hooks/cycle.ts 12 lines1// 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)
12hooks/sessions.ts 63 lines1// 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}
63types/index.d.ts 15 lines1export 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