A pane beside the conversation that shows the steps Claude plans and keeps them current as it works

A Claude Code mod that shows Claude's plan in a pane beside the conversation. Claude writes the steps when it starts work that has two or more of them. It marks one step as doing while it works, and marks each step done when it finishes. You can see what Claude is doing and what is left, without reading the whole transcript.
Works in the Claude Code terminal and in the Code tab of the Claude desktop app.
/steps to show or hide the pane.The pane never opens by itself. Claude keeps the list current while the pane is hidden, so it is ready when you open it.
When you set a goal with /goal, the pane shows it above the list: "Goal" in bold beside a target, then the goal in regular text. A long goal stops after 3 lines with "…", so the steps stay in view. /goal clear removes it. A goal that Claude proposes and you accept shows the same way.
mcp__steps__set_steps, on every call. This is the mod's own tool, which it registers at session start. Claude Code serves a tool that a mod registers only through that mod's tool.call hook, so the hook is the whole tool: no other tool runs in its place. The mod answers no other tool./steps, on every run. This is the mod's own command, which it registers at session start. It shows or hides the pane. The mod answers no other command.All hooks are in hooks/register.tsx.
session.start registers the set_steps tool and the /steps command, then passes the event on unchanged.tool.call for mcp__steps__set_steps answers the mod's own set_steps tool. It checks the list, keeps it in the mod's memory, redraws the pane and returns a one-line count.tool.call for every other tool runs the tool unchanged and returns its result unchanged. It only counts the call. When Claude makes 6 tool calls without an update while steps are still open, it adds one short note to the result that only Claude reads. It also adds one note per request when Claude makes 4 tool calls with no list. Tool calls by subagents do not count. It never blocks a call.command.run for steps answers the mod's own /steps command. It shows or hides the pane and prints one line.command.run for goal runs Claude Code's own /goal unchanged and returns its result unchanged. It only reads the text after /goal to show it in the pane.tool.call for ProposeGoal runs the tool unchanged and returns its result unchanged. When the goal is set, it reads the goal's text to show it in the pane. A goal you turn down does not show.tool.describe for mcp__steps__set_steps sets isDeferred to false, so the tool's schema stays in the prompt and Claude does not have to look it up first. It changes nothing else, and no other tool.prompt.compose adds one section to the system prompt, steps:rule. The rule tells Claude when to make a list and how to keep it current. It keeps every other section as it is, and adds nothing to a bare session.prompt.submit starts the counts again for each new message. It passes the message on unchanged.ui.render for SessionMode adds the Steps button after the footer's own labels.ui.render for the steps pane draws the goal and the list.It makes no permission decisions. It makes no network calls, reads and writes no files, and sends nothing outside Claude Code. The list and the goal live only in the mod's memory for the session, and nothing is saved.
claude plugin test .hooks/register.tsx 356 lines1import type { Register, UiPane } from 'claude-code'
2
3import type { Step, StepStatus } from '../types'
4
5// The directory reads any name $ in this file as the engine, and follows it only as a
6// hook's first parameter in plain $.noun.method(...) calls. So no helper takes $ and
7// no type is named $: each hook makes its own calls.
8const PANE = 'steps'
9const TOOL = 'mcp__steps__set_steps'
10
11// The app can hold the pane open but undrawn or behind another tab, so ask it.
12function isOnScreen(panes: readonly UiPane[]): boolean {
13 return panes.some(p => p.id === PANE && p.isPlaced && p.isShown)
14}
15
16const STATUSES: readonly StepStatus[] = ['todo', 'doing', 'done']
17
18// Claude sends the whole list on every call, so the pane always shows its latest view.
19function parseSteps(raw: unknown): Step[] | string {
20 if (!Array.isArray(raw)) return 'steps must be an array'
21 const out: Step[] = []
22 for (const item of raw) {
23 const title = typeof item?.title === 'string' ? item.title.trim() : ''
24 const status = item?.status
25 if (!title) return 'every step needs a non-empty title'
26 if (!STATUSES.includes(status)) return `status must be one of ${STATUSES.join(', ')}`
27 out.push({ title, status })
28 }
29 return out
30}
31
32const RULE = [
33 '# Steps pane',
34 `A Steps pane beside this conversation shows your plan. Keep it current with the ${TOOL} tool.`,
35 '- For any request with 2 or more steps, call it before the first step with the full plan.',
36 '- Research and investigation count as multi-step work: list the questions or places you will look.',
37 '- Mark exactly one step "doing" while you work on it. Mark it "done" the moment it is finished,',
38 ' in the same message as the first tool call of the next step. Do not batch updates for later.',
39 '- Add, rename or drop steps when the plan changes. Always send the whole list.',
40 '- Skip it only for a one-step answer.',
41].join('\n')
42
43// The rule is read once, so mid-turn nothing reminded Claude and the pane fell
44// behind. Tool results now carry a short nudge the person never sees.
45const STALE_AFTER = 6 // tool calls since the last set_steps, with steps still open
46const PLAN_AFTER = 4 // tool calls in a request that has no list yet
47
48// Module variables on purpose: a reload starts the list and the count again, which is harmless.
49// Keeping the list here, not in $.state, means the mod declares no state contract.
50let steps: Step[] = []
51let callsSinceUpdate = 0
52let hasListThisRequest = false
53let hasNudgedForPlan = false
54// The goal from /goal or Claude's ProposeGoal, shown above the list.
55let goal: string | undefined
56
57const GOAL_LINES = 3
58
59// A long goal pushed the steps out of view, so it stops after GOAL_LINES lines.
60// Text has no line limit, only a one-line cut, so this wraps by words at the
61// pane's width the way the surface will, keeps those lines and ends the last
62// with an ellipsis. Exact in the terminal; close on the desktop's wider font.
63function goalText(text: string, columns: number): string {
64 const full = `${text.charAt(0).toUpperCase()}${text.slice(1)}`
65 if (columns < 2) return full
66 // glue is what joins a line to the one before: a space, or nothing inside a split word.
67 const lines: { text: string; glue: string }[] = []
68 let cur: { text: string; glue: string } | undefined
69 for (const word of full.split(/\s+/).filter(Boolean)) {
70 if (cur && cur.text.length + 1 + word.length <= columns) {
71 cur.text += ` ${word}`
72 continue
73 }
74 if (cur) lines.push(cur)
75 let rest = word
76 let glue = ' '
77 while (rest.length > columns) {
78 lines.push({ text: rest.slice(0, columns), glue })
79 rest = rest.slice(columns)
80 glue = ''
81 }
82 cur = { text: rest, glue }
83 }
84 if (cur) lines.push(cur)
85 if (lines.length <= GOAL_LINES) return full
86
87 const kept = lines.slice(0, GOAL_LINES)
88 const last = kept[GOAL_LINES - 1]
89 if (last && last.text.length >= columns) last.text = last.text.slice(0, columns - 1)
90
91 return `${kept.map((l, i) => (i === 0 ? l.text : l.glue + l.text)).join('')}…`
92}
93
94function nudgeFor(list: Step[]): string | undefined {
95 const open = list.filter(s => s.status !== 'done')
96 if (open.length > 0 && callsSinceUpdate >= STALE_AFTER) {
97 callsSinceUpdate = 0
98 const doing = list.find(s => s.status === 'doing')
99 const now = doing ? `"${doing.title}" is still marked doing` : 'no step is marked doing'
100 return (
101 `Steps pane: ${STALE_AFTER} tool calls since your last ${TOOL} call, and ${now}. ` +
102 'If that step is finished or you moved on, call it now with the updated list.'
103 )
104 }
105 if (!hasListThisRequest && !hasNudgedForPlan && open.length === 0 && callsSinceUpdate >= PLAN_AFTER) {
106 hasNudgedForPlan = true
107 return (
108 `Steps pane: ${callsSinceUpdate} tool calls on this request and no list of steps. ` +
109 `If this is more than one step (research counts), call ${TOOL} now with the plan.`
110 )
111 }
112 return undefined
113}
114
115export const register: Register = on => {
116 on('session.start', async ($, e, next) => {
117 await $.tool.register({
118 name: 'set_steps',
119 description:
120 'Replace the list of steps shown to the user in the side pane. Send the full list every time, in order. ' +
121 'Use it to plan multi-step work and to mark progress as you go.',
122 inputSchema: {
123 type: 'object',
124 properties: {
125 steps: {
126 type: 'array',
127 items: {
128 type: 'object',
129 properties: {
130 title: { type: 'string', description: 'Short step name, a few words' },
131 status: { type: 'string', enum: STATUSES },
132 },
133 required: ['title', 'status'],
134 },
135 },
136 },
137 required: ['steps'],
138 },
139 })
140 await $.command.register({ name: 'steps', description: 'Show or hide the Steps pane' })
141
142 return next(e)
143 })
144
145 // Only /steps and the Steps button open the pane. A pane that opens by itself gets in the way.
146 on('command.run', { command: 'steps' }, async $ => {
147 const panes = await $.ui.panes()
148 if (isOnScreen(panes)) {
149 await $.ui.close({ id: PANE })
150 return { text: 'Steps pane hidden.' }
151 }
152 await $.ui.open({ id: PANE, title: 'Steps' })
153
154 return { text: 'Steps pane shown.' }
155 })
156
157 // The Steps button sits in the footer under the prompt, after the engine's own mode labels.
158 // The desktop footer draws nothing for a Client (a plain-text control with a
159 // hover-only fill), so both surfaces use a Button. Button has no padding prop,
160 // so on the desktop non-breaking spaces widen its pill.
161 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
162 const { Box, Button } = $.ui.resolve(e)
163 const label = e.surface === 'desktop' ? ' Steps ' : 'Steps'
164 const toggle = async () => {
165 const panes = await $.ui.panes()
166 if (isOnScreen(panes)) await $.ui.close({ id: PANE })
167 else await $.ui.open({ id: PANE, title: 'Steps' })
168 }
169
170 return (
171 <Box>
172 {await next(e)}
173 <Button key="toggle" plain label={label} onPress={toggle} />
174 </Box>
175 )
176 })
177
178 // Plugin tools sit behind ToolSearch by default, and Claude never looked set_steps up.
179 // Keep its schema in the prompt so it is always callable.
180 on('tool.describe', { tool: 'mcp__steps__set_steps' }, async ($, e, next) => ({ ...(await next(e)), isDeferred: false }))
181
182 // The rule is a system prompt section: read once per session and cached, not repeated per message.
183 on('prompt.compose', async ($, e, next) => {
184 const out = await next(e)
185 if (e.traits.includes('bare')) return out
186
187 return { sections: [...out.sections, { id: 'steps:rule', text: RULE, scope: 'session' as const }] }
188 })
189
190 // A new message from the person is a new request: its own plan, its own count.
191 on('prompt.submit', async ($, e, next) => {
192 callsSinceUpdate = 0
193 hasListThisRequest = false
194 hasNudgedForPlan = false
195 return next(e)
196 })
197
198 // Counts the main loop's tool calls; a subagent's calls are its own work.
199 on('tool.call', async ($, e, next) => {
200 if (e.tool === TOOL || e.agentId !== undefined) return next(e)
201 const out = await next(e)
202 if (out.deny !== undefined) return out
203 callsSinceUpdate++
204 const nudge = nudgeFor(steps)
205
206 return nudge ? { ...out, context: [...(out.context ?? []), nudge] } : out
207 })
208
209 // The only way a mod serves a tool it registered: this hook is set_steps's whole implementation.
210 on('tool.call', { tool: 'mcp__steps__set_steps' }, async ($, e) => {
211 const parsed = parseSteps((e as { steps?: unknown }).steps)
212 if (typeof parsed === 'string') return { deny: parsed }
213 callsSinceUpdate = 0
214 hasListThisRequest = true
215 steps = parsed
216 $.ui.invalidate('ui.render')
217 const done = parsed.filter(s => s.status === 'done').length
218
219 return { result: `Steps updated: ${done}/${parsed.length} done.` }
220 })
221
222 // The engine runs /goal; the mod only reads what was typed. Bare /goal shows the
223 // goal and changes nothing, and /goal clear ends it.
224 on('command.run', { command: 'goal' }, async ($, e, next) => {
225 const out = await next(e)
226 const args = e.args.trim()
227 if (args === '') return out
228 goal = args === 'clear' ? undefined : args
229 $.ui.invalidate('ui.render')
230
231 return out
232 })
233
234 // Claude can set a goal too. A goal the person turns down never shows.
235 on('tool.call', { tool: 'ProposeGoal' }, async ($, e, next) => {
236 const out = await next(e)
237 if (out.deny !== undefined || out.isError) return out
238 const condition = typeof e.condition === 'string' ? e.condition.trim() : ''
239 if (condition) {
240 goal = condition
241 $.ui.invalidate('ui.render')
242 }
243
244 return out
245 })
246
247 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
248 const list = steps
249 const done = list.filter(s => s.status === 'done').length
250
251 // Desktop: icons carry the state, text stays in the theme's own color.
252 if (e.surface === 'desktop') {
253 const { Box, Text, Svg } = $.ui.resolve(e)
254 // "Goal" in bold beside its symbol, then the goal itself in regular weight.
255 const title = goal ? (
256 <Box flexDirection="column">
257 <Box flexDirection="row" alignItems="center" gap={1}>
258 <Svg source={GOAL_ICON} alt="goal" width={16} height={16} />
259 <Text bold>Goal</Text>
260 </Box>
261 <Text wrap="wrap">{goalText(goal, e.props.bodyColumns)}</Text>
262 </Box>
263 ) : null
264 if (list.length === 0) {
265 return title ? (
266 <Box flexDirection="column" gap={1}>
267 {title}
268 <Text dimColor>No steps yet.</Text>
269 </Box>
270 ) : (
271 <Text dimColor>No steps yet.</Text>
272 )
273 }
274
275 return (
276 <Box flexDirection="column" gap={1}>
277 {title}
278 <Box flexDirection="column" gap={1}>
279 {list.map(step => (
280 <Box flexDirection="row" alignItems="center" gap={1}>
281 <Svg source={ICONS[step.status]} alt={step.status} width={16} height={16} />
282 <Text
283 bold={step.status === 'doing'}
284 dimColor={step.status === 'done'}
285 strikethrough={step.status === 'done'}
286 wrap="wrap"
287 >
288 {step.title}
289 </Text>
290 </Box>
291 ))}
292 </Box>
293 <Box flexDirection="row" justifyContent="flex-end">
294 <Text dimColor>
295 {done} of {list.length} done
296 </Text>
297 </Box>
298 </Box>
299 )
300 }
301
302 // Terminal: plain text, no color, the marker and weight carry the state.
303 const { Box, Text } = $.ui.resolve(e)
304
305 return (
306 <Box flexDirection="column">
307 {goal ? <Text bold>{`${GOAL_MARK} Goal`}</Text> : null}
308 {goal ? <Text>{goalText(goal, e.props.bodyColumns)}</Text> : null}
309 {list.map(step => (
310 <Text bold={step.status === 'doing'} dimColor={step.status === 'done'} strikethrough={step.status === 'done'}>
311 {step.status === 'done' ? '✓' : step.status === 'doing' ? '›' : '○'} {step.title}
312 </Text>
313 ))}
314 <Text dimColor>{list.length === 0 ? 'No steps yet.' : `${done} of ${list.length} done`}</Text>
315 </Box>
316 )
317 })
318}
319
320// Black and gray. "ink" is near-black on a light pane and near-white on a dark one,
321// so the icons never vanish into the background; "paper" is the opposite, for the check.
322const STYLE =
323 '<style>.ink{fill:#1C1C1E;stroke:#1C1C1E}.paper{stroke:#FFFFFF}.gray{fill:#8E8E93;stroke:#8E8E93}' +
324 '@media (prefers-color-scheme: dark){.ink{fill:#F2F2F7;stroke:#F2F2F7}.paper{stroke:#1C1C1E}}</style>'
325const svg = (viewBox: string, body: string) =>
326 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${viewBox}">${STYLE}${body}</svg>`
327
328// The spinner: a faint ring with a quarter arc turning on it, one turn a second.
329// The sandboxed frame (isInteractive) painted a white square on the dark pane,
330// so the icon draws as a plain image. A CSS animation inside the SVG turns it,
331// with no redraws from the engine.
332const SPINNER = svg(
333 '0 0 16 16',
334 '<style>@keyframes spin{to{transform:rotate(360deg)}}.spin{transform-origin:8px 8px;animation:spin 1s linear infinite}</style>' +
335 '<circle class="gray" cx="8" cy="8" r="6.5" style="fill:none" stroke-width="1.5" opacity="0.35"/>' +
336 '<path class="ink spin" d="M8 1.5A6.5 6.5 0 0 1 14.5 8" style="fill:none" stroke-width="1.5" stroke-linecap="round"/>',
337)
338
339// The goal's symbol: a target. GOAL_MARK is the terminal's text version of it.
340const GOAL_ICON = svg(
341 '0 0 16 16',
342 '<circle class="ink" cx="8" cy="8" r="6.5" style="fill:none" stroke-width="1.5"/>' +
343 '<circle class="ink" cx="8" cy="8" r="3.5" style="fill:none" stroke-width="1.5"/>' +
344 '<circle class="ink" cx="8" cy="8" r="1.25" style="stroke:none"/>',
345)
346const GOAL_MARK = '◎'
347
348const ICONS: Record<StepStatus, string> = {
349 todo: svg('0 0 16 16', '<circle class="gray" cx="8" cy="8" r="6.5" style="fill:none" stroke-width="1.5"/>'),
350 doing: SPINNER,
351 done: svg(
352 '0 0 16 16',
353 '<circle class="ink" cx="8" cy="8" r="7.25" style="stroke:none"/><path class="paper" d="M4.75 8.25l2.25 2.25 4.25-4.5" fill="none" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>',
354 ),
355}
356types/index.d.ts 3 lines1export type StepStatus = 'todo' | 'doing' | 'done'
2export type Step = { title: string; status: StepStatus }
3