SLOPSHOPPER

ribbon

A band above the prompt with the session's time, cost, project, branch and rate limits.

newbandprocesstimer
v?MITupdated 2026-10-24fabioueno/koubou/plugins/ribbon
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ribbon
› 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 ⟨Claude Code's own drawing⟩ ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │  30m 00s  0.42  app  detached  31% │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │  30m 00s  0.42  app  detached  31% │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
README

<h1>Koubou (工房)</h1>

Koubou is a Claude Code plugin marketplace. Each plugin does one job, and you can enable or disable each one on its own. Each plugin has its own evals, or its own tests when it is a mod.


Plugins

PluginProvidesWhat it does
crewSidebar modShows a side panel with a card for each agent at work.
emberBackground modKeeps the prompt cache warm while you are away, and warns when a resumed one went cold.
gitgit:gitignoreBuilds and updates .gitignore files from the official github/gitignore templates.
kansoTwo skills, a hookSets one writing style for all text, and turns substantial answers into pages.
ribbonPrompt band modShows a band above the prompt with the session's time, cost, project, branch and limits.
workflowFive skillsPlans, coordinates, builds test first, reviews and opens pull requests. Needs kanso.

Installation

Add the marketplace, then install the plugins you want:

/plugin marketplace add fabioueno/koubou
/plugin install crew@koubou
/plugin install ember@koubou
/plugin install git@koubou
/plugin install kanso@koubou
/plugin install ribbon@koubou
/plugin install workflow@koubou

Disable a plugin, or enable it again, without uninstalling it:

/plugin disable kanso@koubou
/plugin enable kanso@koubou

Plugins carry no version, so each one updates when a commit changes its folder. Run /plugin marketplace update koubou to fetch new commits.

Documentation

DocumentDescription
[CONTRIBUTING.md][contributing]Conventions, adding a plugin, and running evals.

License

[MIT][license]

[contributing]: CONTRIBUTING.md [license]: LICENSE

Source 13 files
hooks/register.ts 10 lines
1import type { Register } from "claude-code";
2
3import { onAbovePrompt } from "./render/above-prompt";
4import { onSessionStart } from "./session/start";
5
6export const register: Register = (on, options) => {
7  onSessionStart(on, options);
8  onAbovePrompt(on, options);
9};
10
hooks/render/above-prompt.tsx 37 lines
1import type { On, PluginOptions } from "claude-code";
2import { atom, read } from "claude-code";
3
4import { band } from "../status/band";
5
6// Declared in each file that reads them: the mod validator follows an atom only
7// within its own file.
8const status = atom({ plugin: "ribbon", key: "status" } as const, null);
9
10/**
11 * The band, kept next to the prompt, under whatever the other plugins draw
12 * above the prompt, so each one's part shows whatever order the plugins run in.
13 */
14export function onAbovePrompt(on: On, options: PluginOptions): void {
15  on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
16    const current = await read($, status);
17    if (e.surface !== "terminal" || e.props.hasSurvey || current === null) {
18      return next(e);
19    }
20
21    const E = $.ui.resolve(e);
22    const above = await next(e);
23
24    return (
25      <E.Box flexDirection="column">
26        {above}
27        {band(E, {
28          status: current,
29          width: e.props.bodyColumns,
30          showCost: options.showCost !== false,
31          showRateLimits: options.showRateLimits !== false,
32        })}
33      </E.Box>
34    );
35  });
36}
37
hooks/session/start.ts 92 lines
1import type { EngineInterface, On, PluginOptions } from "claude-code";
2import { atom, update } from "claude-code";
3
4import type { Status } from "../../types";
5import { fablePercent } from "../status/fable";
6import { gaugeOf } from "../status/limits";
7import { basename, homeOf } from "../text/paths";
8
9// Declared in each file that reads them: the mod validator follows an atom only
10// within its own file.
11const status = atom({ plugin: "ribbon", key: "status" } as const, null);
12
13// The status line script refreshed the Fable usage cache as a side effect; with
14// the status line gone, a timer runs it for the same cache.
15interface Usage {
16  cache: string;
17  script: string;
18}
19
20async function usageOf($: EngineInterface, options: PluginOptions): Promise<Usage | undefined> {
21  if (options.showRateLimits === false) {
22    return undefined;
23  }
24
25  const dir = (await $.env.get("CLAUDE_CONFIG_DIR")) ?? `${await homeOf($)}/.claude`;
26
27  return { cache: `${dir}/cache/statusline-usage.json`, script: `${dir}/statusline.sh` };
28}
29
30async function placeOf($: EngineInterface, cwd: string): Promise<Pick<Status, "project" | "branch" | "worktree">> {
31  const git = async (...args: string[]): Promise<string | undefined> => {
32    const ran = await $.process.run(["git", "-C", cwd, ...args]).catch(() => undefined);
33    return ran?.exitCode === 0 ? ran.stdout.trim() : undefined;
34  };
35
36  const [branch, gitDir, commonDir, top] = await Promise.all([
37    git("branch", "--show-current"),
38    git("rev-parse", "--absolute-git-dir"),
39    git("rev-parse", "--path-format=absolute", "--git-common-dir"),
40    git("rev-parse", "--show-toplevel"),
41  ]);
42
43  const isWorktree = gitDir !== undefined && commonDir !== undefined && gitDir !== commonDir && top !== undefined;
44
45  return {
46    project: basename(await $.session.root().catch(() => cwd)),
47    branch: branch === undefined ? undefined : branch || "detached",
48    worktree: isWorktree ? basename(top) : undefined,
49  };
50}
51
52async function refreshStatus($: EngineInterface, usageFiles: Usage | undefined): Promise<void> {
53  const [usage, cwd, now] = await Promise.all([$.session.usage(), $.session.cwd(), $.clock.now()]);
54  const fable = usageFiles && (await $.fs.read(usageFiles.cache).then(fablePercent, () => undefined));
55
56  const next: Status = {
57    now,
58    startedAt: usage.startedAt,
59    costUsd: usage.cost?.usd ?? 0,
60    fiveHour: gaugeOf(usage.rateLimits, "five_hour"),
61    sevenDay: gaugeOf(usage.rateLimits, "seven_day"),
62    fable,
63    ...(await placeOf($, cwd)),
64  };
65
66  await update($, status, () => next);
67}
68
69function startTimers($: EngineInterface, usageFiles: Usage | undefined): void {
70  $.clock.every(15_000, () => {
71    refreshStatus($, usageFiles).catch(() => undefined);
72  });
73
74  if (usageFiles !== undefined) {
75    $.clock.every(180_000, () => {
76      $.process.run(["bash", usageFiles.script], { stdin: "{}" }).catch(() => undefined);
77    });
78  }
79}
80
81export function onSessionStart(on: On, options: PluginOptions): void {
82  on("session.start", async ($, e, next) => {
83    const started = await next(e);
84    const usageFiles = await usageOf($, options);
85
86    await refreshStatus($, usageFiles).catch(() => undefined);
87    startTimers($, usageFiles);
88
89    return started;
90  });
91}
92
hooks/status/band.tsx 90 lines
1import type { RenderElement } from "claude-code";
2
3import type { Gauge, Status } from "../../types";
4import type { Els } from "../cards/elements";
5import { CARD_BORDER, MUTED, RAMP } from "../palette/colors";
6import { GLYPHS } from "../palette/glyphs";
7import { gradient } from "../palette/gradient";
8import { duration, untilReset } from "../text/units";
9
10// The fixed-colour items, in the order they take the ramp.
11function fixedItems(view: BandView): [string, string][] {
12  const { status } = view;
13  const items: [string, string][] = [[GLYPHS.clock, duration(status.now - status.startedAt)]];
14
15  if (view.showCost) {
16    items.push([GLYPHS.cost, status.costUsd.toFixed(2)]);
17  }
18
19  items.push([GLYPHS.project, status.project]);
20
21  if (status.branch !== undefined) {
22    items.push([GLYPHS.branch, status.branch]);
23  }
24  if (status.worktree !== undefined) {
25    items.push([GLYPHS.worktree, status.worktree]);
26  }
27
28  return items;
29}
30
31function gauge(E: Els, glyph: string, limit: Gauge | undefined, view: BandView, fable?: number): RenderElement | null {
32  const { Box, Text } = E;
33  if (limit === undefined && fable === undefined) {
34    return null;
35  }
36
37  const reset = untilReset(limit?.resetsAt, view.status.now);
38
39  return (
40    <Box gap={1}>
41      {limit !== undefined && (
42        <Text wrap="truncate-end" color={gradient(limit.percent)}>
43          {glyph} {Math.trunc(limit.percent)}%
44        </Text>
45      )}
46
47      {fable !== undefined && (
48        <Text wrap="truncate-end" color={gradient(fable)}>
49          {GLYPHS.fable} {fable}%
50        </Text>
51      )}
52
53      {reset !== undefined && (
54        <Text wrap="truncate-end" color={MUTED}>
55          {reset}
56        </Text>
57      )}
58    </Box>
59  );
60}
61
62export interface BandView {
63  status: Status;
64  width: number;
65  showCost: boolean;
66  showRateLimits: boolean;
67}
68
69/**
70 * One rounded line: fixed items along a pastel ramp first, then the gauges
71 * coloured by their value.
72 */
73export function band(E: Els, view: BandView): RenderElement {
74  const { Box, Text } = E;
75  const { status } = view;
76
77  return (
78    <Box borderStyle="round" borderColor={CARD_BORDER} paddingX={1} columnGap={4} flexWrap="wrap" width={view.width}>
79      {fixedItems(view).map(([glyph, text], index) => (
80        <Text wrap="truncate-end" color={RAMP[Math.min(index, RAMP.length - 1)]}>
81          {glyph} {text}
82        </Text>
83      ))}
84
85      {view.showRateLimits && gauge(E, GLYPHS.hourglass, status.fiveHour, view)}
86      {view.showRateLimits && gauge(E, GLYPHS.calendar, status.sevenDay, view, status.fable)}
87    </Box>
88  );
89}
90
hooks/status/fable.ts 42 lines
1type Loose = Readonly<Record<string, unknown>>;
2
3function scopedFable(usage: Loose): Loose | undefined {
4  const limits = Array.isArray(usage.limits) ? (usage.limits as Loose[]) : [];
5
6  return limits.find((limit) => {
7    const model = (limit.scope as Loose | undefined)?.model as Loose | undefined;
8
9    return (
10      limit.kind === "weekly_scoped" &&
11      String(model?.display_name ?? "")
12        .toLowerCase()
13        .includes("fable")
14    );
15  });
16}
17
18function pickOf(usage: Loose): { percent: number; reset: unknown } | undefined {
19  const scoped = scopedFable(usage);
20  if (scoped) {
21    return { percent: Number(scoped.percent ?? 0), reset: scoped.resets_at };
22  }
23
24  const flat = (usage.seven_day_overage_included ?? usage.seven_day_fable) as Loose | undefined;
25
26  return flat ? { percent: Number(flat.utilization ?? 0), reset: flat.resets_at } : undefined;
27}
28
29/**
30 * The Fable weekly percent from the usage endpoint's cache: its scoped limit
31 * first, else the flat bucket under either of its names. A zero percent with no
32 * reset is a window the account does not have.
33 */
34export function fablePercent(raw: string): number | undefined {
35  const pick = pickOf(JSON.parse(raw) as Loose);
36  if (!pick || (pick.percent === 0 && (pick.reset === null || pick.reset === undefined))) {
37    return undefined;
38  }
39
40  return Math.trunc(pick.percent);
41}
42
hooks/status/limits.ts 9 lines
1import type { SessionRateLimit } from "claude-code";
2
3import type { Gauge } from "../../types";
4
5export function gaugeOf(limits: readonly SessionRateLimit[], kind: string): Gauge | undefined {
6  const limit = limits.find((each) => each.kind === kind);
7  return limit ? { percent: limit.percentUsed, resetsAt: limit.resetsAt } : undefined;
8}
9
hooks/text/paths.ts 10 lines
1import type { EngineInterface } from "claude-code";
2
3export async function homeOf($: EngineInterface): Promise<string> {
4  return (await $.env.get("HOME")) ?? "";
5}
6
7export function basename(path: string): string {
8  return path.split("/").filter(Boolean).at(-1) ?? path;
9}
10
hooks/cards/elements.ts 4 lines
1import type { Elements } from "claude-code";
2
3export type Els = Elements["terminal"];
4
hooks/palette/colors.ts 6 lines
1// Hex blended toward the Carbonfox background (#161616): the terminal has no
2// alpha, so a softer colour is a mix with the background.
3export const CARD_BORDER = "#4A4A4A";
4export const MUTED = "#6C7086";
5export const RAMP = ["#F2CDCD", "#F5C2E7", "#CBA6F7", "#B4BEFE", "#89B4FA", "#74C7EC", "#89DCEB"] as const;
6
hooks/palette/glyphs.ts 11 lines
1export const GLYPHS = {
2  branch: "\u{e725}",
3  calendar: "\u{f073}",
4  clock: "\u{f017}",
5  cost: "\u{f155}",
6  fable: "\u{f02d}",
7  hourglass: "\u{f254}",
8  project: "\u{f07b}",
9  worktree: "\u{f0405}",
10} as const;
11
hooks/palette/gradient.ts 19 lines
1function hex(red: number, green: number, blue: number): string {
2  return `#${[red, green, blue].map((value) => Math.round(value).toString(16).padStart(2, "0")).join("")}`;
3}
4
5/**
6 * The gauge colour for a percent: green through yellow to red, along the
7 * Catppuccin Mocha anchors the old status line used.
8 */
9export function gradient(percent: number): string {
10  const clamped = Math.max(0, Math.min(100, percent));
11  if (clamped < 50) {
12    return hex(166 + (83 * clamped) / 50, 227 - clamped / 50, 161 + (14 * clamped) / 50);
13  }
14
15  const past = clamped - 50;
16
17  return hex(249 - (6 * past) / 50, 226 - (87 * past) / 50, 175 - (7 * past) / 50);
18}
19
hooks/text/units.ts 41 lines
1/**
2 * The two largest units that apply: `1h 02m`, `7m 03s`, `12s`.
3 */
4export function duration(milliseconds: number): string {
5  const seconds = Math.max(0, Math.floor(milliseconds / 1000));
6  const hours = Math.floor(seconds / 3600);
7  const minutes = Math.floor((seconds % 3600) / 60);
8
9  if (hours > 0) {
10    return `${hours}h ${String(minutes).padStart(2, "0")}m`;
11  }
12  if (minutes > 0) {
13    return `${minutes}m ${String(seconds % 60).padStart(2, "0")}s`;
14  }
15
16  return `${seconds}s`;
17}
18
19/**
20 * The countdown to an ISO reset time, or nothing once it has passed.
21 */
22export function untilReset(resetsAt: string | undefined, now: number): string | undefined {
23  if (resetsAt === undefined) {
24    return undefined;
25  }
26
27  const seconds = Math.floor((Date.parse(resetsAt) - now) / 1000);
28
29  if (!(seconds > 0)) {
30    return undefined;
31  }
32  if (seconds >= 86_400) {
33    return `${Math.floor(seconds / 86_400)}d ${Math.floor((seconds % 86_400) / 3600)}h`;
34  }
35  if (seconds >= 3600) {
36    return duration(seconds * 1000);
37  }
38
39  return seconds >= 60 ? `${Math.floor(seconds / 60)}m` : "<1m";
40}
41