SLOPSHOPPER

qsm-statusline

The session's cost in the one line that is always on screen: a compiled renderer that reads the harness payload and prints the model, the context window, the…

newprocess
A shopper browsing a rack in a slop shop
README

qsm-statusline

One status line for Claude Code that shows the model, the context window, the rate limits and the git state, for the person who watches what a session costs.

Why it exists

Claude Code's default status line shows little beyond the model, so the human cannot see what the session costs until it is too late to change course. qsm-statusline replaces it with one line that is always on screen: model, context window used, five-hour and weekly rate limits, and git state. It takes the stance that the human decides the pacing and can only do so with the numbers in front of them. It refuses to be a dashboard or a notification system. When the harness shows these numbers itself, this plugin can go.

What you get

KindNameWhat it does
Skillsetup-statuslineInstalls, updates, fixes or removes the status line, and writes the settings key with your go
CLIqsm-statusline3 verbs (render, refresh, doctor), see USAGE.md

The line shows the model, the context window used, the five-hour and weekly rate limits with reset times, and the git state. A source that is not there drops its segment. The render never waits on git.

Opus 5 | 370k/1m
context: ██████░░░░░░░░░ 37% | git rewrite: +2 ~1 ?3
session: ██████░░░░ 62% resets in 2h | week: ██░░░░░░░░ 18% resets in 3d 11h

Install

claude plugin install qsm-statusline@qsm-marketplace
bun plugins/qsm-statusline/scripts/install.ts

The install script compiles qsm-statusline and links it into ~/.local/bin. It does not touch your settings. Add this key yourself, then restart Claude Code:

{ "statusLine": { "type": "command", "command": "qsm-statusline render", "padding": 1 } }

More in INSTALL.md.

Use

Ask: "set up the statusline" or "my statusline is broken". The skill fires and does the wiring. To check it yourself:

qsm-statusline doctor
echo '{}' | qsm-statusline render --no-color

To remove it, delete the statusLine key and restart Claude Code.

Caveats

  • The first render in a directory has no git segment. It appears on the next frame.
  • The limit bars need a subscription. Without one the line is two rows.
  • Claude Code reads the status line once at startup, so every change needs a restart.
Source 3 files
hooks/register.tsx 10 lines
1// qsm-statusline's function-hooks module.
2import type { On } from "claude-code";
3import { registerNoun } from "./noun.generated.ts";
4
5export function register(on: On): void {
6  // Adds $.statusline: one method per qsm-statusline verb, generated by lib/generate-noun.ts. Never
7  // hand-edit noun.generated.ts or types/index.d.ts.
8  registerNoun(on);
9}
10
hooks/noun.generated.ts 127 lines
1// Generated by lib/generate-noun.ts from qsm.json. Do not hand-edit; run
2// `bun lib/generate-noun.ts plugins/<name>` again after changing qsm.json.
3import type { Caught, EngineInterface, On } from "claude-code";
4import type { Statusline } from "../types/index.d.ts";
5
6/** How one option becomes argv: a positional value (`flag` null), a bare
7 * boolean flag, or a flag and its value; a list repeats it per element. */
8export interface VerbArg {
9  readonly name: string;
10  readonly flag: string | null;
11  readonly boolean?: true;
12}
13
14export interface VerbRow {
15  readonly words: readonly string[];
16  readonly args: readonly VerbArg[];
17  readonly passthrough?: true;
18}
19
20export const verbs: Readonly<Record<string, VerbRow>> = {
21  render: { words: ["qsm-statusline","render"], args: [{ name: "cwd", flag: "--cwd" }, { name: "noColor", flag: "--no-color", boolean: true }] },
22  refresh: { words: ["qsm-statusline","refresh"], args: [{ name: "cwd", flag: "--cwd" }] },
23  doctor: { words: ["qsm-statusline","doctor"], args: [{ name: "settings", flag: "--settings" }] },
24};
25
26/** One verb's argv: its words, each option in declaration order, the
27 * passthrough rest after `--`, and `--json` last. */
28export function argvOf(row: VerbRow, options: Readonly<Record<string, unknown>> = {}): string[] {
29  const argv = [...row.words];
30  for (const arg of row.args) {
31    const value = options[arg.name];
32    const values = Array.isArray(value) ? value : [value];
33    for (const one of values) {
34      if (one === undefined || one === false) {
35        continue;
36      }
37      argv.push(...(arg.flag === null ? [] : [arg.flag]), ...(arg.boolean ? [] : [String(one)]));
38    }
39  }
40  const rest = options.rest;
41  if (row.passthrough && Array.isArray(rest)) {
42    argv.push("--", ...rest.map(String));
43  }
44  argv.push("--json");
45  return argv;
46}
47
48/** The binary prints one outcome envelope on stdout under --json:
49 * { code, exit, reason, message, warnings, data }. Data on ok; otherwise the
50 * code and message become the error. A stale or misbehaving binary that
51 * prints something else (not JSON, or JSON with no numeric `exit`) throws
52 * loud here instead of a raw JSON.parse SyntaxError or a silent `undefined`. */
53export function unwrap(
54  argv: readonly string[],
55  result: { readonly exitCode: number; readonly stdout: string; readonly stderr: string },
56  label = argv.slice(0, 2).join(" "),
57): unknown {
58  const text = result.stdout.trim();
59  const stderr = result.stderr.trim().slice(0, 200);
60  const cause = `(exit ${result.exitCode}): ${stderr.length > 0 ? stderr : "no stderr"}`;
61  if (text.length === 0) {
62    throw new Error(`${argv.join(" ")}: printed no envelope ${cause}`);
63  }
64  let parsed: unknown;
65  try {
66    parsed = JSON.parse(text);
67  } catch {
68    parsed = undefined;
69  }
70  if (typeof parsed !== "object" || parsed === null || typeof (parsed as { exit?: unknown }).exit !== "number") {
71    throw new Error(`${argv.join(" ")}: stdout is not an outcome envelope ${cause}: ${text.slice(0, 200)}`);
72  }
73  const envelope = parsed as { code: string; exit: number; message: string; data: unknown };
74  if (envelope.exit !== 0) {
75    throw new Error(`${label}: ${envelope.code}: ${envelope.message}`);
76  }
77  return envelope.data;
78}
79
80/** What every `.catch` does first: a toast and a log for the person. The toast says `fixedReason`
81 * when the cause may carry something the toast must not show, else the cause; the cause goes to
82 * the log and comes back for the caller's own policy. */
83export function failure(
84  $: EngineInterface,
85  event: string,
86  error: Caught["error"],
87  fixedReason?: string,
88): string {
89  const said = error.message ?? error.kind;
90  $.ui.toast(`qsm-statusline ERROR: ${event}: ${(fixedReason ?? said).slice(0, 120)}`);
91  $.ui.log(`${event} failed (${error.kind}): ${said}`);
92  return said;
93}
94
95/** Adds $.statusline to the engine. Call once from the plugin's own
96 * `register(on, options)` — never call the engine.create hook's own
97 * `next(e)` result from anywhere but here. */
98export function registerNoun(on: On): void {
99  on("engine.create", async (_$, e, next) => {
100    const beneath = await next(e);
101    const run = async (row: VerbRow, options?: Readonly<Record<string, unknown>>): Promise<unknown> => {
102      const argv = argvOf(row, options);
103      let result: Awaited<ReturnType<typeof beneath.process.run>>;
104      try {
105        result = await beneath.process.run(argv);
106      } catch (error) {
107        // A spawn in a deleted directory fails as ENOENT naming the binary; name the directory. A
108        // check that cannot run leaves the spawn's own error as the cause.
109        const gone = await beneath.session
110          .cwd()
111          .then(async (cwd) => ((await beneath.fs.exists(cwd)) ? null : cwd))
112          .catch(() => null);
113        if (gone !== null) {
114          throw new Error(`${row.words.join(" ")}: the session's working directory ${gone} no longer exists; cd to one that does`);
115        }
116        throw error;
117      }
118      return unwrap(argv, result, row.words.join(" "));
119    };
120    const methods = Object.entries(verbs).map(([method, row]) => [
121      method,
122      (options?: Readonly<Record<string, unknown>>) => run(row, options),
123    ]);
124    return { ...beneath, statusline: Object.fromEntries(methods) as Statusline };
125  });
126}
127
types/index.d.ts 22 lines
1// Generated by lib/generate-noun.ts from qsm.json. Do not hand-edit; run
2// `bun lib/generate-noun.ts plugins/<name>` again after changing qsm.json.
3
4export type Statusline = {
5  render(options: {
6    cwd?: string;
7    noColor?: boolean;
8  }): Promise<unknown>;
9  refresh(options: {
10    cwd?: string;
11  }): Promise<unknown>;
12  doctor(options: {
13    settings?: string;
14  }): Promise<unknown>;
15};
16
17declare module 'claude-code' {
18  interface EngineInterface {
19    statusline: Statusline;
20  }
21}
22