ccigarette 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ivan Matveev
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,136 @@
1
+ # 🚬 ccigarette
2
+
3
+ [![npm version](https://img.shields.io/npm/v/ccigarette.svg)](https://www.npmjs.com/package/ccigarette)
4
+ [![license](https://img.shields.io/badge/license-MIT-blue.svg)](#license)
5
+ [![node](https://img.shields.io/badge/node-%E2%89%A5%2022-brightgreen.svg)](#requirements)
6
+
7
+ > Smoking kills. Your context window.
8
+
9
+ A smouldering pixel cigarette that replaces your Claude Code status line. The cigarette **is** your context window: as the context fills up, the ember creeps toward the filter. Smoke curls on its own line above the ember while the agent is working. Burned down to the filter? Time to `/compact`.
10
+
11
+ <!-- Live status line: see docs/demo.tape (VHS) to regenerate. -->
12
+ ![ccigarette burning down as the context window fills](https://raw.githubusercontent.com/blackbalancef/ccigarette/main/docs/demo.gif)
13
+
14
+ The top line is smoke — a wisp `~` is born above the ember and, cooling into `°`, drifts toward the burned side, frame by frame. The burned tobacco settles as `▁` ash, so the cigarette keeps its full original length and you always see how much you've smoked.
15
+
16
+ ## Features
17
+
18
+ - 🔥 **Burns with your context** — `context_window.used_percentage` drives the ember toward the filter. Reach the filter, reach for `/compact`.
19
+ - 💨 **Live smoke & flicker** while the agent is working; a steady ember when idle. The animation is fully stateless — derived from the wall clock, with zero state kept between invocations.
20
+ - 🚬 **Six brands**, each with its own filter, colors, and burnable length. Lucky Strike burns the longest; Беломорканал burns out the fastest.
21
+ - 🎛 **Interactive TUI configurator** with a live animated preview — `npx ccigarette`, pick a brand, install in one keystroke.
22
+ - 🎨 **Truecolor** with an automatic 256-color downsample, a mirrorable orientation, and an optional dark backdrop for light terminals.
23
+ - 🛟 **Never crashes the status line** — broken JSON renders an unlit cigarette; any render error prints a bare `🚬` and exits `0`.
24
+
25
+ ## Install
26
+
27
+ ```bash
28
+ npx ccigarette
29
+ ```
30
+
31
+ This opens a terminal configurator: pick your brand (live animated preview), flip the orientation, toggle the label/percent/model, then hit **Install** — it writes the `statusLine` block into `~/.claude/settings.json` (honoring `CLAUDE_CONFIG_DIR`) and you're smoking.
32
+
33
+ Manual install, if you prefer editing settings yourself:
34
+
35
+ ```json
36
+ {
37
+ "statusLine": {
38
+ "type": "command",
39
+ "command": "npx -y ccigarette@0.1.0",
40
+ "padding": 0,
41
+ "refreshInterval": 1
42
+ }
43
+ }
44
+ ```
45
+
46
+ The installed command pins the version you set up: it runs from the npx cache (works offline after the first run) and never auto-updates. To upgrade, run `npx ccigarette` again. Prefer scripts? `npx ccigarette install` / `npx ccigarette uninstall` do the same non-interactively.
47
+
48
+ ## Requirements
49
+
50
+ - **[Claude Code](https://claude.com/claude-code)** — `ccigarette` is a `statusLine` provider.
51
+ - **Node.js ≥ 22**
52
+ - A terminal with Unicode glyph support (any modern terminal). Truecolor is recommended; a 256-color fallback is included.
53
+
54
+ ## Brands
55
+
56
+ Each brand has its own filter, colors, and — importantly — its own burnable length. Your context feels different depending on what you smoke.
57
+
58
+ | Brand | Character |
59
+ |---|---|
60
+ | **Marlboro** | Cork filter with speckle, red band. The classic. |
61
+ | **KENT** | White filter, blue band. Strict. |
62
+ | **Camel** | Ochre, double-speckled cork. |
63
+ | **Parliament** | Recessed filter — you can see the hollow. |
64
+ | **LUCKY STRIKE** | No filter, red ring at the butt. Longest burn of the pack. |
65
+ | **Беломорканал (Belomorkanal)** | A papirosa. Hollow paper mouthpiece, barely any tobacco — burns out fast. Honest. |
66
+
67
+ ## How it works
68
+
69
+ - Claude Code pipes status JSON to the command every second (`refreshInterval: 1`); burn progress comes from `context_window.used_percentage`.
70
+ - Animation is stateless: the frame is derived from the wall clock, so smoke sways and the ember flickers with zero state between invocations.
71
+ - Smoke and flicker only happen while the agent is actually working (the transcript file's mtime is under 10 s old). An idle cigarette rests with a steady ember. No `transcript_path` → treated as active.
72
+ - At 0% the cigarette is unlit. Past `warnAt` (default 90%) the percent turns red. After `/compact` the percentage drops — fresh cigarette, on the house.
73
+ - The status line **never crashes**: broken JSON renders an unlit cigarette; a render error prints a bare `🚬` and exits `0`.
74
+
75
+ ## Config
76
+
77
+ Lives at `~/.config/ccigarette/config.json` (respects `XDG_CONFIG_HOME`). Managed by the TUI, editable by hand:
78
+
79
+ | Field | Default | Meaning |
80
+ |---|---|---|
81
+ | `brand` | `"marlboro"` | `marlboro` · `kent` · `camel` · `parliament` · `lucky` · `belomor` |
82
+ | `direction` | `"right"` | `right` = filter on the right, burns leftward; `left` = mirrored |
83
+ | `showLabel` | `true` | Brand name after the art |
84
+ | `showPercent` | `true` | Context percentage |
85
+ | `showModel` | `true` | Model display name |
86
+ | `smoke` | `true` | Smoke wisp drifting above the ember (one line) |
87
+ | `backdrop` | `false` | Render a dark card behind the art — readable on light terminals, matches the mockup |
88
+ | `warnAt` | `90` | Percent turns red at this threshold |
89
+ | `colorMode` | `"truecolor"` | `truecolor` or `ansi256` (nearest-color downsample) |
90
+
91
+ ## Беломорканал
92
+
93
+ Отдельное слово про Беломор. Это папироса, а не сигарета: длинная полая бумажная гильза-мундштук `▐══════▌`, которая не сгорает никогда, и совсем немного табака. Поэтому у Беломора самая короткая сгораемая часть в паке — контекст выгорает стремительно, зато мундштук вечен. Мы считаем, что это самая честная метафора работы с большим контекстом из всех доступных.
94
+
95
+ ## Uninstall
96
+
97
+ Run `npx ccigarette`, go to the Install screen, hit Enter. It removes only its own `statusLine` block and never touches the rest of your settings. If ccigarette replaced another statusLine on install, uninstall puts the original back. Non-interactive: `npx ccigarette uninstall`.
98
+
99
+ ## Contributing
100
+
101
+ PRs are welcome — it's a small, well-tested codebase. MIT-licensed.
102
+
103
+ ### Develop
104
+
105
+ ```bash
106
+ git clone https://github.com/blackbalancef/ccigarette.git
107
+ cd ccigarette
108
+ npm install
109
+ npm run build # tsup → dist/
110
+ npm test # vitest
111
+ npm run typecheck # tsc --noEmit
112
+ ```
113
+
114
+ Preview every brand at several burn stages:
115
+
116
+ ```bash
117
+ node design/preview.mjs # all brands, 0–100%
118
+ node design/preview.mjs --animate marlboro # live burn animation
119
+ node design/preview.mjs --flip # filter on the left
120
+ ```
121
+
122
+ Dry-run the status line exactly like Claude Code calls it:
123
+
124
+ ```bash
125
+ echo '{"context_window":{"used_percentage":40},"model":{"display_name":"Sonnet 4.5"}}' | node dist/bin.js
126
+ ```
127
+
128
+ ## Disclaimer
129
+
130
+ `ccigarette` is a joke — a pixel cigarette that visualizes your context window. It is **not advertising** and does **not promote, endorse, or encourage** tobacco use. Smoking is a leading cause of preventable death; if you smoke, please consider quitting — let the agent smoke instead. (See [WHO — tobacco](https://www.who.int/health-topics/tobacco).)
131
+
132
+ The brand names and colors shown (Marlboro, KENT, Camel, Parliament, Lucky Strike, Беломорканал) are trademarks of their respective owners, used here only as cultural references and satire. This project is not affiliated with, sponsored by, or endorsed by any of them.
133
+
134
+ ## License
135
+
136
+ MIT. Smoking (this status line) is encouraged; the real thing kills you *and* doesn't show context usage.
@@ -0,0 +1,140 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ BRANDS,
4
+ BRAND_ORDER,
5
+ hasForeignStatusLine,
6
+ install,
7
+ isInstalled,
8
+ loadConfig,
9
+ renderCigarette,
10
+ saveConfig,
11
+ uninstall
12
+ } from "./chunk-GN3HBMWB.js";
13
+
14
+ // src/tui/app.tsx
15
+ import { render } from "ink";
16
+
17
+ // src/tui/ui.tsx
18
+ import { useState, useEffect } from "react";
19
+ import { Box, Text, useApp, useInput } from "ink";
20
+
21
+ // src/tui/preview.ts
22
+ var PREVIEW_PCT = 45;
23
+ function previewLines(config, frame) {
24
+ return renderCigarette({ config, pct: PREVIEW_PCT, frame, active: true, model: "Fable 5" });
25
+ }
26
+
27
+ // src/tui/ui.tsx
28
+ import { jsx, jsxs } from "react/jsx-runtime";
29
+ function App() {
30
+ const { exit } = useApp();
31
+ const [config, setConfig] = useState(() => loadConfig());
32
+ const [screen, setScreen] = useState("brand");
33
+ const [frame, setFrame] = useState(0);
34
+ const [cursor, setCursor] = useState(0);
35
+ const [installed, setInstalled] = useState(() => isInstalled());
36
+ const [foreign, setForeign] = useState(() => hasForeignStatusLine());
37
+ const [error, setError] = useState(null);
38
+ useEffect(() => {
39
+ const t = setInterval(() => setFrame((f) => f + 1), 400);
40
+ return () => clearInterval(t);
41
+ }, []);
42
+ const update = (patch) => {
43
+ const next = { ...config, ...patch };
44
+ setConfig(next);
45
+ saveConfig(next);
46
+ };
47
+ const brandIdx = BRAND_ORDER.indexOf(config.brand);
48
+ const toggles = [
49
+ { key: "direction", label: "Orientation (right/left)" },
50
+ { key: "showLabel", label: "Brand label" },
51
+ { key: "showPercent", label: "Percent" },
52
+ { key: "showModel", label: "Model" },
53
+ { key: "smoke", label: "Smoke" },
54
+ { key: "colorMode", label: "Color (truecolor/ansi256)" },
55
+ { key: "backdrop", label: "Backdrop (card background)" }
56
+ ];
57
+ useInput((input, key) => {
58
+ if (input === "q") {
59
+ exit();
60
+ return;
61
+ }
62
+ if (key.tab) {
63
+ setScreen((s) => s === "brand" ? "settings" : s === "settings" ? "install" : "brand");
64
+ setCursor(0);
65
+ return;
66
+ }
67
+ if (screen === "brand") {
68
+ if (key.leftArrow || key.upArrow) update({ brand: BRAND_ORDER[(brandIdx + BRAND_ORDER.length - 1) % BRAND_ORDER.length] });
69
+ if (key.rightArrow || key.downArrow) update({ brand: BRAND_ORDER[(brandIdx + 1) % BRAND_ORDER.length] });
70
+ } else if (screen === "settings") {
71
+ if (key.upArrow) setCursor((c) => (c + toggles.length - 1) % toggles.length);
72
+ if (key.downArrow) setCursor((c) => (c + 1) % toggles.length);
73
+ if (key.return || input === " ") {
74
+ const t = toggles[cursor].key;
75
+ if (t === "direction") update({ direction: config.direction === "right" ? "left" : "right" });
76
+ else if (t === "colorMode") update({ colorMode: config.colorMode === "truecolor" ? "ansi256" : "truecolor" });
77
+ else update({ [t]: !config[t] });
78
+ }
79
+ } else if (screen === "install") {
80
+ if (key.return || input === " ") {
81
+ try {
82
+ if (installed) {
83
+ uninstall();
84
+ setInstalled(false);
85
+ } else {
86
+ install();
87
+ setInstalled(true);
88
+ setForeign(false);
89
+ }
90
+ setError(null);
91
+ } catch (e) {
92
+ setError(e instanceof Error ? e.message : String(e));
93
+ }
94
+ }
95
+ }
96
+ });
97
+ const { top, bottom } = previewLines(config, frame);
98
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", paddingX: 1, children: [
99
+ /* @__PURE__ */ jsx(Text, { bold: true, children: "\u{1F6AC} ccigarette" }),
100
+ /* @__PURE__ */ jsxs(Box, { marginY: 1, flexDirection: "column", children: [
101
+ /* @__PURE__ */ jsx(Text, { children: top }),
102
+ /* @__PURE__ */ jsx(Text, { children: bottom })
103
+ ] }),
104
+ screen === "brand" && /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
105
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: "\u2190 \u2192 pick brand \xB7 Tab next \xB7 q quit" }),
106
+ /* @__PURE__ */ jsx(Text, { children: BRAND_ORDER.map((k) => k === config.brand ? `[${BRANDS[k].label}]` : ` ${BRANDS[k].label} `).join(" ") })
107
+ ] }),
108
+ screen === "settings" && /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
109
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: "\u2191 \u2193 select \xB7 Enter/Space toggle \xB7 Tab next \xB7 q quit" }),
110
+ toggles.map((t, i) => {
111
+ const v = config[t.key];
112
+ const shown = typeof v === "boolean" ? v ? "on" : "off" : String(v);
113
+ return /* @__PURE__ */ jsx(Text, { inverse: i === cursor, children: `${t.label}: ${shown}` }, t.key);
114
+ })
115
+ ] }),
116
+ screen === "install" && /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
117
+ /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
118
+ "Enter \u2014 ",
119
+ installed ? "remove from Claude Code" : "install into Claude Code",
120
+ " \xB7 Tab next \xB7 q quit"
121
+ ] }),
122
+ /* @__PURE__ */ jsx(Text, { children: installed ? "\u2713 installed (statusLine \u2192 ccigarette)" : "\u2717 not installed" }),
123
+ !installed && foreign && /* @__PURE__ */ jsx(Text, { color: "yellow", children: "\u26A0 another statusLine already in settings.json \u2014 install will replace it" }),
124
+ error && /* @__PURE__ */ jsxs(Text, { color: "red", children: [
125
+ "\u2717 ",
126
+ error
127
+ ] })
128
+ ] })
129
+ ] });
130
+ }
131
+
132
+ // src/tui/app.tsx
133
+ import { jsx as jsx2 } from "react/jsx-runtime";
134
+ async function run() {
135
+ const instance = render(/* @__PURE__ */ jsx2(App, {}));
136
+ await instance.waitUntilExit();
137
+ }
138
+ export {
139
+ run
140
+ };
package/dist/bin.js ADDED
@@ -0,0 +1,133 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ SettingsError,
4
+ VERSION,
5
+ install,
6
+ loadConfig,
7
+ renderCigarette,
8
+ settingsPath,
9
+ uninstall
10
+ } from "./chunk-GN3HBMWB.js";
11
+
12
+ // src/statusline.ts
13
+ import { statSync } from "fs";
14
+ var ACTIVE_WINDOW_MS = 1e4;
15
+ function parseStdin(raw) {
16
+ try {
17
+ const j = JSON.parse(raw);
18
+ const up = j?.context_window?.used_percentage;
19
+ const dn = j?.model?.display_name;
20
+ const tp = j?.transcript_path;
21
+ return {
22
+ usedPercentage: typeof up === "number" ? up : null,
23
+ modelDisplayName: typeof dn === "string" ? dn : null,
24
+ transcriptPath: typeof tp === "string" ? tp : null
25
+ };
26
+ } catch {
27
+ return { usedPercentage: null, modelDisplayName: null, transcriptPath: null };
28
+ }
29
+ }
30
+ function isActive(transcriptPath, now) {
31
+ if (!transcriptPath) return true;
32
+ try {
33
+ return now - statSync(transcriptPath).mtimeMs < ACTIVE_WINDOW_MS;
34
+ } catch {
35
+ return true;
36
+ }
37
+ }
38
+ function currentFrame(now) {
39
+ return Math.floor(now / 1e3) % 4;
40
+ }
41
+ function runStatusline(raw, now) {
42
+ try {
43
+ const input = parseStdin(raw);
44
+ const config = loadConfig();
45
+ const { top, bottom } = renderCigarette({
46
+ config,
47
+ pct: input.usedPercentage ?? 0,
48
+ frame: currentFrame(now),
49
+ active: isActive(input.transcriptPath, now),
50
+ model: input.modelDisplayName ?? ""
51
+ });
52
+ return `${top}
53
+ ${bottom}`;
54
+ } catch {
55
+ return "\u{1F6AC}";
56
+ }
57
+ }
58
+
59
+ // src/cli.ts
60
+ var USAGE = `ccigarette ${VERSION} \u2014 a pixel cigarette for your Claude Code status line
61
+
62
+ Usage:
63
+ ccigarette interactive TUI configurator (run in a terminal)
64
+ ccigarette install write the statusLine block into Claude Code settings
65
+ ccigarette uninstall remove it (restores a previously replaced statusLine)
66
+ ccigarette --version print the version
67
+ ccigarette --help show this help
68
+
69
+ Claude Code pipes status JSON on stdin \u2014 that renders the status line itself.`;
70
+ function runCommand(argv) {
71
+ const [cmd] = argv;
72
+ try {
73
+ switch (cmd) {
74
+ case "--version":
75
+ case "-v":
76
+ process.stdout.write(`${VERSION}
77
+ `);
78
+ return;
79
+ case "--help":
80
+ case "-h":
81
+ process.stdout.write(`${USAGE}
82
+ `);
83
+ return;
84
+ case "install":
85
+ install();
86
+ process.stdout.write(`\u2713 statusLine installed \u2192 ${settingsPath()}
87
+ `);
88
+ return;
89
+ case "uninstall":
90
+ process.stdout.write(uninstall() ? `\u2713 statusLine removed from ${settingsPath()}
91
+ ` : `nothing to remove \u2014 no ccigarette statusLine in ${settingsPath()}
92
+ `);
93
+ return;
94
+ default:
95
+ process.stderr.write(`unknown argument: ${cmd}
96
+
97
+ ${USAGE}
98
+ `);
99
+ process.exitCode = 1;
100
+ }
101
+ } catch (e) {
102
+ process.stderr.write(`${e instanceof SettingsError ? e.message : String(e)}
103
+ `);
104
+ process.exitCode = 1;
105
+ }
106
+ }
107
+ async function readStdin(stream) {
108
+ const chunks = [];
109
+ for await (const c of stream) chunks.push(Buffer.from(c));
110
+ return Buffer.concat(chunks).toString("utf8");
111
+ }
112
+ async function main() {
113
+ const argv = process.argv.slice(2);
114
+ if (argv.length > 0) {
115
+ runCommand(argv);
116
+ return;
117
+ }
118
+ if (process.stdin.isTTY) {
119
+ const { run } = await import("./app-N2XCI26M.js");
120
+ await run();
121
+ return;
122
+ }
123
+ const raw = await readStdin(process.stdin);
124
+ process.stdout.write(`${runStatusline(raw, Date.now())}
125
+ `);
126
+ }
127
+
128
+ // src/bin.ts
129
+ process.stdout.on("error", () => process.exit(0));
130
+ main().catch(() => {
131
+ process.stdout.write("\u{1F6AC}\n");
132
+ process.exit(0);
133
+ });
@@ -0,0 +1,383 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/config.ts
4
+ import { readFileSync, writeFileSync, mkdirSync, renameSync } from "fs";
5
+ import { homedir } from "os";
6
+ import { dirname, join } from "path";
7
+ var DEFAULT_CONFIG = {
8
+ brand: "marlboro",
9
+ direction: "right",
10
+ showLabel: true,
11
+ showPercent: true,
12
+ showModel: true,
13
+ smoke: true,
14
+ backdrop: false,
15
+ // transparent terminal background; true renders a dark card like in the mockup
16
+ warnAt: 90,
17
+ colorMode: "truecolor"
18
+ };
19
+ var BRANDS = ["marlboro", "kent", "camel", "parliament", "lucky", "belomor"];
20
+ var DIRECTIONS = ["right", "left"];
21
+ var COLOR_MODES = ["truecolor", "ansi256"];
22
+ function configPath() {
23
+ const base = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
24
+ return join(base, "ccigarette", "config.json");
25
+ }
26
+ var oneOf = (v, allowed, def) => allowed.includes(v) ? v : def;
27
+ var bool = (v, def) => typeof v === "boolean" ? v : def;
28
+ function normalizeConfig(partial) {
29
+ const p = partial ?? {};
30
+ const warn = typeof p.warnAt === "number" && Number.isFinite(p.warnAt) ? Math.max(0, Math.min(100, Math.round(p.warnAt))) : DEFAULT_CONFIG.warnAt;
31
+ return {
32
+ brand: oneOf(p.brand, BRANDS, DEFAULT_CONFIG.brand),
33
+ direction: oneOf(p.direction, DIRECTIONS, DEFAULT_CONFIG.direction),
34
+ showLabel: bool(p.showLabel, DEFAULT_CONFIG.showLabel),
35
+ showPercent: bool(p.showPercent, DEFAULT_CONFIG.showPercent),
36
+ showModel: bool(p.showModel, DEFAULT_CONFIG.showModel),
37
+ smoke: bool(p.smoke, DEFAULT_CONFIG.smoke),
38
+ backdrop: bool(p.backdrop, DEFAULT_CONFIG.backdrop),
39
+ warnAt: warn,
40
+ colorMode: oneOf(p.colorMode, COLOR_MODES, DEFAULT_CONFIG.colorMode)
41
+ };
42
+ }
43
+ function loadConfig() {
44
+ try {
45
+ return normalizeConfig(JSON.parse(readFileSync(configPath(), "utf8")));
46
+ } catch {
47
+ return { ...DEFAULT_CONFIG };
48
+ }
49
+ }
50
+ function saveConfig(config) {
51
+ const p = configPath();
52
+ mkdirSync(dirname(p), { recursive: true });
53
+ const tmp = `${p}.tmp`;
54
+ writeFileSync(tmp, JSON.stringify(normalizeConfig(config), null, 2));
55
+ renameSync(tmp, p);
56
+ }
57
+
58
+ // src/render/palette.ts
59
+ var PALETTE = {
60
+ paper: "#F2F0EB",
61
+ // paper from the mockup: readable thanks to the dark backdrop (termBg)
62
+ termBg: "#0C0C10",
63
+ // background of the terminal card from the mockup — backdrop behind the art
64
+ ashTip: "#B0AFAA",
65
+ emberHot: "#FFB03A",
66
+ emberCore: "#FF6B35",
67
+ emberDeep: "#E8491D",
68
+ unlit: "#4A4A4A",
69
+ smokeNear: "#9E9E9E",
70
+ smokeFar: "#5E5E5E",
71
+ meta: "#6E6E6E",
72
+ warn: "#E8491D"
73
+ };
74
+
75
+ // src/render/brands.ts
76
+ var ART_WIDTH = 20;
77
+ var BRANDS2 = {
78
+ marlboro: {
79
+ label: "Marlboro",
80
+ labelColor: "#D42A30",
81
+ body: 11,
82
+ band: [["\u2588", "#D42A30"]],
83
+ filter: [["\u2590", "#C8813C"], ["\u2588", "#C8813C"], ["\u2592", "#A9662B"], ["\u2588", "#C8813C"], ["\u258C", "#C8813C"]]
84
+ },
85
+ kent: {
86
+ label: "KENT",
87
+ labelColor: "#1B4F9C",
88
+ body: 12,
89
+ band: [["\u2588", "#1B4F9C"]],
90
+ filter: [["\u2590", "#EDEDE6"], ["\u2588", "#EDEDE6"], ["\u2588", "#EDEDE6"], ["\u258C", "#EDEDE6"]]
91
+ },
92
+ camel: {
93
+ label: "Camel",
94
+ labelColor: "#B8804A",
95
+ body: 11,
96
+ band: [["\u2588", "#E8A33D"]],
97
+ filter: [["\u2590", "#C8813C"], ["\u2592", "#A9662B"], ["\u2588", "#C8813C"], ["\u2592", "#A9662B"], ["\u258C", "#C8813C"]]
98
+ },
99
+ parliament: {
100
+ label: "Parliament",
101
+ labelColor: "#4A7BC4",
102
+ body: 11,
103
+ band: [["\u2588", "#24549C"]],
104
+ filter: [["\u2590", "#EDEDE6"], ["\u2588", "#EDEDE6"], ["\u2593", "#9FA8B8"], [" ", ""], ["\u258C", "#EDEDE6"]]
105
+ },
106
+ lucky: {
107
+ label: "LUCKY STRIKE",
108
+ labelColor: "#CE1126",
109
+ body: 15,
110
+ band: [],
111
+ filter: [["\u2588", "#CE1126"], ["\u258C", "#F2F0EB"]]
112
+ },
113
+ belomor: {
114
+ label: "Belomorkanal",
115
+ labelColor: "#6B99BC",
116
+ body: 9,
117
+ paper: "#EFE9DC",
118
+ band: [],
119
+ filter: [
120
+ ["\u2590", "#D9CBAA"],
121
+ ["\u2550", "#D9CBAA"],
122
+ ["\u2550", "#D9CBAA"],
123
+ ["\u2550", "#D9CBAA"],
124
+ ["\u2550", "#D9CBAA"],
125
+ ["\u2550", "#D9CBAA"],
126
+ ["\u2550", "#D9CBAA"],
127
+ ["\u258C", "#D9CBAA"]
128
+ ]
129
+ }
130
+ };
131
+ var BRAND_ORDER = ["marlboro", "kent", "camel", "parliament", "lucky", "belomor"];
132
+ var SMOKE_FRAMES = [
133
+ [[-1, "~", PALETTE.smokeNear]],
134
+ [[-2, "\xB0", PALETTE.smokeFar], [-1, "~", PALETTE.smokeNear]],
135
+ [[-3, "\xB0", PALETTE.smokeFar], [-2, "~", PALETTE.smokeNear]],
136
+ [[-3, "\xB0", PALETTE.smokeFar]]
137
+ ];
138
+ var MIRROR = { "\u2590": "\u258C", "\u258C": "\u2590" };
139
+
140
+ // package.json
141
+ var package_default = {
142
+ name: "ccigarette",
143
+ version: "0.1.0",
144
+ description: "A smouldering pixel cigarette that burns down as your Claude Code context window fills up.",
145
+ type: "module",
146
+ license: "MIT",
147
+ author: "Ivan Matveev",
148
+ repository: { type: "git", url: "git+https://github.com/blackbalancef/ccigarette.git" },
149
+ homepage: "https://github.com/blackbalancef/ccigarette#readme",
150
+ bugs: "https://github.com/blackbalancef/ccigarette/issues",
151
+ exports: {},
152
+ keywords: [
153
+ "claude-code",
154
+ "statusline",
155
+ "status-line",
156
+ "cli",
157
+ "terminal",
158
+ "cigarette",
159
+ "context-window",
160
+ "ansi",
161
+ "tui"
162
+ ],
163
+ bin: { ccigarette: "dist/bin.js" },
164
+ files: ["dist", "README.md", "LICENSE"],
165
+ engines: { node: ">=22" },
166
+ scripts: {
167
+ build: "tsup",
168
+ test: "vitest run",
169
+ "test:watch": "vitest",
170
+ typecheck: "tsc --noEmit",
171
+ prepublishOnly: "npm run build",
172
+ "demo:record": "vhs docs/demo.tape",
173
+ "demo:crop": 'ffmpeg -y -i docs/demo.raw.gif -filter_complex "[0:v]crop=840:120:0:8[c];[c]split[a][b];[a]palettegen=max_colors=64[p];[b][p]paletteuse=dither=bayer:bayer_scale=5" docs/demo.gif',
174
+ demo: "npm run build && npm run demo:record && npm run demo:crop"
175
+ },
176
+ dependencies: {
177
+ ink: "^7.1.0",
178
+ react: "^19.2.0"
179
+ },
180
+ devDependencies: {
181
+ "@types/node": "^22.0.0",
182
+ "@types/react": "^19.2.0",
183
+ "ink-testing-library": "^4.0.0",
184
+ tsup: "^8.5.0",
185
+ typescript: "^6.0.0",
186
+ vitest: "^4.1.0"
187
+ }
188
+ };
189
+
190
+ // src/version.ts
191
+ var VERSION = package_default.version;
192
+
193
+ // src/install.ts
194
+ import { copyFileSync, existsSync, mkdirSync as mkdirSync2, readFileSync as readFileSync2, renameSync as renameSync2, rmSync, writeFileSync as writeFileSync2 } from "fs";
195
+ import { homedir as homedir2 } from "os";
196
+ import { dirname as dirname2, join as join2 } from "path";
197
+ var COMMAND = `npx -y ccigarette@${VERSION}`;
198
+ var STATUSLINE_BLOCK = { type: "command", command: COMMAND, padding: 0, refreshInterval: 1 };
199
+ var SettingsError = class extends Error {
200
+ };
201
+ function settingsPath() {
202
+ const dir = process.env.CLAUDE_CONFIG_DIR || join2(homedir2(), ".claude");
203
+ return join2(dir, "settings.json");
204
+ }
205
+ function backupPath() {
206
+ return join2(dirname2(settingsPath()), "ccigarette.statusline-backup.json");
207
+ }
208
+ function readSettings() {
209
+ let raw;
210
+ try {
211
+ raw = readFileSync2(settingsPath(), "utf8");
212
+ } catch (e) {
213
+ if (e.code === "ENOENT") return {};
214
+ throw new SettingsError(`cannot read ${settingsPath()}: ${e.message}`);
215
+ }
216
+ try {
217
+ const parsed = JSON.parse(raw);
218
+ return parsed && typeof parsed === "object" ? parsed : {};
219
+ } catch {
220
+ throw new SettingsError(`${settingsPath()} is not valid JSON \u2014 fix or remove it, then retry`);
221
+ }
222
+ }
223
+ function readSettingsSafe() {
224
+ try {
225
+ return readSettings();
226
+ } catch {
227
+ return {};
228
+ }
229
+ }
230
+ function writeSettings(settings) {
231
+ const p = settingsPath();
232
+ mkdirSync2(dirname2(p), { recursive: true });
233
+ if (existsSync(p)) copyFileSync(p, `${p}.bak`);
234
+ const tmp = `${p}.tmp`;
235
+ writeFileSync2(tmp, JSON.stringify(settings, null, 2));
236
+ renameSync2(tmp, p);
237
+ }
238
+ function isOurs(statusLine) {
239
+ return !!statusLine && typeof statusLine === "object" && typeof statusLine.command === "string" && statusLine.command.includes("ccigarette");
240
+ }
241
+ function isInstalled() {
242
+ return isOurs(readSettingsSafe().statusLine);
243
+ }
244
+ function hasForeignStatusLine() {
245
+ const sl = readSettingsSafe().statusLine;
246
+ return sl !== void 0 && !isOurs(sl);
247
+ }
248
+ function install() {
249
+ const s = readSettings();
250
+ if (s.statusLine !== void 0 && !isOurs(s.statusLine)) {
251
+ writeFileSync2(backupPath(), JSON.stringify(s.statusLine, null, 2));
252
+ }
253
+ s.statusLine = { ...STATUSLINE_BLOCK };
254
+ writeSettings(s);
255
+ }
256
+ function uninstall() {
257
+ const s = readSettings();
258
+ if (!isOurs(s.statusLine)) return false;
259
+ let previous;
260
+ try {
261
+ previous = JSON.parse(readFileSync2(backupPath(), "utf8"));
262
+ } catch {
263
+ previous = void 0;
264
+ }
265
+ if (previous !== void 0) {
266
+ s.statusLine = previous;
267
+ writeSettings(s);
268
+ rmSync(backupPath(), { force: true });
269
+ } else {
270
+ delete s.statusLine;
271
+ writeSettings(s);
272
+ }
273
+ return true;
274
+ }
275
+
276
+ // src/render/ansi.ts
277
+ var RESET = "\x1B[0m";
278
+ var BOLD = "\x1B[1m";
279
+ function hexToRgb(hex) {
280
+ const int = parseInt(hex.slice(1), 16);
281
+ return [int >> 16 & 255, int >> 8 & 255, int & 255];
282
+ }
283
+ function toAnsi256(hex) {
284
+ const [r, g, b] = hexToRgb(hex);
285
+ if (r === g && g === b) {
286
+ if (r < 8) return 16;
287
+ if (r > 248) return 231;
288
+ return 232 + Math.round((r - 8) / 247 * 24);
289
+ }
290
+ const toLevel = (v) => Math.round(v / 255 * 5);
291
+ return 16 + 36 * toLevel(r) + 6 * toLevel(g) + toLevel(b);
292
+ }
293
+ function sgr(layer, hex, colorMode) {
294
+ if (colorMode === "ansi256") return `\x1B[${layer};5;${toAnsi256(hex)}m`;
295
+ const [r, g, b] = hexToRgb(hex);
296
+ return `\x1B[${layer};2;${r};${g};${b}m`;
297
+ }
298
+ function paint(text, hex, colorMode, bold = false, bgHex = "") {
299
+ const fg = hex ? sgr(38, hex, colorMode) : "";
300
+ const bg = bgHex ? sgr(48, bgHex, colorMode) : "";
301
+ if (!fg && !bg) return text;
302
+ return (bold ? BOLD : "") + fg + bg + text + RESET;
303
+ }
304
+
305
+ // src/render/cigarette.ts
306
+ function renderCigarette(input) {
307
+ const { config, frame, active, model } = input;
308
+ const b = BRANDS2[config.brand];
309
+ const paperColor = b.paper ?? PALETTE.paper;
310
+ const pct = Number.isFinite(input.pct) ? Math.max(0, Math.min(100, input.pct)) : 0;
311
+ const cm = config.colorMode;
312
+ const flip = config.direction === "left";
313
+ const burnt = Math.round(pct / 100 * b.body);
314
+ const rest = b.body - burnt;
315
+ const lit = pct > 0;
316
+ const cells = [];
317
+ for (let i = 0; i < burnt; i++) cells.push(["\u2581", PALETTE.ashTip]);
318
+ cells.push([lit && burnt > 0 ? "\u2592" : " ", PALETTE.ashTip]);
319
+ if (lit) {
320
+ const hot = active ? frame % 2 === 0 : true;
321
+ cells.push(["\u2593", hot ? PALETTE.emberDeep : PALETTE.emberCore]);
322
+ cells.push(["\u2588", hot ? PALETTE.emberCore : PALETTE.emberHot]);
323
+ } else {
324
+ cells.push(["\u2593", PALETTE.unlit], ["\u2593", PALETTE.unlit]);
325
+ }
326
+ for (let i = 0; i < rest; i++) cells.push(["\u2588", paperColor]);
327
+ cells.push(...b.band, ...b.filter);
328
+ const smoke = Array.from({ length: ART_WIDTH }, () => null);
329
+ if (lit && active && config.smoke) {
330
+ const anchor = 1 + burnt;
331
+ const fi = (frame % SMOKE_FRAMES.length + SMOKE_FRAMES.length) % SMOKE_FRAMES.length;
332
+ for (const [dx, ch, col] of SMOKE_FRAMES[fi]) {
333
+ const x = flip ? ART_WIDTH - 1 - (anchor + dx) : anchor + dx;
334
+ if (x >= 0 && x < ART_WIDTH) smoke[x] = [ch, col];
335
+ }
336
+ }
337
+ const bottomCells = flip ? cells.slice().reverse().map(([ch, col]) => [MIRROR[ch] ?? ch, col]) : cells;
338
+ const bg = config.backdrop ? PALETTE.termBg : "";
339
+ const top = smoke.map((c) => c ? paint(c[0], c[1], cm, false, bg) : paint(" ", "", cm, false, bg)).join("");
340
+ const art = bottomCells.map(([ch, col]) => paint(ch, col, cm, false, bg)).join("");
341
+ let tailPlain = "";
342
+ const tail = [];
343
+ if (config.showLabel) {
344
+ const label = b.label.padEnd(13);
345
+ tailPlain += label;
346
+ tail.push(paint(label, b.labelColor, cm, true, bg));
347
+ }
348
+ const metaPlain = [];
349
+ const meta = [];
350
+ if (config.showPercent) {
351
+ const warn = pct >= config.warnAt;
352
+ const pctText = `${String(Math.round(pct)).padStart(3)}%`;
353
+ metaPlain.push(pctText);
354
+ meta.push(paint(pctText, warn ? PALETTE.warn : PALETTE.meta, cm, warn, bg));
355
+ }
356
+ if (config.showModel && model) {
357
+ const shown = model.length > 24 ? `${model.slice(0, 23)}\u2026` : model;
358
+ metaPlain.push(shown);
359
+ meta.push(paint(shown, PALETTE.meta, cm, false, bg));
360
+ }
361
+ if (meta.length) {
362
+ tailPlain += metaPlain.join(" \xB7 ");
363
+ tail.push(meta.join(paint(" \xB7 ", PALETTE.meta, cm, false, bg)));
364
+ }
365
+ if (!tail.length) return { top, bottom: art };
366
+ const pad = paint(" ".repeat(2 + tailPlain.length), "", cm, false, bg);
367
+ return { top: top + pad, bottom: art + paint(" ", "", cm, false, bg) + tail.join("") };
368
+ }
369
+
370
+ export {
371
+ loadConfig,
372
+ saveConfig,
373
+ BRANDS2 as BRANDS,
374
+ BRAND_ORDER,
375
+ renderCigarette,
376
+ VERSION,
377
+ SettingsError,
378
+ settingsPath,
379
+ isInstalled,
380
+ hasForeignStatusLine,
381
+ install,
382
+ uninstall
383
+ };
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "ccigarette",
3
+ "version": "0.1.0",
4
+ "description": "A smouldering pixel cigarette that burns down as your Claude Code context window fills up.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Ivan Matveev",
8
+ "repository": { "type": "git", "url": "git+https://github.com/blackbalancef/ccigarette.git" },
9
+ "homepage": "https://github.com/blackbalancef/ccigarette#readme",
10
+ "bugs": "https://github.com/blackbalancef/ccigarette/issues",
11
+ "exports": {},
12
+ "keywords": [
13
+ "claude-code",
14
+ "statusline",
15
+ "status-line",
16
+ "cli",
17
+ "terminal",
18
+ "cigarette",
19
+ "context-window",
20
+ "ansi",
21
+ "tui"
22
+ ],
23
+ "bin": { "ccigarette": "dist/bin.js" },
24
+ "files": ["dist", "README.md", "LICENSE"],
25
+ "engines": { "node": ">=22" },
26
+ "scripts": {
27
+ "build": "tsup",
28
+ "test": "vitest run",
29
+ "test:watch": "vitest",
30
+ "typecheck": "tsc --noEmit",
31
+ "prepublishOnly": "npm run build",
32
+ "demo:record": "vhs docs/demo.tape",
33
+ "demo:crop": "ffmpeg -y -i docs/demo.raw.gif -filter_complex \"[0:v]crop=840:120:0:8[c];[c]split[a][b];[a]palettegen=max_colors=64[p];[b][p]paletteuse=dither=bayer:bayer_scale=5\" docs/demo.gif",
34
+ "demo": "npm run build && npm run demo:record && npm run demo:crop"
35
+ },
36
+ "dependencies": {
37
+ "ink": "^7.1.0",
38
+ "react": "^19.2.0"
39
+ },
40
+ "devDependencies": {
41
+ "@types/node": "^22.0.0",
42
+ "@types/react": "^19.2.0",
43
+ "ink-testing-library": "^4.0.0",
44
+ "tsup": "^8.5.0",
45
+ "typescript": "^6.0.0",
46
+ "vitest": "^4.1.0"
47
+ }
48
+ }