Buttons above the prompt: compact, clear or rewind the conversation, commit and push, or write a handoff, clear, and resume from it.

A Claude Code mod that adds a band above the prompt with a live context-window meter and five one-click buttons for keeping a long session under control.
Desktop (Code tab):
Terminal:
Context window 62.5k / 1M (6%)
██░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
[ Compact ] [ Clear ] [ Commit & push ] [ Clear + handoff ]
The bar turns to the warning color once the context window is 80% full.
| Button | What it does |
|---|---|
| Compact | Runs /compact, then refreshes the meter. |
| Clear | Asks "Are you sure you want to clear the conversation?" with Yes and No. On Yes it runs /clear to start a fresh conversation, then refreshes the meter. |
| Rewind | Runs /rewind to open the rewind picker, so you can restore the conversation and code to an earlier point. Shown only where /rewind exists (the terminal); the desktop Code tab has no /rewind. |
| Commit & push | Asks Claude to commit every change in the repository with a descriptive message and push the current branch. Claude says so instead if there's no repository, nothing to commit, or no upstream. |
| Clear + handoff | Runs the mattpocock-skills:handoff skill to write a handoff document. When that turn ends, it runs /clear and starts a new turn that reads the handoff file and picks up where it left off. While this runs, the band shows "Writing handoff…" and then "Clearing and resuming from handoff…". |
The buttons only work between turns. While Claude is working they're dimmed and do nothing.
If the handoff turn is interrupted, or no handoff file was written, the conversation is kept and a toast says why.
mattpocock-skills plugin, which provides the handoff skill.In a Claude Code terminal session:
/plugin install manager --marketplace mlt-5/manager
Answer y to add the marketplace, then pick a scope. Installed at the user scope, it also loads in the desktop Code tab.
Without the CLI, add this to ~/.claude/settings.json and start a new session:
{
"extraKnownMarketplaces": {
"manager": { "source": { "source": "github", "repo": "mlt-5/manager" } }
},
"enabledPlugins": { "manager@manager": true }
}
To run it from a local folder while developing:
claude --plugin-dir "/path/to/manager"
manager/
├── .claude-plugin/plugin.json manifest
├── .claude-plugin/marketplace.json makes the repo a marketplace
├── hooks/hooks.json lists the hooks module
├── hooks/register.tsx the mod: meter, buttons, handoff flow
├── types/index.d.ts types for the mod's saved state (phase, contextFill)
├── tests/ buttons.test.ts, context.test.ts
├── docs/preview.svg the preview image above
└── tsconfig.json
.claude-plugin/types/ holds editor types that Claude Code generates when it loads the mod. It recreates them, so there's no need to edit or commit them.
claude plugin validate .
claude plugin test .hooks/register.tsx 202 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ContextFill, Phase } from '../types'
5
6const HANDOFF_SKILL = 'mattpocock-skills:handoff'
7
8const CLEAR_QUESTION = 'Are you sure you want to clear the conversation?'
9
10const COMMIT_PROMPT =
11 'I pressed the "Commit & push" button: commit all current changes in this repository with a clear commit message describing them, then push the current branch to its remote. If this is not a git repository, or there is nothing to commit, or the branch has no upstream, say so instead of guessing.'
12
13const phase = atom({ plugin: 'manager', key: 'phase' } as const, 'idle' as Phase)
14const contextFill = atom({ plugin: 'manager', key: 'contextFill' } as const, null as ContextFill | null)
15
16// Desktop draws each block character wider than a cell, so it gets fewer to keep the bar on one line.
17const barChars = (surface: string) => (surface === 'desktop' ? 30 : 40)
18
19// 60600 -> "60.6k", 1000000 -> "1M"
20const formatTokens = (n: number) => {
21 const [value, unit] = n >= 1e6 ? [n / 1e6, 'M'] : n >= 1e3 ? [n / 1e3, 'k'] : [n, '']
22 return `${Number(value.toFixed(1))}${unit}`
23}
24
25async function refreshFill($: EngineInterface) {
26 const { context } = await $.session.usage()
27 await update($, contextFill, () => ({ tokens: context.tokens, window: context.window, percent: context.percent }))
28}
29
30export const register: Register = on => {
31 // Path of the last file written while the handoff skill ran.
32 let handoffPath: string | undefined
33
34 on('session.start', async ($, e, next) => {
35 const result = await next(e)
36 await refreshFill($).catch(() => {})
37 return result
38 })
39
40 on('session.measure', async ($, e, next) => {
41 const { tokens, window, percent } = e.context
42 await update($, contextFill, () => ({ tokens, window, percent }))
43 return next(e)
44 })
45
46 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
47 if ((await read($, phase)) === 'writing-handoff') {
48 handoffPath = e.file_path
49 }
50
51 return next(e)
52 }).catch(($, e, next) => next(e)) // never block a Write on our failure
53
54 on('turn.complete', async ($, e, next) => {
55 const result = await next(e)
56
57 if (e.agentId !== undefined || (await read($, phase)) !== 'writing-handoff') {
58 return result
59 }
60
61 const path = handoffPath
62 handoffPath = undefined
63
64 if (e.isAborted || path === undefined) {
65 await update($, phase, () => 'idle')
66 $.ui.toast(e.isAborted ? 'Handoff interrupted; conversation kept.' : 'No handoff file was written; conversation kept.')
67
68 return result
69 }
70
71 await update($, phase, () => 'resuming')
72
73 // Wait for the turn to settle before clearing and resuming.
74 $.clock.after(0, async () => {
75 try {
76 await $.command.run({ command: 'clear' })
77 await $.prompt.submit({
78 text: `Continue from the handoff document at ${path}. Read it first, then pick up where it leaves off.`,
79 })
80 } catch (err) {
81 $.ui.toast(`Handoff resume failed: ${String(err)}. Handoff file: ${path}`)
82 } finally {
83 await update($, phase, () => 'idle')
84 }
85 })
86
87 return result
88 })
89
90 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
91 if (e.props.hasSurvey) {
92 return next(e)
93 }
94
95 const { Box, Button, Text } = $.ui.resolve(e)
96 const current = await read($, phase)
97
98 if (current !== 'idle') {
99 return (
100 <Box>
101 <Text dimColor>
102 {current === 'writing-handoff' ? 'Writing handoff…' : 'Clearing and resuming from handoff…'}
103 </Text>
104 </Box>
105 )
106 }
107
108 // The buttons act between turns only; while Claude works they show dim and do nothing.
109 const isWorking = e.props.isWorking
110 const run = async (command: string) => {
111 if (isWorking) return
112 try {
113 await $.command.run({ command })
114 } catch (err) {
115 $.ui.toast(`/${command} failed: ${err instanceof Error ? err.message : String(err)}`)
116 }
117 await refreshFill($).catch(() => {})
118 }
119
120 // /rewind exists in the terminal but not in every surface (the desktop Code tab has none), so the button shows only where it can run.
121 const canRewind = (await $.command.list().catch(() => [])).some(c => c.name === 'rewind')
122 const fill = await read($, contextFill)
123 const percent = fill?.percent ?? 0
124 const chars = barChars(e.surface)
125 const filled = Math.min(chars, Math.round((percent / 100) * chars))
126 const usedLabel =
127 fill === null
128 ? '…'
129 : `${fill.tokens === undefined ? '—' : formatTokens(fill.tokens)} / ${formatTokens(fill.window)} (${fill.percent ?? 0}%)`
130
131 return (
132 <Box flexDirection="column">
133 {/* One column nested in the band's root, so the buttons stay under the bar on every surface. */}
134 <Box key="context" flexDirection="column" alignSelf="flex-start">
135 {/* As wide as the bar, so the header's right end lines up with the bar's. */}
136 <Box flexDirection="column" alignSelf="flex-start">
137 <Box justifyContent="space-between" columnGap={2}>
138 <Text dimColor>Context window</Text>
139 <Text dimColor>{usedLabel}</Text>
140 </Box>
141 <Text>
142 <Text color={percent >= 80 ? 'warning' : 'suggestion'}>{'█'.repeat(filled)}</Text>
143 <Text dimColor>{'░'.repeat(chars - filled)}</Text>
144 </Text>
145 </Box>
146 <Box flexDirection="row" gap={1} marginTop={1}>
147 <Button
148 key="compact"
149 label="Compact"
150 dimColor={isWorking}
151 onPress={() => run('compact')}
152 />
153 <Button
154 key="clear"
155 label="Clear"
156 dimColor={isWorking}
157 onPress={async () => {
158 if (isWorking) return
159 // Anything but "Yes" (No, a typed answer, or closing the dialog) keeps the conversation.
160 const answer = await $.ui.ask(CLEAR_QUESTION, { options: ['Yes', 'No'], header: 'Clear' }).catch(() => undefined)
161 if (answer === 'Yes') await run('clear')
162 }}
163 />
164 {canRewind && (
165 <Button
166 key="rewind"
167 label="Rewind"
168 dimColor={isWorking}
169 onPress={() => run('rewind')}
170 />
171 )}
172 <Button
173 key="commit"
174 label="Commit & push"
175 dimColor={isWorking}
176 onPress={async () => {
177 if (!isWorking) await $.prompt.submit({ text: COMMIT_PROMPT })
178 }}
179 />
180 <Button
181 key="handoff"
182 label="Clear + handoff"
183 dimColor={isWorking}
184 onPress={async () => {
185 if (isWorking) return
186 handoffPath = undefined
187 await update($, phase, () => 'writing-handoff')
188 try {
189 await $.command.run({ command: HANDOFF_SKILL })
190 } catch (err) {
191 await update($, phase, () => 'idle')
192 $.ui.toast(`Could not run /${HANDOFF_SKILL}: ${String(err)}`)
193 }
194 }}
195 />
196 </Box>
197 </Box>
198 </Box>
199 )
200 })
201}
202types/index.d.ts 11 lines1export type Phase = 'idle' | 'writing-handoff' | 'resuming'
2
3/** The live context window: tokens and percent are absent until the window's first response. */
4export type ContextFill = { tokens?: number; window: number; percent?: number }
5
6declare module 'claude-code' {
7 interface PluginState {
8 manager: { phase: Phase; contextFill: ContextFill | null }
9 }
10}
11