Show the session's task steps above the prompt

Claude Code 세션의 작업 단계를 입력창 바로 위에 한 줄로 띄우는 mod 입니다.
✓ 설계 ─ ● 구현 · 12m ┄ ○ 테스트 ┄ ○ 배포 1/4
창이 좁으면 완료 단계는 개수로 접고 다음 단계 하나만 남깁니다.
✓×7 ─ ● 리뷰 · 3m ┄ ○ 배포 ┄ +2 7/11
✓ 완료(초록) · ● 진행 중(노랑) · ○ 남음(회색) · ! 막힘(빨강)
Claude Code 에서:
/plugin marketplace add hyunn12/progress-band
/plugin install progress-band@progress-band
이미 열려 있는 세션은 /reload-plugins 를 실행합니다.
progress 도구(mcp__progress-band__progress)를 등록하고, Claude 가 단계 목록을 넘기면 입력창 위에 그립니다.✓ <작업 설명> 완료 · 2s 알림을 띄웁니다. 도구 호출 없이 엔진 이벤트로 동작합니다.Claude 는 시키지 않으면 도구를 잘 부르지 않습니다. ~/.claude/CLAUDE.md 에 아래 한 줄을 넣으면 여러 단계 작업을 시작할 때 알아서 켭니다.
- 3단계 이상 작업을 시작하면 첫 도구 호출로 `mcp__progress-band__progress`(지연 로드면 ToolSearch 먼저)에 전체 단계를 넘기고, 단계가 끝날 때마다 전체 목록으로 다시 호출, 끝나면 빈 목록을 넘긴다.
특정 스킬에서만 켜고 싶으면 그 스킬의 진행 절에 같은 문장을 넣습니다.
MIT
hooks/register.tsx 147 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Step } from '../types'
5
6const steps = atom({ plugin: 'progress-band', key: 'steps' } as const, [] as Step[])
7const since = atom({ plugin: 'progress-band', key: 'since' } as const, {} as Record<string, number>)
8const tick = atom({ plugin: 'progress-band', key: 'tick' } as const, 0)
9const lastAgent = atom({ plugin: 'progress-band', key: 'lastAgent' } as const, null as string | null)
10
11const MARK = {
12 done: { icon: '✓', color: 'green' },
13 now: { icon: '●', color: 'yellow' },
14 left: { icon: '○', color: 'gray' },
15 blocked: { icon: '!', color: 'red' },
16} as const
17
18type Segment = { text: string; color?: string; link: string }
19
20const width = (s: string) => [...s].reduce((n, c) => n + ((c.codePointAt(0) ?? 0) >= 0x1100 ? 2 : 1), 0)
21
22const minutes = (from: number | undefined, now: number) =>
23 from === undefined ? '' : ` · ${Math.floor((now - from) / 60000)}m`
24
25const full = (list: Step[], start: Record<string, number>, now: number): Segment[] =>
26 list.map((s, i) => ({
27 text: `${MARK[s.state].icon} ${s.label}${s.state === 'now' ? minutes(start[s.label], now) : ''}`,
28 color: MARK[s.state].color,
29 link: i === 0 ? '' : s.state === 'done' || s.state === 'now' ? ' ─ ' : ' ┄ ',
30 }))
31
32// Narrow terminal: finished steps fold into a count, only the first remaining step is named.
33const compact = (list: Step[], start: Record<string, number>, now: number): Segment[] => {
34 const done = list.filter(s => s.state === 'done').length
35 const left = list.filter(s => s.state === 'left')
36 const kept = list.filter(s => s.state === 'now' || s.state === 'blocked').concat(left.slice(0, 1))
37 const out: Segment[] = done ? [{ text: `✓×${done}`, color: 'green', link: '' }] : []
38 for (const s of full(kept, start, now)) out.push({ ...s, link: out.length ? s.link || ' ┄ ' : '' })
39 if (left.length > 1) out.push({ text: `+${left.length - 1}`, link: ' ┄ ' })
40 return out
41}
42
43export const register: Register = on => {
44 on('session.start', async ($, e, next) => {
45 await $.tool.register({
46 name: 'progress',
47 description:
48 "Shows the current multi-step task as a strip above the prompt. Call it when a task of 3+ steps starts and whenever a step starts, finishes or blocks, passing the full step list each time (short noun-phrase labels in the user's language). Pass an empty list when the task ends.",
49 inputSchema: {
50 type: 'object',
51 properties: {
52 steps: {
53 type: 'array',
54 items: {
55 type: 'object',
56 properties: {
57 label: { type: 'string' },
58 state: { type: 'string', enum: ['done', 'now', 'left', 'blocked'] },
59 },
60 required: ['label', 'state'],
61 },
62 },
63 },
64 required: ['steps'],
65 },
66 })
67 // ponytail: 30s redraw for the elapsed minutes, fine for a minute-resolution clock
68 $.clock.every(30000, async () => {
69 const now = await $.clock.now()
70 await update($, tick, () => now)
71 })
72 return next(e)
73 })
74
75 on('tool.call', { tool: 'mcp__progress-band__progress' }, async ($, e) => {
76 const list = ((e as { steps?: Step[] }).steps ?? []).filter(s => s.state in MARK)
77 const before = await read($, steps)
78 const start = await read($, since)
79 const now = await $.clock.now()
80 const nextStart: Record<string, number> = {}
81 for (const s of list) {
82 if (s.state === 'now') nextStart[s.label] = start[s.label] ?? now
83 }
84 for (const s of list) {
85 const wasBlocked = before.some(b => b.label === s.label && b.state === 'blocked')
86 if (s.state === 'blocked' && !wasBlocked) $.ui.toast(`! ${s.label}`)
87 }
88 await update($, since, () => nextStart)
89 await update($, tick, () => now)
90 await update($, steps, () => list)
91 return { result: `shown ${list.length} steps` }
92 }).catch(() => ({ deny: 'progress-band: could not update the strip' }))
93
94 on('turn.complete', async ($, e, next) => {
95 const result = await next(e)
96 if (e.agentId) {
97 const agent = await $.agent
98 .list()
99 .then(list => list.find(a => a.id === e.agentId))
100 .catch(() => undefined)
101 const name = agent?.description || agent?.type || 'subagent'
102 const sec = Math.round(e.durationMs / 1000)
103 const line = `${e.isAborted ? '✗' : '✓'} ${name} 완료 · ${sec < 60 ? `${sec}s` : `${Math.round(sec / 60)}m`}`
104 $.ui.toast(line)
105 $.ui.log(line)
106 await update($, lastAgent, () => line)
107 }
108 return result
109 })
110
111 // Background-task notifications also arrive as prompts; only the person's own message clears the line.
112 on('prompt.submit', async ($, e, next) => {
113 const kind = (e as { origin?: { kind?: string } }).origin?.kind
114 if (kind === 'composer' || kind === 'bridge') await update($, lastAgent, () => null)
115 return next(e)
116 })
117
118 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
119 const list = await read($, steps)
120 const agent = await read($, lastAgent)
121 if (e.props.hasSurvey || (list.length === 0 && !agent)) {
122 return next(e)
123 }
124 const start = await read($, since)
125 const now = await read($, tick)
126 const done = list.filter(s => s.state === 'done').length
127 const count = list.length ? ` ${done}/${list.length}` : ''
128 const tail = agent ? `${count}${list.length ? ' · ' : ''}${agent}` : count
129 let segments = full(list, start, now)
130 const fits = (segs: Segment[]) =>
131 segs.reduce((n, s) => n + width(s.link + s.text), width(tail)) <= e.props.bodyColumns
132 if (!fits(segments)) segments = compact(list, start, now)
133 const { Box, Text } = $.ui.resolve(e)
134 return (
135 <Box flexWrap="wrap">
136 {segments.map((s, i) => (
137 <Text key={`s${i}`}>
138 <Text dimColor>{s.link}</Text>
139 {s.color ? <Text color={s.color}>{s.text}</Text> : <Text dimColor>{s.text}</Text>}
140 </Text>
141 ))}
142 <Text dimColor>{tail}</Text>
143 </Box>
144 )
145 })
146}
147types/index.d.ts 8 lines1export type Step = { label: string; state: 'done' | 'now' | 'left' | 'blocked' }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'progress-band': { steps: Step[]; since: Record<string, number>; tick: number; lastAgent: string | null }
6 }
7}
8