SLOPSHOPPER

turn-status

A quiet band above the prompt: what Claude is doing, how long, task progress and failures; hides when idle.

newbandguardtoasttimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · turn-status
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ▣ client module ./motion.tsx ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▣ client module ./motion.tsx
README

Claude Code mods

Two small mods for Claude Code that make it easier to see what Claude is doing and to decide what to do next.

ModWhat you get
recapAfter Claude finishes a real piece of work, a side pane lists 14–20 possible next moves, color-coded by direction and ranked 80/20. Pick a few and it writes your next prompt for you.
turn-statusA quiet one-line band above the prompt while Claude works: what it is doing right now, how long it has been going, task progress and failures. It hides itself when Claude is idle.

Install

In a terminal, start claude and paste the line for the mod you want:

/plugin install recap --marketplace lokki007/claude-code-mods
/plugin install turn-status --marketplace lokki007/claude-code-mods

Claude Code asks whether to add the marketplace: answer y, then press Enter to install it for your user. The mod works straight away in that terminal session, and in every new chat after that, including the desktop app's Code tab. Restart chats that were already open.

The desktop app's Code tab can't run /plugin install itself; install once from a terminal and the desktop app picks it up.

Needs a recent Claude Code (one that supports hooks-module plugins, 2.1.29x or later).

recap: your next-prompt builder

The ✨ Next pane in the Claude desktop app: 17 next moves, color-coded by direction, with + Pick and ☆ Save on each

The ✨ Next pane in the Claude desktop app, beside the chat: 17 options, filterable by direction, ranked 80/20, each with + Pick and ☆ Save.

When a turn edits files or digs through research, the ✨ Next pane opens beside the chat:

  • One line of status: 💚 done, 🟡 in progress or 🟥 blocked, plus a one-sentence summary.
  • Many options, not just one: Claude looks at everything it saw (bugs, loose ends, shortcuts, missed chances), thinks about what your customers would value, and offers 28–40 next moves across eight directions:
DirectionDirection
🐞Fix💰Money
🧹Tech debt📣Growth & leads
✨Features🚀Ship & ops
🎨UI & design🧪Wild ideas

Each option has a colored bar per direction it serves, a rough size (quick, medium, big), and whether it needs your ideas (💬) or Claude can decide it alone (🤖).

  • 80/20 ranking: the best value for the effort comes first; moves that help several directions at once rank higher, and the top three wear ⭐ 80/20. Click a direction to see only its options.
  • 📦 Packages: the options also come in 3 to 5 ready-made bundles, like "the 2 big ones", a handful of quick wins, or a dozen moves for a bigger push. Use this package picks the whole bundle (the prompt names it); ☆ Save for later sends it to the Saved tab under its name, and every new recap is told what you saved, so it brings back what still matters.
  • It learns, gently: after a few prompts it notices which directions you usually pick and nudges them up a little, never hiding the rest. Reset learning clears it.
  • Save for later: ☆ Save keeps an option in the Saved tab for that project, across chats, so you can pick from it any time (/recap opens it).
  • Build the prompt: press + Pick on a few options. The first is the main job, the rest ride along. Choose Where (keep it local, staging, production) and Questions:
  • Interview me first: Claude asks up to five plain-language multiple-choice questions in the pane; click your answers and press Send answers. You are never asked technical questions.
  • Just do it: no questions, Claude decides and tells you what it chose.
  • Auto: interviews you only when a pick needs your ideas.
  • Send it: Put in prompt, Add to my draft or Send now ▶. A− / A+ change the text size, and your choices are remembered.

It also keeps track for you:

  • Never left guessing: when a turn gets no pane, a short note says why ("nothing was changed, it was just a chat") and that /recap makes one anyway.
  • The latest recap per project: /recap in any chat of the same project shows it straight away; ↻ New options brews fresh ones for that chat.
  • A gentle reminder: open a chat on a project whose last piece of work was left 🟡 or 🟥, and the owl names it with one next step.
  • A progress diary per project: each finished recap adds a plain, dated entry (what was done, what was asked, status) you can share with clients. /recap diary shows where it is.
  • A log for debugging: every attempt, whether it worked and why, in ~/.claude/recap/log.jsonl (/recap log).
  • A plain warning if two copies of the mod end up loaded in one chat, instead of failing quietly. turn-status does the same.

Commands: /recap opens the pane, /recap again brews fresh options, /recap diary and /recap log show those files. Newer Claude versions have a built-in /recap; there the same commands start with /next instead (/next, /next again, and so on), and the mod tells you once.

What's next for it: a daily digest across all your projects (a sketch).

How it works: the options come from a short follow-up question to Claude about the conversation that just happened, so they can point at real code. It costs a little extra per qualifying turn (most of the conversation is served from the prompt cache); if that fails it falls back to the small, fast model.

turn-status: a quiet live status band

While Claude works, one row above the prompt shows:

⟳ Building the status band   Running npm test   1:46   ▬▬▬▬▬ 1/5   ● 1 failed
  • what Claude is doing in plain words (the current task when it keeps a task list, else "Editing svg.ts", "Running npm test", "Thinking")
  • a timer whose digits roll over like an odometer
  • task progress, one segment per task, when there is a task list
  • a red count when a tool fails

When the turn ends it shows "✓ Done in 1:46" for a few seconds and then gets out of the way. In the desktop app it is drawn as a small animated graphic; in a terminal, as text.

For tinkerers

Each mod is a Claude Code hooks-module plugin under plugins/<mod>: hooks/register.tsx is the entry point, types/index.d.ts declares the state it keeps, and hooks/*.test.ts are its tests. Run them with:

claude plugin validate plugins/recap
claude plugin test plugins/recap

To work on a mod locally, add this folder as a marketplace once (claude plugin marketplace add <path to this repo>), install from it, then release changes with:

node scripts/release.mjs recap [patch|minor|major] [--publish]

It bumps the version, validates and tests, commits the release, refreshes the installed copy, prunes old cached copies, and with --publish pushes here.

License

MIT

Source 6 files
hooks/register.tsx 117 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Snap } from '../types'
5import { IDLE, LINGER_MS, applyTaskCall, describeTool, plainLine, taskCallOf } from './model'
6import { bandSvg } from './svg'
7import type { Instance } from './copies'
8import { duplicateWarning, otherCopies, registryPath, withInstance } from './copies'
9
10export const snap = atom({ plugin: 'turn-status', key: 'snap' } as const, IDLE)
11
12export const register: Register = on => {
13  // A second copy of this mod in the same chat: each copy announces itself, then looks for the other.
14  on('session.start', async ($, e, next) => {
15    const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
16    if (home) {
17      const me: Instance = { session: await $.session.id(), token: crypto.randomUUID(), root: $.plugin.root, at: await $.clock.now() }
18      const registry = registryPath(home)
19      const announce = async () => {
20        const raw = (await $.fs.exists(registry)) ? String(await $.fs.read(registry)) : '[]'
21        await $.fs.write(registry, JSON.stringify(withInstance(raw, me)))
22      }
23      await announce()
24      // Two copies writing at the same instant can drop each other: write again twice, a little apart.
25      $.clock.after(300 + Math.floor(Math.random() * 300), () => void announce())
26      $.clock.after(900 + Math.floor(Math.random() * 400), () => void announce())
27      $.clock.after(2000, async () => {
28        const raw = (await $.fs.exists(registry)) ? String(await $.fs.read(registry)) : '[]'
29        const others = otherCopies(withInstance(raw, me), me)
30        // Every copy sees the others; the one that started last says so (its check runs last), so the warning shows once.
31        if (others.length > 0 && [me, ...others].sort((a, b) => b.at - a.at || a.token.localeCompare(b.token))[0]!.token === me.token) {
32          $.ui.toast(duplicateWarning(me, others), { timeoutMs: 12000 })
33        }
34      })
35    }
36    return next(e)
37  })
38
39  on('turn.start', async ($, e, next) => {
40    const now = await $.clock.now()
41    await update($, snap, s => ({
42      ...IDLE,
43      tasks: s.tasks ?? [],
44      phase: 'working' as const,
45      turnId: e.turnId,
46      startedAt: now,
47      actionAt: now,
48    }))
49    return next(e)
50  })
51
52  on('tool.call', async ($, e, next) => {
53    // A subagent's tools are its own business; the line follows the main loop.
54    const isMain = e.agentId === undefined
55    if (isMain) {
56      const now = await $.clock.now()
57      await update($, snap, s => ({ ...s, action: describeTool(e as any), actionAt: now }))
58    }
59    const ran = await next(e)
60    const now = await $.clock.now()
61    const failed = ran.deny === undefined && ran.isError === true
62    const call = ran.deny === undefined && !failed ? taskCallOf(e as any, ran.result) : null
63    await update($, snap, s => {
64      let out: Snap = { ...IDLE, ...s }
65      if (isMain) out = { ...out, action: null, actionAt: now }
66      if (failed) out = { ...out, failed: out.failed + 1, lastFailAt: now }
67      if (call) out = applyTaskCall(out, call, now)
68      return out
69    })
70    return ran
71  }).catch(($, e, next) => next(e))
72
73  on('turn.complete', async ($, e, next) => {
74    if (e.agentId === undefined) {
75      const now = await $.clock.now()
76      await update($, snap, s => ({
77        ...IDLE,
78        ...s,
79        phase: 'done' as const,
80        action: null,
81        durationMs: e.durationMs,
82        aborted: e.isAborted,
83        doneAt: now,
84      }))
85      // Then get out of the way, unless another turn has started meanwhile.
86      $.clock.after(LINGER_MS, () => {
87        void update($, snap, s => (s.phase === 'done' && s.turnId === e.turnId ? { ...s, phase: 'idle' as const } : s))
88      })
89    }
90    return next(e)
91  })
92
93  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
94    const s: Snap = { ...IDLE, ...(await read($, snap)) } // older saved state may lack newer fields
95    if (e.props.hasSurvey || s.phase === 'idle') return next(e)
96    const now = await $.clock.now()
97
98    if (e.surface === 'desktop') {
99      const { Svg } = $.ui.resolve({ ...e, surface: e.surface })
100      const band = bandSvg(s, now)
101      return <Svg source={band.source} alt={band.alt} width={band.width} height={band.height} />
102    }
103
104    if (e.surface === 'terminal') {
105      const { Box, Client } = $.ui.resolve({ ...e, surface: e.surface })
106      return (
107        <Box>
108          <Client key="band" module="./motion.tsx" props={s} flexGrow={1} />
109        </Box>
110      )
111    }
112
113    const { Text } = $.ui.resolve(e)
114    return <Text dimColor>{plainLine(s, now)}</Text>
115  })
116}
117
hooks/model.ts 168 lines
1import type { Snap, Task } from '../types'
2
3// Pure state and wording, shared by the hooks module, both drawings and tests.
4
5export const IDLE: Snap = {
6  phase: 'idle',
7  turnId: '',
8  action: null,
9  failed: 0,
10  durationMs: 0,
11  aborted: false,
12  startedAt: 0,
13  doneAt: 0,
14  actionAt: 0,
15  lastFailAt: 0,
16  tasks: [],
17  taskDoneAt: 0,
18  taskDoneId: '',
19}
20
21/** How long the finished line stays before the band gets out of the way. */
22export const LINGER_MS = 8000
23
24const clip = (t: string, max: number) => {
25  const one = t.replace(/\s+/g, ' ').trim()
26  return one.length > max ? `${one.slice(0, max - 1)}…` : one
27}
28
29const base = (p: unknown) =>
30  typeof p === 'string' ? (p.split(/[\\/]/).pop() ?? p) : ''
31
32const host = (u: unknown) => {
33  try {
34    return new URL(String(u)).host
35  } catch {
36    return ''
37  }
38}
39
40/** A tool call in words: verb and target ("Editing svg.ts", "Running npm test"). */
41export const describeTool = (e: { tool: string } & Record<string, any>): string => {
42  switch (e.tool) {
43    case 'Read':
44      return `Reading ${base(e.file_path)}`
45    case 'Edit':
46      return `Editing ${base(e.file_path)}`
47    case 'Write':
48      return `Writing ${base(e.file_path)}`
49    case 'NotebookEdit':
50      return `Editing ${base(e.notebook_path)}`
51    case 'Bash':
52    case 'PowerShell':
53      return `Running ${clip(String(e.command ?? '').split('\n')[0] ?? '', 40)}`
54    case 'Grep':
55      return `Searching for “${clip(String(e.pattern ?? ''), 28)}”`
56    case 'Glob':
57      return `Finding ${clip(String(e.pattern ?? ''), 32)}`
58    case 'WebFetch':
59      return `Reading ${host(e.url) || 'a web page'}`
60    case 'WebSearch':
61      return `Searching the web for “${clip(String(e.query ?? ''), 28)}”`
62    case 'Agent':
63    case 'Task':
64      return `Delegating: ${clip(String(e.description ?? 'a subtask'), 36)}`
65    case 'Skill':
66      return `Using ${String(e.skill ?? 'a skill')}`
67    case 'TodoWrite':
68    case 'TaskCreate':
69    case 'TaskUpdate':
70    case 'TaskList':
71    case 'TaskGet':
72      return 'Updating the plan'
73    default: {
74      const mcp = /^mcp__(.+?)__(.+)$/.exec(e.tool)
75      if (mcp) return `Using ${(mcp[2] ?? '').replace(/_/g, ' ')}`
76      return `Using ${e.tool}`
77    }
78  }
79}
80
81export type TaskCall =
82  | { tool: 'TodoWrite'; todos: { content: string; status: Task['status']; activeForm: string }[] }
83  | { tool: 'TaskCreate'; subject: string; activeForm?: string; createdId: string }
84  | { tool: 'TaskUpdate'; taskId: string; subject?: string; activeForm?: string; status?: Task['status'] | 'deleted' }
85
86export const taskCallOf = (e: { tool: string } & Record<string, any>, result: any): TaskCall | null => {
87  if (e.tool === 'TodoWrite' && Array.isArray(e.todos)) return { tool: 'TodoWrite', todos: e.todos }
88  if (e.tool === 'TaskCreate' && typeof result?.task?.id === 'string') {
89    return { tool: 'TaskCreate', subject: e.subject, activeForm: e.activeForm, createdId: result.task.id }
90  }
91  if (e.tool === 'TaskUpdate' && typeof e.taskId === 'string') {
92    return { tool: 'TaskUpdate', taskId: e.taskId, subject: e.subject, activeForm: e.activeForm, status: e.status }
93  }
94  return null
95}
96
97/** The task list after one task tool call. */
98export const applyTaskCall = (prev: Snap, call: TaskCall, now: number): Snap => {
99  const s: Snap = { ...IDLE, ...prev }
100  let tasks: Task[]
101  if (call.tool === 'TodoWrite') {
102    tasks = call.todos.map((t, i) => ({ id: `todo-${i}`, title: t.content, active: t.activeForm || null, status: t.status }))
103  } else if (call.tool === 'TaskCreate') {
104    const isFinished = s.tasks.length > 0 && s.tasks.every(t => t.status === 'completed')
105    tasks = [
106      ...(isFinished ? [] : s.tasks),
107      { id: call.createdId, title: call.subject, active: call.activeForm ?? null, status: 'pending' },
108    ]
109  } else {
110    tasks = s.tasks.flatMap(t => {
111      if (t.id !== call.taskId) return [t]
112      if (call.status === 'deleted') return []
113      return [{ ...t, title: call.subject ?? t.title, active: call.activeForm ?? t.active, status: call.status ?? t.status }]
114    })
115  }
116  const wasDone = new Set(s.tasks.filter(t => t.status === 'completed').map(t => t.id))
117  const justDone = tasks.find(t => t.status === 'completed' && !wasDone.has(t.id))
118  return {
119    ...s,
120    tasks,
121    taskDoneAt: justDone ? now : s.taskDoneAt,
122    taskDoneId: justDone ? justDone.id : s.taskDoneId,
123  }
124}
125
126export const fmtClock = (ms: number): string => {
127  const s = Math.floor(Math.max(0, ms) / 1000)
128  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
129}
130
131/** What the line says, in priority order: the task, then the tool doing it. */
132export type Words = {
133  /** the main words: the running task, else the tool action, else "Thinking" */
134  headline: string
135  /** the tool action under a running task, when there is one */
136  detail: string | null
137  /** "2/5" when there is a task list */
138  progress: string | null
139  done: number
140  total: number
141}
142
143export const words = (s: Snap): Words => {
144  const current = s.tasks.find(t => t.status === 'in_progress')
145  const done = s.tasks.filter(t => t.status === 'completed').length
146  const total = s.tasks.length
147  const task = current ? (current.active ?? current.title) : null
148  const action = s.action ?? 'Thinking'
149  return {
150    headline: task ?? action,
151    detail: task ? action : null,
152    progress: total > 0 ? `${done}/${total}` : null,
153    done,
154    total,
155  }
156}
157
158/** The whole line as plain text: the fallback drawing and the image's alt. */
159export const plainLine = (s: Snap, now: number): string => {
160  const w = words(s)
161  const failed = s.failed > 0 ? ` · ${s.failed} failed` : ''
162  const tasks = w.progress ? ` · ${w.progress} tasks` : ''
163  if (s.phase === 'working') {
164    return `${w.headline}${w.detail ? ` · ${w.detail}` : ''} · ${fmtClock(now - s.startedAt)}${tasks}${failed}`
165  }
166  return `${s.aborted ? 'Interrupted' : 'Done'} in ${fmtClock(s.durationMs)}${tasks}${failed}`
167}
168
hooks/svg.ts 197 lines
1import type { Snap } from '../types'
2import { LINGER_MS, fmtClock, plainLine, words } from './model'
3
4// Desktop band: one quiet row drawn as an SVG image whose motion is SMIL, so
5// it runs at the browser's frame rate. Each animation's `begin` is offset by
6// how long ago its moment was, so a redraw resumes motion instead of replaying it.
7
8const H = 34
9const MID = H / 2
10const BASE = MID + 4.5
11const ROLL_S = 0.28
12const ORANGE = '#D97757'
13const GREEN = '#3FB950'
14const RED = '#F2777B'
15
16const esc = (t: string) =>
17  t.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
18const n = (x: number) => Number(x.toFixed(2))
19const ago = (now: number, at: number) => n(-(Math.max(0, now - at) / 1000))
20const clip = (t: string, max: number) => (t.length > max ? `${t.slice(0, max - 1)}…` : t)
21
22/** Rough advance widths, enough to lay a row of system-ui text out. */
23const textW = (t: string, px: number) => t.length * px * 0.5
24const SANS = `system-ui,-apple-system,'Segoe UI',sans-serif`
25const MONO = `ui-monospace,'SF Mono','Cascadia Mono',Consolas,monospace`
26
27const STYLE = `
28  svg{--fg:#ECEAE6;--dim:#8F8C86;--track:rgba(255,255,255,.1)}
29  @media (prefers-color-scheme: light){svg{--fg:#1F1E1C;--dim:#6E6B65;--track:rgba(0,0,0,.1)}}
30  .h{font:500 13px ${SANS};fill:var(--fg)}
31  .d{font:400 12.5px ${SANS};fill:var(--dim)}
32  .m{font:500 12px ${MONO};fill:var(--dim)}`
33
34/** One odometer column: digits 0..count-1 then 0 again, rolling every `stepS` seconds. */
35const odoColumn = (x: number, count: number, stepS: number, elapsedS: number, id: string) => {
36  const lh = 14
37  const r = Math.min(ROLL_S / stepS, 0.4)
38  const times: number[] = [0]
39  const values: number[] = [0]
40  const splines: string[] = []
41  for (let i = 0; i < count; i++) {
42    times.push((i + 1 - r) / count, (i + 1) / count)
43    values.push(-i * lh, -(i + 1) * lh)
44    splines.push('0 0 1 1', '0.5 0 0.2 1')
45  }
46  const digits = Array.from({ length: count + 1 }, (_, i) =>
47    `<text class="m" x="${x}" y="${BASE - 0.5 + i * lh}">${i % count}</text>`).join('')
48  return `<clipPath id="${id}"><rect x="${x - 1}" y="${MID - 7}" width="9" height="14"/></clipPath>
49    <g clip-path="url(#${id})"><g>
50      <animateTransform attributeName="transform" type="translate" calcMode="spline"
51        values="${values.map(v => `0 ${n(v)}`).join(';')}" keyTimes="${times.map(n).join(';')}"
52        keySplines="${splines.join(';')}" dur="${count * stepS}s" begin="${n(-elapsedS)}s" repeatCount="indefinite"/>
53      ${digits}</g></g>`
54}
55
56/** m:ss that rolls by itself; returns its width. */
57const timer = (x: number, elapsedS: number) => {
58  const cw = 7.3
59  let svg = ''
60  let cx = x
61  if (elapsedS >= 600) {
62    svg += odoColumn(cx, 6, 600, elapsedS, 'o0')
63    cx += cw
64  }
65  svg += odoColumn(cx, 10, 60, elapsedS, 'o1')
66  svg += `<text class="m" x="${n(cx + cw)}" y="${BASE - 0.5}">:</text>`
67  svg += odoColumn(cx + cw * 2, 6, 10, elapsedS, 'o2') + odoColumn(cx + cw * 3, 10, 1, elapsedS, 'o3')
68  return { svg, w: cx + cw * 4 - x }
69}
70
71const spinner = (x: number) => `
72  <g transform="translate(${x} ${MID})">
73    <circle r="2.6" fill="${ORANGE}">
74      <animate attributeName="opacity" values="1;.45;1" dur="1.6s" repeatCount="indefinite"/>
75    </circle>
76    <circle r="6.5" fill="none" stroke="${ORANGE}" stroke-width="1.6" stroke-linecap="round" stroke-dasharray="10 31" opacity=".9">
77      <animateTransform attributeName="transform" type="rotate" values="0;360" dur="1s" repeatCount="indefinite"/>
78    </circle>
79  </g>`
80
81const check = (x: number, s: Snap, now: number) => {
82  if (s.aborted) return `<rect x="${x - 4.5}" y="${MID - 4.5}" width="9" height="9" rx="2" fill="#E5A84D"/>`
83  const tick = `<path d="M-3.6 0.2 L-1.1 2.7 L3.8 -2.5" fill="none" stroke="#0D1117" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"`
84  if (now - s.doneAt > 900) return `<g transform="translate(${x} ${MID})"><circle r="8" fill="${GREEN}"/>${tick}/></g>`
85  const b = ago(now, s.doneAt)
86  return `<g transform="translate(${x} ${MID})">
87    <circle r="0" fill="${GREEN}">
88      <animate attributeName="r" values="0;9.5;8" keyTimes="0;.6;1" dur=".42s" begin="${b}s" fill="freeze" calcMode="spline" keySplines="0.2 0.8 0.2 1;0.4 0 0.6 1"/>
89    </circle>
90    ${tick} stroke-dasharray="13" stroke-dashoffset="13">
91      <animate attributeName="stroke-dashoffset" values="13;0" dur=".28s" begin="${n(b + 0.28)}s" fill="freeze" calcMode="spline" keySplines="0.4 0 0.2 1"/>
92    </path>
93  </g>`
94}
95
96/** A small segmented bar with the count after it; returns its width. */
97const progress = (x: number, s: Snap, now: number) => {
98  const { done, total } = words(s)
99  const barW = Math.min(84, Math.max(36, total * 14))
100  const gap = total > 12 ? 0 : 2.5
101  const segW = (barW - (total - 1) * gap) / total
102  let svg = ''
103  if (gap === 0) {
104    svg += `<rect x="${x}" y="${MID - 2}" width="${barW}" height="4" rx="2" style="fill:var(--track)"/>
105      <rect x="${x}" y="${MID - 2}" width="${n((barW * done) / total)}" height="4" rx="2" fill="${GREEN}"/>`
106  } else {
107    s.tasks.forEach((t, i) => {
108      const sx = n(x + i * (segW + gap))
109      const w = n(segW)
110      svg += `<rect x="${sx}" y="${MID - 2}" width="${w}" height="4" rx="2" style="fill:var(--track)"/>`
111      if (t.status === 'completed') {
112        const grow = t.id === s.taskDoneId && now - s.taskDoneAt < 700
113          ? `<animate attributeName="width" values="0;${w}" dur=".45s" begin="${ago(now, s.taskDoneAt)}s" fill="freeze" calcMode="spline" keySplines="0.2 0.8 0.2 1"/>`
114          : ''
115        svg += `<rect x="${sx}" y="${MID - 2}" width="${w}" height="4" rx="2" fill="${GREEN}">${grow}</rect>`
116      } else if (t.status === 'in_progress') {
117        svg += `<rect x="${sx}" y="${MID - 2}" width="${w}" height="4" rx="2" fill="${ORANGE}">
118          <animate attributeName="opacity" values=".35;1;.35" dur="1.6s" repeatCount="indefinite"/></rect>`
119      }
120    })
121  }
122  const count = `${done}/${total}`
123  svg += `<text class="m" x="${n(x + barW + 7)}" y="${BASE - 0.5}">${count}</text>`
124  return { svg, w: barW + 7 + count.length * 7.3 }
125}
126
127const failures = (x: number, s: Snap, now: number) => {
128  const label = `${s.failed} failed`
129  const fresh = now - s.lastFailAt < 1200
130  const ring = fresh
131    ? `<circle cx="${x + 3}" cy="${MID}" r="3" fill="none" stroke="${RED}" stroke-width="1.5">
132        <animate attributeName="r" values="3;10" dur=".9s" begin="${ago(now, s.lastFailAt)}s" fill="freeze" calcMode="spline" keySplines="0.2 0.8 0.2 1"/>
133        <animate attributeName="opacity" values="1;0" dur=".9s" begin="${ago(now, s.lastFailAt)}s" fill="freeze"/>
134      </circle>`
135    : ''
136  return {
137    svg: `${ring}<circle cx="${x + 3}" cy="${MID}" r="3" fill="${RED}"/>
138      <text x="${x + 11}" y="${BASE}" style="font:500 12.5px ${SANS};fill:${RED}">${label}</text>`,
139    w: 11 + textW(label, 12.5),
140  }
141}
142
143const SEP = 16
144
145const wrap = (w: number, body: string, alt: string) =>
146  `<svg xmlns="http://www.w3.org/2000/svg" width="${Math.ceil(w)}" height="${H}" viewBox="0 0 ${Math.ceil(w)} ${H}" role="img" aria-label="${esc(alt)}"><style>${STYLE}</style>${body}</svg>`
147
148export const bandSvg = (s: Snap, now: number): { source: string; alt: string; width: number; height: number } => {
149  const alt = plainLine(s, now)
150  const w = words(s)
151  let x = 14
152  let body = ''
153
154  if (s.phase === 'working') {
155    body += spinner(x)
156    x += 16
157    const headline = clip(w.headline, 56)
158    const detail = w.detail ? clip(w.detail, 40) : ''
159    // New words ease in, so a change of task or tool reads as motion, not a flicker.
160    const enter = now - s.actionAt < 400
161      ? `<animate attributeName="opacity" values="0;1" dur=".25s" begin="${ago(now, s.actionAt)}s" fill="freeze"/>`
162      : ''
163    body += `<text x="${x}" y="${BASE}" xml:space="preserve">${enter}<tspan class="h">${esc(headline)}</tspan>${detail ? `<tspan class="d">   ${esc(detail)}</tspan>` : ''}</text>`
164    x += textW(headline, 13) + (detail ? textW(`   ${detail}`, 12.5) * 0.92 : 0) + SEP
165    const t = timer(x, (now - s.startedAt) / 1000)
166    body += t.svg
167    x += t.w + SEP
168  } else {
169    body += check(x, s, now)
170    x += 16
171    const label = s.aborted ? 'Interrupted' : 'Done'
172    const time = ` in ${fmtClock(s.durationMs)}`
173    body += `<text x="${x}" y="${BASE}" xml:space="preserve"><tspan class="h">${label}</tspan><tspan class="d">${time}</tspan></text>`
174    x += textW(label, 13) + textW(time, 12.5) + SEP
175  }
176
177  if (w.total > 0) {
178    const p = progress(x, s, now)
179    body += p.svg
180    x += p.w + SEP
181  }
182  if (s.failed > 0) {
183    const f = failures(x, s, now)
184    body += f.svg
185    x += f.w + SEP
186  }
187
188  if (s.phase === 'done') {
189    // Fade out just before the band is taken down.
190    const out = n(ago(now, s.doneAt) + LINGER_MS / 1000 - 0.6)
191    body = `<g><animate attributeName="opacity" values="1;0" dur=".6s" begin="${out}s" fill="freeze"/>${body}</g>`
192  }
193
194  const width = Math.ceil(x - SEP + 14)
195  return { source: wrap(width, body, alt), alt, width, height: H }
196}
197
hooks/copies.ts 30 lines
1// Spotting a second copy of this mod loaded in the same chat (the same check recap makes).
2
3/** A copy of the mod announcing itself in a chat. */
4export type Instance = { session: string; token: string; root: string; at: number }
5
6const DAY = 86_400_000
7
8/** Where the copy registry lives. */
9export const registryPath = (home: string) => `${home.replace(/\\/g, '/').replace(/\/+$/, '')}/.claude/turn-status/instances.json`
10
11/** The copy registry with this copy added and day-old entries dropped. */
12export const withInstance = (raw: string, me: Instance): Instance[] => {
13  let list: Instance[] = []
14  try {
15    const parsed = JSON.parse(raw)
16    if (Array.isArray(parsed)) list = parsed.filter(i => i && typeof i.token === 'string')
17  } catch {
18    list = []
19  }
20  return [...list.filter(i => i.token !== me.token && me.at - i.at < DAY), me]
21}
22
23/** Other copies that started in the same chat at the same moment: a second copy of the mod. */
24export const otherCopies = (list: readonly Instance[], me: Instance, windowMs = 5000): Instance[] =>
25  list.filter(i => i.session === me.session && i.token !== me.token && Math.abs(i.at - me.at) < windowMs)
26
27export const duplicateWarning = (me: Instance, others: readonly Instance[]) =>
28  `⚠ turn-status is loaded twice in this chat (${[me, ...others].map(i => i.root).join(' and ')}). `
29  + 'Two copies can clash; remove one from your plugins and restart the chat.'
30
hooks/motion.tsx 88 lines
1import type { ClientModule } from 'claude-code'
2
3import type { Snap } from '../types'
4import { fmtClock, words } from './model'
5
6// Terminal band: the same one quiet row, drawn in cells. The frame clock runs
7// only while a turn does (spinner and timer); a finished line is static.
8
9const TICK_MS = 100
10const SPIN = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
11
12export const clip = (t: string, max: number) => (t.length > max ? `${t.slice(0, max - 1)}…` : t)
13
14/** One block per task (at most 16), for the progress bar. */
15export const blocks = (s: Snap, frame: number): ('done' | 'on' | 'off' | 'todo')[] =>
16  s.tasks.slice(0, 16).map(t =>
17    t.status === 'completed' ? 'done' : t.status === 'in_progress' ? (frame % 10 < 6 ? 'on' : 'off') : 'todo')
18
19type Local = { frame: number; startedAt: number; turnId: string; stop?: () => void }
20
21const TurnBand: ClientModule<Snap, Local> = (props, surface) => {
22  const { Box, Text } = surface.elements
23  let L = surface.state
24  if (L === undefined) {
25    L = { frame: 0, startedAt: Date.now(), turnId: props.turnId }
26    surface.setState(L)
27  }
28  const s = L
29  if (props.turnId !== s.turnId) {
30    s.turnId = props.turnId
31    s.startedAt = Date.now()
32  }
33
34  const isWorking = props.phase === 'working'
35  if (isWorking && s.stop === undefined) {
36    s.stop = surface.every(TICK_MS, () => {
37      s.frame++
38      surface.setState(s)
39    })
40  } else if (!isWorking && s.stop !== undefined) {
41    s.stop()
42    s.stop = undefined
43  }
44
45  const w = words(props)
46  const cols = surface.columns || 100
47  const bar = w.total > 0 ? (
48    <>
49      <Text>   </Text>
50      {blocks(props, s.frame).map((b, i) => (
51        <Text key={`b${i}`} color={b === 'done' ? 'success' : b === 'todo' ? 'subtle' : 'claude'} dimColor={b === 'off'}>
52          {b === 'todo' ? '▱' : '▰'}
53        </Text>
54      ))}
55      <Text dimColor> {w.progress}</Text>
56    </>
57  ) : null
58  const fail = props.failed > 0 ? <Text color="error">   ● {props.failed} failed</Text> : null
59
60  if (isWorking) {
61    const room = Math.max(16, cols - 30)
62    const headline = clip(w.headline, Math.min(56, room))
63    const detail = w.detail && cols >= 90 ? clip(w.detail, 40) : null
64    return (
65      <Box>
66        <Text color="claude">{SPIN[s.frame % SPIN.length]} </Text>
67        <Text bold>{headline}</Text>
68        {detail ? <Text dimColor>   {detail}</Text> : null}
69        <Text dimColor>   {fmtClock(Date.now() - s.startedAt)}</Text>
70        {bar}
71        {fail}
72      </Box>
73    )
74  }
75
76  return (
77    <Box>
78      <Text color={props.aborted ? 'warning' : 'success'}>{props.aborted ? '■' : '✓'} </Text>
79      <Text bold>{props.aborted ? 'Interrupted' : 'Done'}</Text>
80      <Text dimColor> in {fmtClock(props.durationMs)}</Text>
81      {bar}
82      {fail}
83    </Box>
84  )
85}
86
87export default TurnBand
88
types/index.d.ts 33 lines
1export type Task = {
2  id: string
3  title: string
4  /** present-continuous form shown while it runs ("Running tests") */
5  active: string | null
6  status: 'pending' | 'in_progress' | 'completed'
7}
8
9export type Snap = {
10  phase: 'idle' | 'working' | 'done'
11  turnId: string
12  /** what the running tool is doing ("Editing svg.ts"); null between tools */
13  action: string | null
14  failed: number
15  durationMs: number
16  aborted: boolean
17  /** epoch ms: turn start, turn end, last action change, last failure */
18  startedAt: number
19  doneAt: number
20  actionAt: number
21  lastFailAt: number
22  /** Claude's task list (TodoWrite / TaskCreate / TaskUpdate), kept across turns */
23  tasks: Task[]
24  taskDoneAt: number
25  taskDoneId: string
26}
27
28declare module 'claude-code' {
29  interface PluginState {
30    'turn-status': { snap: Snap }
31  }
32}
33