SLOPSHOPPER

super-bar

Claude Code's task list as a progress bar above the prompt: a pixel track in the Desktop app, dotted text in the terminal.

newbandguardcommand
v0.3.0NOASSERTIONupdated 2026-10-02RakshitSathvara/rakshit-mods/super-bar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · super-bar
› 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 › /taskbar ○ Tasks off set CLAUDE_CODE_ENABLE_TODO_TOOLS=1 and restart Claude Code × ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
○ Tasks off set CLAUDE_CODE_ENABLE_TODO_TOOLS=1 and restart Claude Code × ⟨Claude Code's own drawing⟩
README

super-bar

A Claude Code mod that draws the session's task list as a progress bar above the prompt: the task Claude is on, a track that fills as tasks finish with a pill naming the count, and the share done.

● Running tests      ⣿⣿⣿⣿⣿⣿│⣿⣿⣿⣿⣿⣿ Tasks 3/5 ⣿⣿⣿⣿│⣿⣿⣿⣿⣿⣿   40% ×

The pill names the task Claude is on and the figure is the share done, so two of five done reads Tasks 3/5 and 40%. The Desktop app draws the track as a pixel bar; the terminal draws it as dotted text, as above. Once every task in the batch is done the bar goes away, until Claude starts new tasks.

Before you start

  • Claude Code 2.1.287 or later (claude --version).
  • The task tools. Claude Code only provides them by default on older models (Opus 4–4.7, Sonnet 4–4.6, Haiku 4.5). On newer ones, turn them on in ~/.claude/settings.json, which covers the terminal and the Desktop app alike:
  { "env": { "CLAUDE_CODE_ENABLE_TODO_TOOLS": "1" } }

Merge the env key into your existing settings rather than replacing the file. Without the tools the bar says "Tasks off". The tool definitions take some context of their own; /context shows how much.

Try it

claude --plugin-dir ./super-bar

The folder is watched, so saving a file reloads the mod in place.

Install it for every session

claude plugin marketplace add RakshitSathvara/rakshit-mods
claude plugin install super-bar@rakshit-mods --scope user

Run /reload-plugins in any open session. When a new version comes out, run claude plugin marketplace update rakshit-mods, then claude plugin update super-bar@rakshit-mods.

Use it

  • × hides the bar until Claude creates the next task.
  • /taskbar shows a hidden bar, /taskbar hide hides it.

What it reaches

claude plugin validate ./super-bar lists every call: $.command.register, $.session.messages, $.state, $.tool.list and $.ui.resolve. It reads no files, starts no processes, makes no network requests and calls no model, so it spends no tokens.

Limits

  • After /resume the tasks are rebuilt from the transcript's newest 4,096 entries.
  • A task call from a subagent can't be told apart from one in the main conversation, so it counts too.

Develop

claude plugin validate ./super-bar
claude plugin test ./super-bar

hooks/tasks-bar.mjs handles the events, keeps state and lays out the band. hooks/track.mjs draws the Desktop app's pixel track as SVG, and hooks/bar.mjs the terminal's dotted bar. Claude Code writes type declarations for your build into .claude-plugin/types/ the first time it loads the mod.

Credits

The Desktop app's pixel track is adapted from plan-progress by Kirill Serditov, under the MIT License; LICENSE carries its notice.

Source 4 files
hooks/tasks-bar.mjs 281 lines
1// Super Bar: Claude Code's task list as a progress bar above the prompt.
2//
3// One bar for the batch of tasks Claude is working through: the task it's on, a
4// track that fills as tasks finish with a pill naming the count, and the share
5// done. It goes once every task in the batch is done. The desktop draws the
6// track as an SVG (track.mjs), the terminal as dotted text (bar.mjs). × hides
7// the bar until Claude creates the next task; /taskbar brings it back.
8//
9// Needs the task tools, which Claude Code leaves out on newer models unless you
10// start it with CLAUDE_CODE_ENABLE_TODO_TOOLS=1. Without them the bar says so.
11
12import { atom, read, update } from 'claude-code'
13import { textRow } from './bar.mjs'
14import { TRACK_H, textWidth, trackSvg } from './track.mjs'
15
16// The desktop track's accent, and the terminal bar's palette in the same violet.
17const ACCENT = '#8B7CF6'
18const VIOLET = { mark: '#a99cf7', fill: '#9a8af4', pillBg: '#7d6cf0', pillFg: '#ffffff', tick: '#e4dffd' }
19
20const TASK_TOOLS = ['TaskCreate', 'TaskUpdate', 'TodoWrite']
21const STATUSES = ['pending', 'in_progress', 'completed']
22const CLOSE = 2 // "×" and the gap before it, in cells
23// A space as wide as a digit, so '  0%' and '100%' take the same room.
24const FIGURE_SPACE = String.fromCharCode(0x2007)
25
26// Held by the host, so they survive a hot reload of this file.
27const board = atom({ plugin: 'super-bar', key: 'board' }, { tasks: [], batch: 1, toolsOn: null })
28const view = atom({ plugin: 'super-bar', key: 'view' }, { hidden: false })
29
30export function register(on) {
31  on('session.start', async ($, e, next) => {
32    const result = await next(e)
33    await checkTools($)
34    if ((await read($, board)).tasks.length === 0) await replay($) // claude --resume / --continue
35    try {
36      await $.command.register({
37        name: 'taskbar',
38        description: 'Show the task bar again, or hide it',
39        argumentHint: '[hide]',
40        immediate: true,
41      })
42    } catch {
43      // The name is taken: the bar still works, only the command is missing.
44    }
45    return result
46  })
47
48  // /clear, /resume and /branch put $.state back to its defaults.
49  on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
50    const result = await next(e)
51    await checkTools($)
52    if (e.source !== 'clear') await replay($)
53    return result
54  })
55
56  // Watch the task tools: let each call run, then fold its result into the board.
57  on('tool.call', { tool: ['TaskCreate', 'TaskUpdate', 'TodoWrite'] }, async ($, e, next) => {
58    const r = await next(e)
59    try {
60      if (r && !r.deny && !r.isError && r.result != null) {
61        const { tool, tool_use_id, ...input } = e
62        await update($, board, (b) => reduce({ ...b, toolsOn: true }, tool, input, r.result))
63        if (tool === 'TaskCreate') await update($, view, (v) => (v.hidden ? { ...v, hidden: false } : v))
64      }
65    } catch {
66      // Never let the bar get in the way of the tool's result.
67    }
68    return r
69  })
70
71  // /model may have changed which tools exist.
72  on('turn.complete', async ($, e, next) => {
73    const result = await next(e)
74    if (!e.agentId) await checkTools($) // main-loop turns only, not subagents
75    return result
76  })
77
78  on('command.run', { command: 'taskbar' }, async ($, e) => {
79    const hidden = String(e.args ?? '').trim().toLowerCase() === 'hide'
80    await update($, view, (v) => ({ ...v, hidden }))
81    return {}
82  })
83
84  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
85    if (e.props.hasSurvey) return next(e)
86    if ((await read($, view)).hidden) return next(e)
87    const b = await read($, board)
88    const tasks = b.tasks.filter((t) => t.batch === b.batch)
89    // Nothing to show before Claude's first task (unless the tools are off), or
90    // once every task in the batch is done.
91    if (tasks.length === 0 && b.toolsOn !== false) return next(e)
92    if (tasks.length > 0 && tasks.every((t) => t.status === 'completed')) return next(e)
93    const theirs = await next(e) // keep what mods after this one draw in the band
94    return band($.ui.resolve(e), e, tasks, b.batch, theirs, $)
95  })
96}
97
98// ── Tasks ───────────────────────────────────────────────────────────────────
99
100// Fold one answered task-tool call into the board. Tasks created after every
101// task of the current batch is done start a new batch.
102function reduce(b, tool, input, result) {
103  const tasks = b.tasks.slice()
104  let batch = b.batch
105  const current = () => tasks.filter((t) => t.batch === batch)
106  const allDone = (list) => list.length > 0 && list.every((t) => t.status === 'completed')
107
108  if (tool === 'TaskCreate') {
109    if (allDone(current())) batch += 1
110    const id = String(result?.task?.id ?? nextId(tasks))
111    const subject = String(input.subject ?? result?.task?.subject ?? 'Task')
112    tasks.push({ id, subject, activeForm: text(input.activeForm), status: 'pending', batch })
113    return { ...b, tasks, batch }
114  }
115
116  if (tool === 'TaskUpdate') {
117    if (result?.success === false) return b
118    const i = tasks.findIndex((t) => t.id === String(input.taskId ?? result?.taskId ?? ''))
119    if (i < 0) return b
120    if (input.status === 'deleted') {
121      tasks.splice(i, 1)
122      return { ...b, tasks }
123    }
124    const t = { ...tasks[i] }
125    if (STATUSES.includes(input.status)) t.status = input.status
126    if (typeof input.subject === 'string') t.subject = input.subject
127    if (typeof input.activeForm === 'string') t.activeForm = input.activeForm
128    tasks[i] = t
129    return { ...b, tasks }
130  }
131
132  if (tool === 'TodoWrite') {
133    const todos = Array.isArray(result?.newTodos) ? result.newTodos : Array.isArray(input.todos) ? input.todos : null
134    if (!todos) return b
135    const known = new Set(current().map((t) => t.subject))
136    if (allDone(current()) && todos.some((t) => !known.has(String(t.content)))) batch += 1
137    const list = todos.map((t, k) => ({
138      id: `todo-${batch}-${k + 1}`,
139      subject: String(t.content ?? ''),
140      activeForm: text(t.activeForm),
141      status: STATUSES.includes(t.status) ? t.status : 'pending',
142      batch,
143    }))
144    return { ...b, tasks: [...tasks.filter((t) => t.batch !== batch), ...list], batch }
145  }
146
147  return b
148}
149
150// Rebuild the board from the transcript, after a resume or a fresh load.
151async function replay($) {
152  let messages
153  try {
154    messages = await $.session.messages()
155  } catch {
156    return
157  }
158  let b = { tasks: [], batch: 1, toolsOn: true }
159  let seen = false
160  for (const m of messages ?? []) {
161    for (const use of m?.toolUses ?? []) {
162      if (!TASK_TOOLS.includes(use.tool) || use.isError || use.result == null) continue
163      b = reduce(b, use.tool, use.input ?? {}, use.result)
164      seen = true
165    }
166  }
167  if (seen) await update($, board, () => b)
168}
169
170async function checkTools($) {
171  let on
172  try {
173    const tools = await $.tool.list()
174    on = tools.some((t) => t?.name === 'TaskCreate' || t?.name === 'TodoWrite')
175  } catch {
176    return
177  }
178  if ((await read($, board)).toolsOn !== on) await update($, board, (b) => ({ ...b, toolsOn: on }))
179}
180
181async function hide($) {
182  await update($, view, (x) => ({ ...x, hidden: true }))
183}
184
185// ── Drawing ─────────────────────────────────────────────────────────────────
186
187function band(ui, e, tasks, batch, theirs, $) {
188  const { Box, Button } = ui
189  // The band's padding takes a cell on each side, and the row stays two cells
190  // short of the edge, where the terminal draws its close mark.
191  const width = Math.max(24, Number(e.props.bodyColumns) || 80) - 4
192  const p = tasks.length > 0 ? progress(tasks) : null
193  const close = Button({ key: 'tb-close', label: '×', plain: true, role: 'dismiss', dimColor: true, onPress: () => hide($) })
194  // Every surface but the terminal draws Svg, so they get the pixel track and the
195  // terminal gets text. Ask the surface: the table holds every constructor.
196  const row =
197    p && e.surface !== 'terminal'
198      ? Box({ key: 'tb-active', flexDirection: 'row', alignItems: 'center', gap: 1, children: [...trackRow(ui, e, p, batch), close] })
199      : Box({
200          key: 'tb-active',
201          flexDirection: 'row',
202          width,
203          columnGap: 1,
204          children: [Box({ width: width - CLOSE, children: [textRow(ui, p ? textProps(p, width) : toolsOff(), width - CLOSE)] }), close],
205        })
206  return Box({ flexDirection: 'column', paddingX: 1, children: [row, theirs] })
207}
208
209// One batch as the bar shows it. The pill names the task you're on and the
210// figure is the share done: 2 of 5 done reads "Tasks 3/5" and 40%.
211function progress(tasks) {
212  const n = tasks.length
213  const done = tasks.filter((t) => t.status === 'completed').length
214  const cur =
215    tasks.find((t) => t.status === 'in_progress') ?? tasks.find((t) => t.status === 'pending') ?? tasks[0]
216  return {
217    n,
218    done,
219    at: Math.min(done + 1, n),
220    pct: Math.round((done / n) * 100),
221    label: cur.status === 'in_progress' && cur.activeForm ? cur.activeForm : cur.subject,
222  }
223}
224
225// The desktop row, as plan-progress lays it out: the state, the task, then the
226// track pinned right at a width that fits, and the share done. The desktop
227// reports about 8 CSS pixels per column.
228function trackRow({ Box, Text, Svg }, e, p, batch) {
229  const total = Math.max(320, (Number(e.props.bodyColumns) || 100) * 8)
230  const titleW = Math.min(Math.round(total * 0.3), Math.round(textWidth(p.label, 6.4)))
231  const trackW = Math.max(120, Math.min(1400, total - titleW - 140))
232  const alt = `${p.label}: task ${p.at} of ${p.n}, ${p.pct}% done`
233  const source = trackSvg({ id: String(batch), total: p.n, finished: p.done, color: ACCENT }, trackW)
234  return [
235    Text({ color: ACCENT, children: ['●'] }),
236    Text({ wrap: 'truncate', children: [p.label] }),
237    Box({ flexGrow: 1 }),
238    Svg({ source, alt, width: trackW, height: TRACK_H }),
239    Text({ dimColor: true, children: [`${String(p.pct).padStart(3, FIGURE_SPACE)}%`] }),
240  ]
241}
242
243// The terminal row's props for bar.mjs.
244function textProps(p, width) {
245  return {
246    mark: '●',
247    markColor: VIOLET.mark,
248    label: p.label,
249    labelWidth: width >= 70 ? Math.min(32, Math.round(width * 0.3)) : width >= 50 ? 14 : 0,
250    fraction: p.done / p.n,
251    ticks: Array.from({ length: p.n - 1 }, (_, k) => (k + 1) / p.n),
252    pill: `Tasks ${p.at}/${p.n}`,
253    pillShort: `${p.at}/${p.n}`,
254    fill: VIOLET.fill,
255    pillBg: VIOLET.pillBg,
256    pillFg: VIOLET.pillFg,
257    tickColor: VIOLET.tick,
258    right: `${p.pct}%`,
259    rightWidth: 4,
260  }
261}
262
263function toolsOff() {
264  return {
265    mark: '○',
266    markColor: 'gray',
267    label: 'Tasks off',
268    labelDim: true,
269    hint: 'set CLAUDE_CODE_ENABLE_TODO_TOOLS=1 and restart Claude Code',
270    right: '',
271  }
272}
273
274function nextId(tasks) {
275  return tasks.reduce((max, t) => Math.max(max, Number(t.id) || 0), 0) + 1
276}
277
278function text(value) {
279  return typeof value === 'string' && value.trim() ? value : null
280}
281
hooks/bar.mjs 123 lines
1// Super Bar's terminal bar.
2//
3// Draws one dotted progress row: a mark, a label, a braille-dot bar with tick
4// marks and a pill riding the fill's edge, then a figure on the right. The
5// hooks module draws it straight into the band with its own element table;
6// the desktop draws track.mjs's SVG instead.
7
8const DOT = '⣿'
9const TICK = '│'
10const TRACK = 'gray'
11
12// The row as Text runs in a Box, sized to `cols` cells.
13export function textRow({ Box, Text }, props, cols) {
14  return Box({ flexDirection: 'row', children: runs(Text, layout(props ?? {}, Math.max(8, cols))) })
15}
16
17// The row as styled segments, left to right, sized to `cols` cells.
18function layout(p, cols) {
19  const segs = []
20  const add = (text, style = {}) => {
21    if (text) segs.push({ text, ...style })
22  }
23
24  const right = String(p.right ?? '')
25  const rightW = Math.max(Number(p.rightWidth) || 0, right.length)
26  const tail = rightW > 0 ? rightW + 1 : 0
27  let pill = ` ${String(p.pill ?? '')} `
28  const spare = cols - 2 - tail // what's left after the mark and its space
29  const minTrack = pill.length + 8
30
31  // Give the bar room first: shorten the label, then drop it. A hint row keeps
32  // its short label only while the whole message fits beside it.
33  let labelW = Math.max(0, Number(p.labelWidth) || 0)
34  if (p.hint) {
35    const own = Array.from(String(p.label ?? '')).length
36    labelW = spare - (own + 1) >= Array.from(String(p.hint)).length ? own : 0
37  } else {
38    if (labelW > 0 && spare - (labelW + 1) < minTrack) labelW = spare - 1 - minTrack
39    if (labelW < 8) labelW = 0
40  }
41  const track = Math.max(0, spare - (labelW > 0 ? labelW + 1 : 0))
42  if (track < pill.length + 4 && p.pillShort) pill = ` ${String(p.pillShort)} `
43
44  add(`${String(p.mark ?? ' ')} `, { color: p.markColor })
45  if (labelW > 0) {
46    const text = fit(String(p.label ?? ''), labelW)
47    add(text, { color: p.labelColor, dim: Boolean(p.labelDim) })
48    add(' '.repeat(labelW - Array.from(text).length + 1))
49  }
50  if (p.hint) add(fit(String(p.hint), track).padEnd(track), { color: TRACK })
51  else bar(segs, p, track, pill)
52  if (rightW > 0) add(` ${right.padStart(rightW)}`, { color: p.rightColor, dim: !p.rightColor })
53  return segs
54}
55
56// Dots up to the fill's edge, dim dots after it, ticks at the boundaries, and the
57// pill centred on the edge so it sits where the work is (clamped at both ends).
58function bar(segs, p, T, pill) {
59  if (T <= 0) return
60  const P = Math.min(pill.length, T)
61  const filled = Math.round(clamp01(p.fraction) * T)
62  const start = Math.max(0, Math.min(T - P, Math.round(filled - P / 2)))
63  const ticks = tickCells(p.ticks, T)
64  for (let i = 0; i < T; i++) {
65    if (i === start) {
66      segs.push({ text: pill.slice(0, P), color: p.pillFg, bg: p.pillBg, bold: true })
67      i += P - 1
68      continue
69    }
70    const lit = i < filled
71    if (ticks.has(i)) segs.push({ text: TICK, color: lit ? p.tickColor : TRACK, dim: !lit })
72    else if (lit) segs.push({ text: DOT, color: p.fill, dim: speckle(i) })
73    else segs.push({ text: DOT, color: TRACK, dim: true })
74  }
75}
76
77// Tick positions in cells, or none when they'd crowd the bar.
78function tickCells(fractions, T) {
79  const out = new Set()
80  if (!Array.isArray(fractions) || fractions.length === 0) return out
81  if (T / (fractions.length + 1) < 3) return out
82  for (const f of fractions) {
83    const i = Math.round(f * T)
84    if (i > 0 && i < T) out.add(i)
85  }
86  return out
87}
88
89// About one lit dot in five drawn dim, the same ones every time: the dot-matrix texture.
90function speckle(i) {
91  return ((i * 2654435761) >>> 0) % 5 === 0
92}
93
94function clamp01(n) {
95  const x = Number(n)
96  return Number.isFinite(x) ? Math.min(1, Math.max(0, x)) : 0
97}
98
99function fit(text, w) {
100  const chars = Array.from(text.replace(/\s+/g, ' ').trim())
101  if (chars.length <= w) return chars.join('')
102  return `${chars.slice(0, Math.max(0, w - 1)).join('')}…`
103}
104
105// Adjacent segments with the same style become one Text.
106function runs(Text, segs) {
107  const merged = []
108  for (const s of segs) {
109    const key = `${s.color ?? ''}|${s.bg ?? ''}|${s.dim ? 1 : 0}|${s.bold ? 1 : 0}`
110    const last = merged[merged.length - 1]
111    if (last && last.key === key) last.text += s.text
112    else merged.push({ ...s, key })
113  }
114  return merged.map((s) => {
115    const props = { children: [s.text] }
116    if (s.color) props.color = s.color
117    if (s.bg) props.backgroundColor = s.bg
118    if (s.dim) props.dimColor = true
119    if (s.bold) props.bold = true
120    return Text(props)
121  })
122}
123
hooks/track.mjs 113 lines
1// Super Bar's desktop track.
2//
3// The batch as an SVG the band draws as an image, so it needs no Client: a
4// pixel fill that thickens towards its edge, a tick between tasks, and a pill
5// riding the edge with the count. Adapted from plan-progress by Kirill
6// Serditov (https://github.com/zycck/claude-mods), MIT License; see LICENSE.
7
8export const TRACK_H = 22
9const NARROW = 360 // below this the pill becomes a dot with the task number
10
11const hex = (h) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16))
12const mix = (a, b, m) => a.map((v, i) => Math.round(v + ((b[i] ?? 0) - v) * m))
13const rgb = (c) => `rgb(${c.join(',')})`
14const hash = (a, b, k) => {
15  const x = Math.sin(a * 127.1 + b * 311.7 + k * 74.7) * 43758.5453
16  return x - Math.floor(x)
17}
18
19// A string's rough width in CSS pixels, in the desktop's 12px sans.
20export const textWidth = (s, px = 6.7) =>
21  [...s].reduce(
22    (w, ch) =>
23      w +
24      (/[ -鿿]/.test(ch) ? 12 : /[ilI.,:;'|!]/.test(ch) ? 3.4 : /[mwMWЖМШЩ]/.test(ch) ? 9.5 : px),
25    0,
26  )
27
28// Where each batch's head was last drawn, so a redraw glides from there.
29const lastHead = new Map()
30
31// One batch, W pixels wide: { id, total, finished, color }.
32export function trackSvg(t, W) {
33  const H = TRACK_H
34  const total = Math.max(1, t.total)
35  // The fill is exactly the finished share: a fresh batch starts empty.
36  const fx = Math.min(1, t.finished / total) * W
37  const from = lastHead.get(t.id) ?? fx
38  lastHead.set(t.id, fx)
39
40  const acc = hex(t.color)
41  const light = mix(acc, [255, 255, 255], 0.32)
42  const grey = [132, 130, 138]
43  const ease = 'calcMode="spline" keyTimes="0;1" keySplines=".2 .8 .2 1"'
44  const glide = Math.abs(from - fx) > 0.5
45
46  // Pixels on a 3px grid, 7 rows, denser and closer to the accent towards the head.
47  const buckets = [0, 1, 2, 3, 4].map((b) => {
48    const m = b / 4
49    const dense = 0.22 + 0.78 * Math.pow(m, 1.5)
50    return { color: rgb(mix(grey, light, m)), opacity: (0.35 + 0.65 * dense).toFixed(2) }
51  })
52  let px = ''
53  for (let col = 0; col * 3 < fx; col++) {
54    const x = col * 3
55    const u = Math.min(1, (x + 1.5) / fx)
56    const dense = 0.22 + 0.78 * Math.pow(u, 1.5)
57    const bucket = Math.min(4, Math.floor(Math.min(1, Math.pow(u, 0.9) * 1.1) * 4.99))
58    for (let r = 0; r < 7; r++) {
59      if (hash(col, r, 1) > dense + 0.1) continue
60      px += `<rect x="${x}" y="${1 + r * 3}" class="b${bucket} t${Math.floor(hash(col, r, 2) * 4)}"/>`
61    }
62  }
63
64  // A short tick between tasks, brighter once passed.
65  let marks = ''
66  for (let k = 1; k < total; k++) {
67    const x = (k / total) * W
68    const passed = x < fx - 1
69    const fill = passed ? rgb(mix(light, [255, 255, 255], 0.45)) : '#8A8984'
70    marks += `<rect x="${(x - 0.75).toFixed(1)}" y="${(H - 8) / 2}" width="1.5" height="8" rx=".75" fill="${fill}" opacity="${passed ? 0.6 : 0.45}"/>`
71  }
72
73  // The knob: a pill with "Tasks 3/5", or a round dot with the number when narrow.
74  const number = Math.min(total, t.finished + 1)
75  let knob
76  let kw = H
77  if (W < NARROW) {
78    knob = `<circle cx="0" cy="${H / 2}" r="${H / 2}" fill="${t.color}"/><text x="0" y="${H / 2 + 4.2}" text-anchor="middle" class="kt">${number}</text>`
79  } else {
80    const count = `${number}/${total}`
81    kw = Math.round(20 + textWidth('Tasks') + 6 + textWidth(count, 6.5))
82    knob = `<rect x="${-kw / 2}" y="0" width="${kw}" height="${H}" rx="${H / 2}" fill="${t.color}"/>`
83    knob += `<text x="${-kw / 2 + 10}" y="${H / 2 + 4.2}" class="kt">Tasks<tspan class="kc" dx="6">${count}</tspan></text>`
84  }
85  const clampX = (x) => Math.max(kw / 2, Math.min(W - kw / 2, x))
86  const kx = clampX(fx)
87  const kFrom = clampX(from)
88
89  const style = `<style>
90${buckets.map((b, i) => `.b${i}{fill:${b.color};fill-opacity:${b.opacity}}`).join('')}
91rect[class]{width:2px;height:2px}
92.t0,.t1,.t2,.t3{animation:tw 2.2s ease-in-out infinite}
93.t1{animation-duration:2.8s;animation-delay:-.7s}.t2{animation-duration:1.9s;animation-delay:-1.3s}.t3{animation-duration:3.3s;animation-delay:-.4s}
94@keyframes tw{0%,100%{opacity:1}50%{opacity:.45}}
95.kt{font:500 12px 'Anthropic Sans',ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif;fill:#fff}
96.kc{font-weight:400;fill-opacity:.75}
97@media (prefers-reduced-motion:reduce){.t0,.t1,.t2,.t3{animation:none}}
98</style>`
99  const glideFill = glide
100    ? `<animate attributeName="width" from="${from.toFixed(1)}" to="${fx.toFixed(1)}" dur=".45s" ${ease} fill="freeze"/>`
101    : ''
102  const glideKnob = glide
103    ? `<animateTransform attributeName="transform" type="translate" from="${kFrom.toFixed(1)} 0" to="${kx.toFixed(1)} 0" dur=".45s" ${ease} fill="freeze"/>`
104    : ''
105
106  return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${style}
107<defs><clipPath id="pill"><rect width="${W}" height="${H}" rx="${H / 2}"/></clipPath><clipPath id="fill"><rect width="${fx.toFixed(1)}" height="${H}">${glideFill}</rect></clipPath>
108<linearGradient id="base" x1="0" x2="${fx.toFixed(1)}" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="${rgb(acc)}" stop-opacity=".05"/><stop offset="1" stop-color="${rgb(acc)}" stop-opacity=".33"/></linearGradient></defs>
109<g clip-path="url(#pill)"><rect width="${W}" height="${H}" fill="#808080" fill-opacity=".16"/>
110<g clip-path="url(#fill)"><rect width="${fx.toFixed(1)}" height="${H}" fill="url(#base)"/>${px}</g>${marks}</g>
111<g transform="translate(${kx.toFixed(1)} 0)">${glideKnob}${knob}</g></svg>`
112}
113
types/index.d.ts 35 lines
1// What super-bar keeps in $.state for the length of a session. The host holds
2// these values, so they survive a hot reload; /clear, /resume and /branch reset them.
3
4export type SuperBarStatus = 'pending' | 'in_progress' | 'completed'
5
6export type SuperBarTask = {
7  id: string
8  subject: string
9  activeForm: string | null
10  status: SuperBarStatus
11  /** Tasks created after every earlier task was done start a new batch. */
12  batch: number
13}
14
15export type SuperBarBoard = {
16  tasks: SuperBarTask[]
17  batch: number
18  /** Whether this session's model has the task tools; null until checked. */
19  toolsOn: boolean | null
20}
21
22export type SuperBarView = {
23  /** Hidden with × until Claude creates the next task, or until /taskbar. */
24  hidden: boolean
25}
26
27declare module 'claude-code' {
28  interface PluginState {
29    'super-bar': {
30      board: SuperBarBoard
31      view: SuperBarView
32    }
33  }
34}
35