SLOPSHOPPER

progress-band

Show the session's task steps above the prompt

newbandguardtoastprompttool
v0.3.2MITupdated 2026-10-07hyunn12/progress-band
A shopper browsing a rack in a slop shop
README

progress-band

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 를 실행합니다.

동작

  • mod 가 progress 도구(mcp__progress-band__progress)를 등록하고, Claude 가 단계 목록을 넘기면 입력창 위에 그립니다.
  • 그리기는 로컬에서 처리돼 토큰을 쓰지 않습니다. 토큰은 도구 호출(단계 목록 전달)에만 듭니다.
  • 진행 중 단계 옆에 그 단계가 시작된 뒤 지난 분을 표시합니다(30초마다 갱신).
  • 단계가 새로 막히면 알림을 한 번 띄웁니다.
  • 서브에이전트가 끝나면 ✓ <작업 설명> 완료 · 2s 알림을 띄웁니다. 도구 호출 없이 엔진 이벤트로 동작합니다.
  • 알림(막힘·서브에이전트 완료)은 입력창 위 띠가 아니라 화면 오른쪽 위에 잠깐 떴다 사라지는 토스트로 뜹니다. 서브에이전트 완료는 입력창 위 줄 끝에도 다음 메시지를 보낼 때까지 남고(단계가 없으면 그 한 줄만), 대화 기록에도 흐린 한 줄로 남아서 토스트를 놓쳐도 스크롤로 볼 수 있습니다(모델에게는 보내지 않아 토큰이 들지 않습니다).
  • 빈 목록을 넘기면 줄이 사라집니다.

자동으로 켜기

Claude 는 시키지 않으면 도구를 잘 부르지 않습니다. ~/.claude/CLAUDE.md 에 아래 한 줄을 넣으면 여러 단계 작업을 시작할 때 알아서 켭니다.

- 3단계 이상 작업을 시작하면 첫 도구 호출로 `mcp__progress-band__progress`(지연 로드면 ToolSearch 먼저)에 전체 단계를 넘기고, 단계가 끝날 때마다 전체 목록으로 다시 호출, 끝나면 빈 목록을 넘긴다.

특정 스킬에서만 켜고 싶으면 그 스킬의 진행 절에 같은 문장을 넣습니다.

라이선스

MIT

Source 2 files
hooks/register.tsx 147 lines
1import { 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}
147
types/index.d.ts 8 lines
1export 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