SLOPSHOPPER

context-bar

The /context breakdown as one live bar, drawn above the prompt.

newbandguardcommandtimer
v0.3.0no licenseupdated 2026-10-07TLOBillyQ/claude-mods/context-bar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-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 › /context-bar ⎿ context-bar: Context bar hidden. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-mods

五个 Claude Code mod,打包成一个本地 marketplace my-mods。

Mod作用
token-weather在输入框上方用“天气预报”显示上下文窗口用量
blast-radius拦下有风险的 Bash 命令,先显示会影响什么,再等你选 Proceed 或 Cancel
replay-theater记录上一轮的文件编辑,逐个 diff 回放
context-bar在输入框上方用一张卡片显示 /context 的分类明细,可折叠
agents-panel侧边面板列出项目的 .claude/agents,每个带 run 按钮

需要 Claude Code 2.1.287 或更高版本(claude --version)。

安装

git clone https://github.com/TLOBillyQ/claude-mods.git
claude plugin marketplace add ./claude-mods
claude plugin install token-weather@my-mods --scope user
claude plugin install blast-radius@my-mods --scope user
claude plugin install replay-theater@my-mods --scope user
claude plugin install context-bar@my-mods --scope user
claude plugin install agents-panel@my-mods --scope user

装好后新开一个会话才会加载。

停用某一个:

claude plugin disable blast-radius@my-mods

拉取新版本后更新:

claude plugin marketplace update my-mods

只想临时试用、不安装:

claude --plugin-dir ./claude-mods/token-weather --plugin-dir ./claude-mods/blast-radius --plugin-dir ./claude-mods/replay-theater --plugin-dir ./claude-mods/context-bar --plugin-dir ./claude-mods/agents-panel

token-weather

每轮结束后读取上下文用量,在输入框上方画一行:天气图标、百分比、已用/总 token、最近 12 轮的迷你图,以及上一轮增加了多少。

用量预报
< 25%☀ Clear
25–49%☁ Cloudy
50–74%☂ Showers
75–89%☇ Storm
≥ 90%↯ Compact soon
☀  Clear  18% of context  36.1k / 200k   last turns ▁▂▅  ▲ +12.4k last turn

blast-radius

Claude 调用下面这些 Bash 命令时,mod 会先拦住,空跑一遍算出影响范围,再打开一个面板:

命令怎么算影响范围
rm -r / rm -rffind + du:会删哪些文件、总大小
git reset --hardgit status --porcelain:会丢掉哪些未提交改动
git cleangit clean -n:会删哪些未跟踪文件
强推(--force、--force-with-lease、+ref)git log HEAD..<upstream>:远端会丢哪些提交
数据库迁移(Django、Rails、Prisma、Alembic、Knex、Sequelize、Flyway)Django 用 showmigrations --plan 列出待执行迁移,其他只做提示

按 1 Proceed,命令照原样执行;按 2 Cancel,Claude 会收到拒绝和原因。关掉面板或按 Esc 等于 Cancel。终端太窄放不下面板时,同样的内容会画在输入框上方。

它只读命令文本,$(…)、别名、调用 rm 的脚本都拦不住。它是安全网,不是权限系统;要硬性禁止请用权限规则。

replay-theater

一轮对话里,mod 记录每次成功的 Edit、MultiEdit 和 Write(Write 会在写入前读取旧内容,所以 diff 是真实的)。回合结束后,输入框上方会出现提示:

↻ 3 edits across 2 files last turn  r: Replay  or /replay

运行 /replay(或聚焦提示条后按 r)打开回放面板:顶部是步骤条,下面是当前这一步的文件和 diff,p / n / c 对应 Prev / Next / Close,也可以点步骤条上的编号直接跳转。它只观察,不会阻止或修改任何编辑。

context-bar

把 /context 的分类明细做成输入框上方的一张卡片:

  • 标题行:◆ context,右边是已用 / 窗口、自动压缩阈值和一个百分比徽章(<50% 薄荷、<80% 柠檬杏、其余珊瑚红)
  • 一条连续的彩色条,每个类别有一个固定的中间亮度粉彩色,深色和浅色终端背景上都看得清;剩余空间是浅灰色,深墨色竖线标出压缩点。条按格宽用色块画,desktop 上也和卡片等宽
  • 图例:每个类别的 token 数和占比
╭──────────────────────────────────────────────────────────────╮
│ ◆ ▾ context                90k of 1M · compacts at 987k  9%  │
│ ██████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░│  │
│ ■ system prompt 4.2k 0%   ■ tools 17k 2%   ■ messages 0 0%   │
│ ■ free 897k                                                  │
╰──────────────────────────────────────────────────────────────╯

默认折叠,只显示标题行;点 ▸ context 展开,再点 ▾ context 收起。手动选择在当前会话的后续回复和热重载时保持;键盘可以 ctrl+x tab 聚焦后回车。/context-bar 显示或隐藏整张卡片。会话开始和每轮结束后刷新,用 summary 模式在本地估算,不额外发 token 计数请求;按需加载的 deferred 工具不占窗口,不显示。终端窄于 60 列时不画图例。

agents-panel

/agents-panel 在侧边打开面板,列出项目 .claude/agents/*.md 里定义的所有 agent:名字、模型、一行描述,右侧 ▶ run 按钮用该 agent 启动一个子 agent(提示词为“Run your default task on this project.”)。再次运行 /agents-panel 关闭。只读项目目录,不读 ~/.claude/agents;每次打开面板时重新读取。

开发

每个 mod 的结构:

<mod>/
├── .claude-plugin/plugin.json   清单,types 指向类型契约
├── hooks/hooks.json             指向 hooks 模块
├── hooks/<mod>.mjs              导出 register(on)
├── types/index.d.ts             $.state 的类型契约
└── tests/<mod>.test.ts

校验和测试:

claude plugin validate ./blast-radius
claude plugin test ./blast-radius
Source 2 files
hooks/context-bar.mjs 237 lines
1// Context Bar: the /context breakdown as a card above the prompt.
2
3import { update } from "claude-code";
4
5const ACCENT = "#d97757";
6// A light grey shows on dark and light backgrounds alike; ink reads on it and on every pastel.
7const FREE = "#c9ced6";
8const INK = "#1f2430";
9const LABEL = { "system tools": "tools", "free space": "free" };
10// /context's own theme keys are mostly greys (several rows share one) and some do not
11// resolve as a background on every surface, so each row gets a fixed colour of its own:
12// mid-lightness pastels, readable on either terminal background.
13const COLOR = {
14  "system prompt": "#5fb3e8",
15  "system tools": "#4fc8a8",
16  "mcp tools": "#a48be8",
17  "custom agents": "#8cc865",
18  agents: "#8cc865",
19  "memory files": "#e8c25a",
20  skills: "#ef8fb5",
21  "slash commands": "#ef8fb5",
22  messages: "#f08a72",
23};
24const SPARE = ["#6fd0d8", "#c69be0", "#f2a96b", "#b5d36a", "#e88f9a"];
25
26// Held by the host, so these survive a hot reload of this file.
27const snapshot = { plugin: "context-bar", key: "snapshot" };
28const collapsed = { plugin: "context-bar", key: "collapsed" };
29const hidden = { plugin: "context-bar", key: "hidden" };
30
31// Mid-turn refresh. Module scope is fine here: a hot reload drops these, and
32// the host cancels the old environment's timer with it, so at worst the next
33// turn writes one extra snapshot.
34const POLL_MS = 2_000;
35const MIN_DELTA = 1_000;
36let poll = null;
37let lastWritten = null;
38
39export function register(on) {
40  on("session.start", async ($, e, next) => {
41    const result = await next(e);
42    await $.command.register({ name: "context-bar", description: "Show or hide the context bar above the prompt" });
43    await takeSnapshot($, true);
44    return result;
45  });
46
47  on("command.run", { command: "context-bar" }, async ($) => {
48    const { value: isHidden = false } = await $.state.get(hidden);
49    await $.state.set(hidden, !isHidden);
50    return { text: isHidden ? "Context bar shown." : "Context bar hidden." };
51  });
52
53  // Fires after each main-thread turn, compaction included: the authoritative
54  // write, in case the throttle below suppressed the turn's final delta.
55  on("session.measure", async ($, e, next) => {
56    const result = await next(e);
57    await takeSnapshot($, true);
58    return result;
59  });
60
61  // Mid-turn refreshes so a long turn's growth shows as it happens: every tool
62  // result lands here, and the poll covers growth with no tool call (long
63  // answers, queued user messages). Subagent turns raise no turn.start; their
64  // tool calls and turn.complete carry an agentId and are left alone.
65  on("turn.start", async ($, e, next) => {
66    const result = await next(e);
67    poll?.cancel();
68    poll = $.clock.every(POLL_MS, () => void takeSnapshot($));
69    return result;
70  });
71
72  on("turn.complete", async ($, e, next) => {
73    const result = await next(e);
74    if (!e.agentId) {
75      poll?.cancel();
76      poll = null;
77    }
78    return result;
79  });
80
81  on("tool.call", async ($, e, next) => {
82    const result = await next(e);
83    if (!e.agentId) await takeSnapshot($);
84    return result;
85  });
86
87  on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
88    const { value: snap } = await $.state.get(snapshot);
89    const { value: isHidden = false } = await $.state.get(hidden);
90    if (e.props.hasSurvey || !snap || isHidden) {
91      return next(e);
92    }
93    const { value: isCollapsed = true } = await $.state.get(collapsed);
94    const { Box, Text, Button } = $.ui.resolve(e);
95    const toggle = Button({
96      key: "toggle",
97      label: `${isCollapsed ? "▸" : "▾"} context`,
98      plain: true,
99      onPress: () => update($, collapsed, (v = true) => !v),
100    });
101    return card(Box, Text, toggle, snap, e.props.bodyColumns, isCollapsed);
102  });
103}
104
105// Snapshots the breakdown unless nothing the user can see moved: the whole
106// percentage point unchanged and under MIN_DELTA new tokens. Never throws —
107// a mid-turn usage hiccup must not break the tool call or timer it rides on.
108async function takeSnapshot($, force = false) {
109  try {
110    // `summary` estimates locally: no token-count requests, unlike /context's `full`.
111    const { context } = await $.session.usage({ breakdown: "summary" });
112    const b = context?.breakdown;
113    if (!b?.rawMaxTokens) return;
114    if (
115      !force &&
116      lastWritten &&
117      Math.round(b.percentage) === Math.round(lastWritten.percentage) &&
118      Math.abs(b.totalTokens - lastWritten.totalTokens) < MIN_DELTA
119    ) {
120      return;
121    }
122    lastWritten = { percentage: b.percentage, totalTokens: b.totalTokens };
123    await $.state.set(snapshot, {
124      model: b.model,
125      totalTokens: b.totalTokens,
126      maxTokens: b.rawMaxTokens,
127      percentage: b.percentage,
128      compactAt: b.isAutoCompactEnabled ? b.autoCompactThreshold : undefined,
129      rows: b.categories
130        .filter((c) => c.kind !== "deferred" && c.tokens > 0)
131        .map(({ name, tokens, color, kind }) => ({ name, tokens, color, kind })),
132    });
133  } catch {
134    // See above: stale beats broken.
135  }
136}
137
138function card(Box, Text, toggle, snap, columns, isCollapsed) {
139  const summary = `${short(snap.totalTokens)}`;
140  const detail = ` of ${short(snap.maxTokens)}` + (snap.compactAt ? ` · compacts at ${short(snap.compactAt)}` : "") + " ";
141  const head = Box({
142    flexDirection: "row",
143    justifyContent: "space-between",
144    children: [
145      Box({ flexDirection: "row", children: [Text({ color: ACCENT, children: "◆ " }), toggle] }),
146      Box({
147        flexDirection: "row",
148        children: [
149          Text({ bold: true, children: summary }),
150          Text({ dimColor: true, children: detail }),
151          Text({ backgroundColor: badgeColor(snap.percentage), color: INK, bold: true, children: ` ${snap.percentage}% ` }),
152        ],
153      }),
154    ],
155  });
156  const children = [head];
157  if (!isCollapsed) {
158    children.push(Box({ flexDirection: "row", children: bar(Box, Text, snap, Math.max(10, columns - 6)) }));
159    if (columns >= 60) children.push(legend(Box, Text, snap));
160  }
161  return Box({ flexDirection: "column", borderStyle: "round", borderDimColor: true, paddingX: 1, children });
162}
163
164// Used rows in their colours, the rest of the window grey, a tick where compaction runs.
165// Each segment is a Box `width` cells wide, not a run of characters: the desktop counts
166// columns in its code font but draws Text in a proportional one, so runs of spaces or
167// shade glyphs come out too short or too long there.
168function bar(Box, Text, snap, cells) {
169  const used = snap.rows.filter((r) => r.kind === "used");
170  const counts = allot(used.map((r) => r.tokens), (snap.totalTokens / snap.maxTokens) * cells, cells);
171  const block = (width, backgroundColor, children) => Box({ width, height: 1, flexShrink: 0, backgroundColor, children });
172  const parts = used.map((r, i) => block(counts[i], colorOf(r, i)));
173  const rest = cells - counts.reduce((s, n) => s + n, 0);
174  const tick = snap.compactAt ? Math.round((snap.compactAt / snap.maxTokens) * cells) - (cells - rest) : -1;
175  if (tick >= 0 && tick < rest) {
176    if (tick > 0) parts.push(block(tick, FREE));
177    parts.push(block(1, FREE, Text({ color: INK, bold: true, children: "│" })));
178    if (rest - tick > 1) parts.push(block(rest - tick - 1, FREE));
179  } else if (rest > 0) {
180    parts.push(block(rest, FREE));
181  }
182  return parts;
183}
184
185// Splits about `width` cells among `weights` by largest remainder: one cell each at least, `cells` at most.
186function allot(weights, width, cells) {
187  const target = Math.min(cells, Math.max(Math.round(width), weights.length));
188  const total = weights.reduce((s, w) => s + w, 0) || 1;
189  const shares = weights.map((w) => (w / total) * target);
190  const counts = shares.map((s) => Math.max(1, Math.floor(s)));
191  let left = target - counts.reduce((s, n) => s + n, 0);
192  for (const [, i] of shares.map((s, i) => [s - Math.floor(s), i]).sort((a, b) => b[0] - a[0])) {
193    if (left-- <= 0) break;
194    counts[i]++;
195  }
196  return counts;
197}
198
199function legend(Box, Text, snap) {
200  const items = snap.rows
201    .filter((r) => r.kind !== "buffer")
202    .map((r, i) => {
203      const name = r.name.toLowerCase();
204      const isFree = r.kind === "free";
205      return Text({
206        children: [
207          Text({ color: isFree ? FREE : colorOf(r, i), children: "■ " }),
208          Text({ dimColor: isFree, children: `${LABEL[name] ?? name} ` }),
209          Text({ bold: !isFree, dimColor: isFree, children: short(r.tokens) }),
210          !isFree && Text({ dimColor: true, children: ` ${pct(r.tokens, snap.maxTokens)}` }),
211        ].filter(Boolean),
212      });
213    });
214  return Box({ flexDirection: "row", flexWrap: "wrap", columnGap: 3, children: items });
215}
216
217// `i` is the row's place among the used rows, which come first, so bar and legend agree.
218function colorOf(row, i) {
219  return COLOR[row.name.toLowerCase()] ?? SPARE[i % SPARE.length];
220}
221
222function badgeColor(percent) {
223  if (percent < 50) return "#4fc8a8";
224  if (percent < 80) return "#e8c25a";
225  return "#f06c6c";
226}
227
228function pct(n, of) {
229  return `${Math.round((n / of) * 100)}%`;
230}
231
232function short(n) {
233  if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(1)}M`;
234  if (n >= 1_000) return `${+(n / 1_000).toFixed(1)}k`;
235  return String(n);
236}
237
types/index.d.ts 17 lines
1export type ContextBarRow = { name: string; tokens: number; color: string; kind: "used" | "free" | "buffer" };
2
3export type ContextBarSnapshot = {
4  model: string;
5  totalTokens: number;
6  maxTokens: number;
7  percentage: number;
8  compactAt?: number;
9  rows: ContextBarRow[];
10};
11
12declare module "claude-code" {
13  interface PluginState {
14    "context-bar": { snapshot: ContextBarSnapshot; collapsed: boolean; hidden: boolean };
15  }
16}
17