space-statusline 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 Oscar Angel
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,150 @@
1
+ # space-statusline
2
+
3
+ Un status line **space-synthwave** ("Outrun Horizon") para
4
+ [Claude Code](https://claude.com/claude-code), **configurable mediante un wizard CLI**.
5
+
6
+ El render corre en **bash** (rápido, sin cold-start) · la configuración se hace en
7
+ **Node + TypeScript** · **truecolor 24-bit** · glifos **Nerd Font** con fallback Unicode.
8
+
9
+ ## Cómo se ve
10
+
11
+ Tres líneas (en modo `multi`, el default):
12
+
13
+ 1. Directorio + estado de git (rama, archivos nuevos/modificados, ahead/behind).
14
+ 2. Regla-horizonte con gradiente synthwave.
15
+ 3. Modelo · barra de contexto · costo · tokens · hora.
16
+
17
+ También hay un modo de **una sola línea** (`single`).
18
+
19
+ ## Requisitos
20
+
21
+ - [Claude Code](https://claude.com/claude-code).
22
+ - `bash` 4+, `jq` y `git` para el render (el wizard necesita Node ≥ 20).
23
+ - Una **Nerd Font** (p. ej. Cascadia Code NF) y una terminal con **truecolor** para la
24
+ estética completa. Hay fallbacks: sin `jq` cae a una línea mínima; con glifos
25
+ Unicode no hace falta Nerd Font; sin truecolor degrada a 256 colores.
26
+
27
+ ## Instalación
28
+
29
+ Una vez publicado en npm:
30
+
31
+ ```bash
32
+ pnpm dlx space-statusline init # wizard + opción de instalar en Claude Code
33
+ # o instalación global:
34
+ pnpm add -g space-statusline && space-statusline init
35
+ ```
36
+
37
+ Desde el código (mientras tanto):
38
+
39
+ ```bash
40
+ git clone <repo> && cd space-statusline
41
+ pnpm install && pnpm build
42
+ node dist/cli.js init
43
+ ```
44
+
45
+ El wizard escribe la config y ofrece **instalarse en Claude Code** (gestiona solo la
46
+ clave `statusLine` de `~/.claude/settings.json`, con backup; nunca toca permisos).
47
+
48
+ ## Uso
49
+
50
+ | Comando | Qué hace |
51
+ |---|---|
52
+ | `init` | Wizard completo (tema, secciones, glifos, layout, preview) + opción de instalar. |
53
+ | `config` | Re-ejecuta el wizard para editar la config existente. |
54
+ | `theme <name>` | Cambio rápido de preset: `outrun-horizon`, `sunset`, `vaporwave`, `mono`. |
55
+ | `install` | Conecta el status line a Claude Code (merge seguro de `settings.json`). |
56
+ | `uninstall` | Quita la entrada `statusLine` (deja un backup). |
57
+ | `preview` | Renderiza con un input de ejemplo para ver el resultado. |
58
+ | `doctor` | Verifica `jq`, `git`, soporte truecolor y Nerd Font. |
59
+
60
+ ## Configuración
61
+
62
+ La config vive en `~/.config/space-statusline/config.json` (ruta XDG; se puede
63
+ sobrescribir con `$SPACE_STATUSLINE_CONFIG`). El schema se valida con `zod` y permite
64
+ ajustar:
65
+
66
+ - **Tema** — preset o gradiente custom (start/end en hex) + paleta de colores.
67
+ - **Secciones** — cuáles mostrar (`dir`, `git`, `model`, `context`, `cost`, `tokens`,
68
+ `clock`) y en qué orden.
69
+ - **Glifos** — `nerdfont` o `unicode`, personalizables.
70
+ - **Layout** — `multi`/`single`, separador, ancho del horizonte y de la barra de
71
+ contexto, uppercase, formato de hora (strftime).
72
+ - **Umbrales** — porcentajes de contexto para los colores de aviso/peligro.
73
+
74
+ El bash lee ese JSON en cada render; si falta o es inválido, usa defaults embebidos.
75
+
76
+ ## Temas
77
+
78
+ `outrun-horizon` (default, violeta→magenta) · `sunset` (cálido, naranja→rosa) ·
79
+ `vaporwave` (frío, cian→violeta) · `mono` (minimal, grises). Más `custom` desde el
80
+ wizard.
81
+
82
+ ## Terminal recomendada (Windows Terminal)
83
+
84
+ Para que el gradiente y los glifos se vean como en la captura, usá
85
+ **CaskaydiaCove Nerd Font** y el esquema de color **Outrun Horizon**.
86
+
87
+ 1. Agregá este esquema en `settings.json` de Windows Terminal, dentro de `"schemes"`:
88
+
89
+ ```json
90
+ {
91
+ "name": "Outrun Horizon",
92
+ "background": "#0D0A17",
93
+ "foreground": "#E7DEFB",
94
+ "cursorColor": "#FF6CF0",
95
+ "selectionBackground": "#7A3CFF",
96
+ "black": "#1A1622",
97
+ "red": "#FF5FB4",
98
+ "green": "#62E6B0",
99
+ "yellow": "#FFB86B",
100
+ "blue": "#A96BFF",
101
+ "purple": "#FF6CF0",
102
+ "cyan": "#4BE4E8",
103
+ "white": "#E7DEFB",
104
+ "brightBlack": "#5C5181",
105
+ "brightRed": "#FF7AC0",
106
+ "brightGreen": "#7DF5C4",
107
+ "brightYellow": "#FFC98A",
108
+ "brightBlue": "#C08BFF",
109
+ "brightPurple": "#FF8FF4",
110
+ "brightCyan": "#74EEF1",
111
+ "brightWhite": "#FFFFFF"
112
+ }
113
+ ```
114
+
115
+ 2. Aplicalo en `"defaults"` (afecta a todos los perfiles) o en un perfil puntual:
116
+
117
+ ```json
118
+ "defaults": {
119
+ "colorScheme": "Outrun Horizon",
120
+ "font": { "face": "CaskaydiaCove Nerd Font", "size": 11 },
121
+ "opacity": 92,
122
+ "useAcrylic": true
123
+ }
124
+ ```
125
+
126
+ ## Cómo funciona
127
+
128
+ El render se mantiene en **bash + jq** porque Claude Code lo invoca muy seguido y
129
+ arrancar Node en cada render añadiría cold-start. El wizard (Node/TS) **no
130
+ reimplementa el render**: solo edita la config JSON que el bash consume. El puente
131
+ entre ambos es ese archivo. El render apunta a < 50 ms.
132
+
133
+ ## Desarrollo
134
+
135
+ ```bash
136
+ pnpm install
137
+ pnpm build # tsc -> dist/
138
+ pnpm typecheck # tsc --noEmit
139
+ pnpm lint # eslint
140
+ ```
141
+
142
+ Probar el runtime directamente:
143
+
144
+ ```bash
145
+ echo '{"model":{"display_name":"Opus 4.1"},"workspace":{"current_dir":"'"$PWD"'"},"context_window":{"used_percentage":58},"cost":{"total_cost_usd":1.24}}' | bash runtime/statusline.sh
146
+ ```
147
+
148
+ ## Licencia
149
+
150
+ MIT — ver [`LICENSE`](./LICENSE).
package/dist/claude.js ADDED
@@ -0,0 +1,98 @@
1
+ import { copyFileSync, chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+ import { getConfigDir } from './config.js';
5
+ import { getRuntimeScriptPath } from './preview.js';
6
+ /**
7
+ * Keys the installer must NEVER create, change, or remove. The merge only ever
8
+ * touches `statusLine`; this list is asserted before/after as defense in depth
9
+ * (SPEC §5.5, §9 RNF3).
10
+ */
11
+ const PROTECTED_KEYS = ['permissions', 'defaultMode', 'skipAutoPermissionPrompt'];
12
+ export function getClaudeDir() {
13
+ return join(homedir(), '.claude');
14
+ }
15
+ export function getClaudeSettingsPath() {
16
+ return join(getClaudeDir(), 'settings.json');
17
+ }
18
+ /** Stable location the runtime script is copied to, independent of the npm install dir. */
19
+ export function getInstalledScriptPath() {
20
+ return join(getConfigDir(), 'statusline.sh');
21
+ }
22
+ /** Parses settings.json, returning {} if it does not exist. Throws on invalid JSON. */
23
+ function readSettings(path) {
24
+ if (!existsSync(path))
25
+ return {};
26
+ const raw = readFileSync(path, 'utf8');
27
+ if (raw.trim().length === 0)
28
+ return {};
29
+ try {
30
+ return JSON.parse(raw);
31
+ }
32
+ catch {
33
+ throw new Error(`${path} is not valid JSON. Refusing to touch it — fix or remove it first.`);
34
+ }
35
+ }
36
+ /** Snapshot of the protected keys, used to assert they are never modified. */
37
+ function snapshotProtected(settings) {
38
+ const subset = {};
39
+ for (const key of PROTECTED_KEYS) {
40
+ if (key in settings)
41
+ subset[key] = settings[key];
42
+ }
43
+ return JSON.stringify(subset);
44
+ }
45
+ /** Backs up an existing settings.json to a timestamped sibling. Returns its path. */
46
+ function backupSettings(path) {
47
+ if (!existsSync(path))
48
+ return null;
49
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
50
+ const backupPath = `${path}.bak.${stamp}`;
51
+ copyFileSync(path, backupPath);
52
+ return backupPath;
53
+ }
54
+ /** Serializes and writes settings as pretty JSON with a trailing newline. */
55
+ function writeSettings(path, settings) {
56
+ mkdirSync(dirname(path), { recursive: true });
57
+ writeFileSync(path, JSON.stringify(settings, null, 2) + '\n', 'utf8');
58
+ }
59
+ /**
60
+ * Copies the runtime script to a stable path and points Claude Code's
61
+ * `statusLine` at it, preserving every other settings key. Backs up first.
62
+ */
63
+ export function install() {
64
+ const scriptPath = getInstalledScriptPath();
65
+ mkdirSync(dirname(scriptPath), { recursive: true });
66
+ copyFileSync(getRuntimeScriptPath(), scriptPath);
67
+ chmodSync(scriptPath, 0o755);
68
+ const settingsPath = getClaudeSettingsPath();
69
+ const settings = readSettings(settingsPath);
70
+ const before = snapshotProtected(settings);
71
+ const backupPath = backupSettings(settingsPath);
72
+ // Touch ONLY statusLine; every other key is preserved by reference.
73
+ settings.statusLine = { type: 'command', command: `bash ${scriptPath}` };
74
+ assertProtectedUnchanged(before, settings, settingsPath);
75
+ writeSettings(settingsPath, settings);
76
+ return { settingsPath, scriptPath, backupPath };
77
+ }
78
+ /** Removes the `statusLine` key, preserving everything else. Backs up first. */
79
+ export function uninstall() {
80
+ const settingsPath = getClaudeSettingsPath();
81
+ const settings = readSettings(settingsPath);
82
+ const hadStatusLine = 'statusLine' in settings;
83
+ const before = snapshotProtected(settings);
84
+ if (!hadStatusLine) {
85
+ return { settingsPath, backupPath: null, hadStatusLine: false };
86
+ }
87
+ const backupPath = backupSettings(settingsPath);
88
+ delete settings.statusLine;
89
+ assertProtectedUnchanged(before, settings, settingsPath);
90
+ writeSettings(settingsPath, settings);
91
+ return { settingsPath, backupPath, hadStatusLine: true };
92
+ }
93
+ /** Throws if any protected key changed between the snapshot and now. */
94
+ function assertProtectedUnchanged(before, settings, path) {
95
+ if (snapshotProtected(settings) !== before) {
96
+ throw new Error(`Aborted: a protected key would have changed in ${path}. No write performed.`);
97
+ }
98
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,148 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import pc from 'picocolors';
6
+ import * as p from '@clack/prompts';
7
+ import { getConfigPath, getDefaults, saveConfig, tryLoadConfig } from './config.js';
8
+ import { PRESET_NAMES, presets } from './themes.js';
9
+ import { runWizard } from './wizard.js';
10
+ import { install, uninstall } from './claude.js';
11
+ import { renderPreview } from './preview.js';
12
+ import { runDoctor } from './doctor.js';
13
+ /** Reads this package's version from package.json (one level above dist/). */
14
+ function readVersion() {
15
+ const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
16
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
17
+ return pkg.version;
18
+ }
19
+ /** `init`: run the full wizard from scratch (pre-filled if a config exists). */
20
+ async function runInit() {
21
+ const existing = tryLoadConfig();
22
+ const config = await runWizard(existing);
23
+ if (!config)
24
+ return;
25
+ const path = getConfigPath();
26
+ saveConfig(config, path);
27
+ const wantsInstall = await p.confirm({ message: 'Install into Claude Code now?', initialValue: true });
28
+ if (p.isCancel(wantsInstall) || !wantsInstall) {
29
+ p.outro(`${pc.green('Saved')} ${pc.dim(path)}\n Run ${pc.cyan('space-statusline install')} when you're ready.`);
30
+ return;
31
+ }
32
+ const result = install();
33
+ p.outro(`${pc.green('Installed.')} ${pc.dim(result.settingsPath)}` +
34
+ (result.backupPath ? `\n Backup: ${pc.dim(result.backupPath)}` : ''));
35
+ }
36
+ /** `install`: connect the status line to Claude Code (safe merge of settings.json). */
37
+ function runInstall() {
38
+ const result = install();
39
+ console.log(pc.green('Installed.'));
40
+ console.log(` Script: ${pc.dim(result.scriptPath)}`);
41
+ console.log(` Settings: ${pc.dim(result.settingsPath)}`);
42
+ if (result.backupPath)
43
+ console.log(` Backup: ${pc.dim(result.backupPath)}`);
44
+ }
45
+ /** `uninstall`: remove the statusLine entry, preserving everything else. */
46
+ function runUninstall() {
47
+ const result = uninstall();
48
+ if (!result.hadStatusLine) {
49
+ console.log(pc.yellow('No statusLine entry found — nothing to remove.'));
50
+ return;
51
+ }
52
+ console.log(pc.green('Uninstalled the statusLine entry.'));
53
+ if (result.backupPath) {
54
+ console.log(` A backup of your previous settings is at ${pc.dim(result.backupPath)}`);
55
+ }
56
+ }
57
+ /** `config`: edit an existing config (errors if there is none yet). */
58
+ async function runConfig() {
59
+ const existing = tryLoadConfig();
60
+ if (!existing) {
61
+ console.error(pc.yellow(`No config found at ${getConfigPath()}.`));
62
+ console.error(`Run ${pc.cyan('space-statusline init')} first.`);
63
+ process.exitCode = 1;
64
+ return;
65
+ }
66
+ const config = await runWizard(existing);
67
+ if (!config)
68
+ return;
69
+ const path = getConfigPath();
70
+ saveConfig(config, path);
71
+ p.outro(`${pc.green('Saved')} ${pc.dim(path)}`);
72
+ }
73
+ /** `theme [name]`: quickly switch to a preset without the full wizard. */
74
+ function runTheme(args) {
75
+ const name = args[0];
76
+ const valid = PRESET_NAMES;
77
+ if (!name || !valid.includes(name)) {
78
+ console.error(name ? pc.red(`Unknown theme: ${name}`) : pc.yellow('Usage: space-statusline theme <name>'));
79
+ console.error(`Available: ${PRESET_NAMES.join(', ')}`);
80
+ process.exitCode = 1;
81
+ return;
82
+ }
83
+ const config = tryLoadConfig() ?? getDefaults();
84
+ const preset = name;
85
+ config.theme = { preset, ...presets[preset] };
86
+ const path = getConfigPath();
87
+ saveConfig(config, path);
88
+ console.log(`${pc.green('Theme set to')} ${pc.magenta(name)} ${pc.dim('— ' + path)}`);
89
+ }
90
+ /** `preview`: render the current config (or defaults) with mock input. */
91
+ function runPreview() {
92
+ const config = tryLoadConfig() ?? undefined;
93
+ process.stdout.write(renderPreview(config));
94
+ process.stdout.write('\n');
95
+ }
96
+ const commands = {
97
+ init: runInit,
98
+ config: runConfig,
99
+ theme: runTheme,
100
+ install: runInstall,
101
+ uninstall: runUninstall,
102
+ preview: runPreview,
103
+ doctor: runDoctor,
104
+ };
105
+ function printHelp() {
106
+ const title = pc.magenta(pc.bold('space-statusline'));
107
+ console.log(`
108
+ ${title} — synthwave status line for Claude Code
109
+
110
+ ${pc.bold('Usage:')} space-statusline <command> [options]
111
+
112
+ ${pc.bold('Commands:')}
113
+ ${pc.cyan('init')} Run the full wizard and optionally install into Claude Code
114
+ ${pc.cyan('config')} Re-run the wizard to edit the existing config
115
+ ${pc.cyan('theme')} ${pc.dim('[name]')} Quickly switch to a preset theme
116
+ ${pc.cyan('install')} Connect the status line to Claude Code (settings.json)
117
+ ${pc.cyan('uninstall')} Remove the status line entry and offer to restore a backup
118
+ ${pc.cyan('preview')} Render with mock input to preview the result
119
+ ${pc.cyan('doctor')} Check the environment (jq, git, truecolor, Nerd Font)
120
+
121
+ ${pc.bold('Options:')}
122
+ -h, --help Show this help
123
+ -v, --version Show the version
124
+ `);
125
+ }
126
+ async function main() {
127
+ const [, , command, ...rest] = process.argv;
128
+ if (!command || command === 'help' || command === '--help' || command === '-h') {
129
+ printHelp();
130
+ return;
131
+ }
132
+ if (command === 'version' || command === '--version' || command === '-v') {
133
+ console.log(readVersion());
134
+ return;
135
+ }
136
+ const handler = commands[command];
137
+ if (!handler) {
138
+ console.error(pc.red(`Unknown command: ${command}`));
139
+ printHelp();
140
+ process.exitCode = 1;
141
+ return;
142
+ }
143
+ await handler(rest);
144
+ }
145
+ main().catch((error) => {
146
+ console.error(pc.red(error instanceof Error ? error.message : String(error)));
147
+ process.exitCode = 1;
148
+ });
package/dist/config.js ADDED
@@ -0,0 +1,176 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+ import { z } from 'zod';
5
+ import { PRESET_NAMES, presets } from './themes.js';
6
+ // ── Schema (mirrors SPEC §5.2) ──────────────────────────────────────────────
7
+ const HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
8
+ const hexColor = z.string().regex(HEX_COLOR, 'Expected a hex color like #RRGGBB');
9
+ /** The renderable sections, in their canonical (default) order. */
10
+ export const SECTION_KEYS = ['dir', 'git', 'model', 'context', 'cost', 'tokens', 'clock'];
11
+ const sectionKey = z.enum(SECTION_KEYS);
12
+ // A theme's `preset` is a built-in name or the synthetic "custom".
13
+ const presetOrCustom = [...PRESET_NAMES, 'custom'];
14
+ const themeSchema = z.strictObject({
15
+ preset: z.enum(presetOrCustom),
16
+ gradient: z.strictObject({ start: hexColor, end: hexColor }),
17
+ colors: z.strictObject({
18
+ accent: hexColor,
19
+ magenta: hexColor,
20
+ cyan: hexColor,
21
+ green: hexColor,
22
+ amber: hexColor,
23
+ dim: hexColor,
24
+ ctxWarn: hexColor,
25
+ ctxDanger: hexColor,
26
+ }),
27
+ });
28
+ const sectionsSchema = z.strictObject({
29
+ // `order` is a full permutation of SECTION_KEYS; `enabled` gates visibility.
30
+ // Keeping order total keeps the bash assembler simple.
31
+ order: z
32
+ .array(sectionKey)
33
+ .refine((order) => order.length === SECTION_KEYS.length && new Set(order).size === order.length, 'order must list each section exactly once'),
34
+ enabled: z.strictObject({
35
+ dir: z.boolean(),
36
+ git: z.boolean(),
37
+ model: z.boolean(),
38
+ context: z.boolean(),
39
+ cost: z.boolean(),
40
+ tokens: z.boolean(),
41
+ clock: z.boolean(),
42
+ }),
43
+ });
44
+ const glyphsSchema = z.strictObject({
45
+ mode: z.enum(['nerdfont', 'unicode']),
46
+ repo: z.string(),
47
+ branch: z.string(),
48
+ clock: z.string(),
49
+ model: z.string(),
50
+ added: z.string(),
51
+ modified: z.string(),
52
+ ahead: z.string(),
53
+ behind: z.string(),
54
+ });
55
+ const layoutSchema = z.strictObject({
56
+ lines: z.enum(['multi', 'single']),
57
+ separator: z.string().min(1),
58
+ // Capped: the gradient renders char-by-char, so very wide values hurt the
59
+ // < 50ms render budget (SPEC §9 RNF1).
60
+ horizonWidth: z.int().min(1).max(120),
61
+ ctxBarWidth: z.int().min(1).max(60),
62
+ uppercase: z.boolean(),
63
+ timeFormat: z.string().min(1),
64
+ });
65
+ const thresholdsSchema = z
66
+ .strictObject({
67
+ ctxWarn: z.int().min(0).max(100),
68
+ ctxDanger: z.int().min(0).max(100),
69
+ })
70
+ .refine((t) => t.ctxDanger >= t.ctxWarn, 'ctxDanger must be >= ctxWarn');
71
+ export const configSchema = z.strictObject({
72
+ version: z.literal(1),
73
+ theme: themeSchema,
74
+ sections: sectionsSchema,
75
+ glyphs: glyphsSchema,
76
+ layout: layoutSchema,
77
+ thresholds: thresholdsSchema,
78
+ });
79
+ // ── Defaults (must stay in sync with the embedded defaults in the bash) ──────
80
+ export const DEFAULT_PRESET = 'outrun-horizon';
81
+ /**
82
+ * Nerd Font glyphs (Cascadia Code NF / CaskaydiaCove). The original runtime had
83
+ * lost the repo/branch/clock codepoints (rendered empty); these restore them.
84
+ */
85
+ export const NERDFONT_GLYPHS = {
86
+ mode: 'nerdfont',
87
+ repo: '\u{f07b}', // nf-fa-folder
88
+ branch: '\u{e0a0}', // powerline git branch
89
+ clock: '\u{f017}', // nf-fa-clock_o
90
+ model: '\u{2726}', // ✦
91
+ added: '\u{271a}', // ✚
92
+ modified: '\u{00b1}', // ±
93
+ ahead: '\u{21e1}', // ⇡
94
+ behind: '\u{21e3}', // ⇣
95
+ };
96
+ /** Plain-Unicode glyphs for terminals without a Nerd Font. */
97
+ export const UNICODE_GLYPHS = {
98
+ mode: 'unicode',
99
+ repo: '\u{25b0}', // ▰
100
+ branch: '\u{2387}', // ⎇
101
+ clock: '\u{25f7}', // ◷
102
+ model: '\u{2726}', // ✦
103
+ added: '\u{271a}', // ✚
104
+ modified: '\u{00b1}', // ±
105
+ ahead: '\u{21e1}', // ⇡
106
+ behind: '\u{21e3}', // ⇣
107
+ };
108
+ /** A complete, valid default config (the Outrun Horizon look). */
109
+ export function getDefaults() {
110
+ return {
111
+ version: 1,
112
+ theme: { preset: DEFAULT_PRESET, ...presets[DEFAULT_PRESET] },
113
+ sections: {
114
+ order: [...SECTION_KEYS],
115
+ enabled: {
116
+ dir: true,
117
+ git: true,
118
+ model: true,
119
+ context: true,
120
+ cost: true,
121
+ tokens: true,
122
+ clock: true,
123
+ },
124
+ },
125
+ glyphs: { ...NERDFONT_GLYPHS },
126
+ layout: {
127
+ lines: 'multi',
128
+ separator: '░', // ░
129
+ horizonWidth: 54,
130
+ ctxBarWidth: 14,
131
+ uppercase: true,
132
+ timeFormat: '%H:%M',
133
+ },
134
+ thresholds: { ctxWarn: 50, ctxDanger: 80 },
135
+ };
136
+ }
137
+ // ── Paths (XDG) ──────────────────────────────────────────────────────────────
138
+ /** Directory that holds config.json and the installed statusline.sh copy. */
139
+ export function getConfigDir() {
140
+ const xdg = process.env.XDG_CONFIG_HOME;
141
+ const base = xdg && xdg.length > 0 ? xdg : join(homedir(), '.config');
142
+ return join(base, 'space-statusline');
143
+ }
144
+ /** Resolved config path, honoring the $SPACE_STATUSLINE_CONFIG override. */
145
+ export function getConfigPath() {
146
+ const override = process.env.SPACE_STATUSLINE_CONFIG;
147
+ if (override && override.length > 0)
148
+ return override;
149
+ return join(getConfigDir(), 'config.json');
150
+ }
151
+ // ── Load / save ──────────────────────────────────────────────────────────────
152
+ /** Reads and validates the config at `path`. Throws on missing/invalid. */
153
+ export function loadConfig(path = getConfigPath()) {
154
+ const raw = readFileSync(path, 'utf8');
155
+ const parsed = JSON.parse(raw);
156
+ const result = configSchema.safeParse(parsed);
157
+ if (!result.success) {
158
+ throw new Error(`Invalid config at ${path}:\n${z.prettifyError(result.error)}`);
159
+ }
160
+ return result.data;
161
+ }
162
+ /** Like loadConfig but returns null when the file does not exist. */
163
+ export function tryLoadConfig(path = getConfigPath()) {
164
+ if (!existsSync(path))
165
+ return null;
166
+ return loadConfig(path);
167
+ }
168
+ /** Validates and writes the config as pretty JSON, creating the dir as needed. */
169
+ export function saveConfig(config, path = getConfigPath()) {
170
+ const result = configSchema.safeParse(config);
171
+ if (!result.success) {
172
+ throw new Error(`Refusing to save invalid config:\n${z.prettifyError(result.error)}`);
173
+ }
174
+ mkdirSync(dirname(path), { recursive: true });
175
+ writeFileSync(path, JSON.stringify(result.data, null, 2) + '\n', 'utf8');
176
+ }
package/dist/doctor.js ADDED
@@ -0,0 +1,33 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import pc from 'picocolors';
3
+ /** True when `bin --version` runs (i.e. the binary is on PATH). */
4
+ function hasBinary(bin) {
5
+ const result = spawnSync(bin, ['--version'], { stdio: 'ignore' });
6
+ return !result.error;
7
+ }
8
+ /** Runs environment checks and prints a human-readable report. */
9
+ export function runDoctor() {
10
+ const checks = [];
11
+ checks.push(hasBinary('jq')
12
+ ? { name: 'jq', status: 'ok', detail: 'found' }
13
+ : { name: 'jq', status: 'fail', detail: 'not found — the status line degrades to a minimal line' });
14
+ checks.push(hasBinary('git')
15
+ ? { name: 'git', status: 'ok', detail: 'found' }
16
+ : { name: 'git', status: 'warn', detail: 'not found — the git section will be hidden' });
17
+ const colorterm = process.env.COLORTERM ?? '';
18
+ checks.push(/truecolor|24bit/.test(colorterm)
19
+ ? { name: 'truecolor', status: 'ok', detail: `COLORTERM=${colorterm}` }
20
+ : { name: 'truecolor', status: 'warn', detail: 'COLORTERM not truecolor — colors degrade to 256' });
21
+ checks.push({
22
+ name: 'Nerd Font',
23
+ status: 'warn',
24
+ detail: 'cannot auto-detect — check the sample below',
25
+ });
26
+ console.log(pc.bold('space-statusline doctor') + '\n');
27
+ for (const c of checks) {
28
+ const icon = c.status === 'ok' ? pc.green('✓') : c.status === 'warn' ? pc.yellow('!') : pc.red('✗');
29
+ console.log(` ${icon} ${c.name.padEnd(11)} ${pc.dim(c.detail)}`);
30
+ }
31
+ console.log(`\n Nerd Font sample: \u{f07b} \u{e0a0} \u{f017} ${pc.dim('(folder · branch · clock)')}`);
32
+ console.log(pc.dim(' If those show as boxes, run the wizard and pick the Unicode glyph set.'));
33
+ }
@@ -0,0 +1,45 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { mkdtempSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { dirname, join } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ /**
7
+ * Absolute path to the bundled runtime script. `runtime/` ships next to `dist/`,
8
+ * so from dist/preview.js it is one level up.
9
+ */
10
+ export function getRuntimeScriptPath() {
11
+ return join(dirname(fileURLToPath(import.meta.url)), '..', 'runtime', 'statusline.sh');
12
+ }
13
+ /** A representative mock of the JSON Claude Code feeds the status line on stdin. */
14
+ export function buildMockInput() {
15
+ return JSON.stringify({
16
+ model: { display_name: 'Opus 4.1' },
17
+ workspace: { current_dir: process.cwd() },
18
+ context_window: { used_percentage: 58, total_input_tokens: 12000, total_output_tokens: 3400 },
19
+ cost: { total_cost_usd: 1.24 },
20
+ });
21
+ }
22
+ /**
23
+ * Renders the status line with the given config (or embedded defaults when
24
+ * omitted) by invoking the real bash runtime with a mock stdin payload.
25
+ * Returns the raw ANSI output so callers can print it verbatim.
26
+ */
27
+ export function renderPreview(config, mockInput = buildMockInput()) {
28
+ const env = { ...process.env };
29
+ if (config) {
30
+ const dir = mkdtempSync(join(tmpdir(), 'space-statusline-'));
31
+ const configPath = join(dir, 'config.json');
32
+ writeFileSync(configPath, JSON.stringify(config), 'utf8');
33
+ env.SPACE_STATUSLINE_CONFIG = configPath;
34
+ }
35
+ else {
36
+ // Force the embedded defaults by pointing at a path that does not exist.
37
+ env.SPACE_STATUSLINE_CONFIG = '/nonexistent';
38
+ }
39
+ const result = spawnSync('bash', [getRuntimeScriptPath()], {
40
+ input: mockInput,
41
+ env,
42
+ encoding: 'utf8',
43
+ });
44
+ return result.stdout ?? '';
45
+ }
package/dist/themes.js ADDED
@@ -0,0 +1,65 @@
1
+ // Theme presets. A preset defines only the color identity of the status line:
2
+ // the synthwave gradient (used for the horizon rule, the dir name and the
3
+ // context bar) plus the semantic palette. Everything else (sections, glyphs,
4
+ // layout, thresholds) lives in the config-level defaults in config.ts.
5
+ /** Names of the built-in presets (excludes the synthetic "custom"). */
6
+ export const PRESET_NAMES = ['outrun-horizon', 'sunset', 'vaporwave', 'mono'];
7
+ /** Built-in palettes. `outrun-horizon` is the default and matches the runtime. */
8
+ export const presets = {
9
+ // The original synthwave look: violet → magenta horizon.
10
+ 'outrun-horizon': {
11
+ gradient: { start: '#7A3CFF', end: '#FF6CF0' },
12
+ colors: {
13
+ accent: '#A96BFF',
14
+ magenta: '#FF6CF0',
15
+ cyan: '#4BE4E8',
16
+ green: '#62E6B0',
17
+ amber: '#FFB86B',
18
+ dim: '#5C5180',
19
+ ctxWarn: '#FFB86B',
20
+ ctxDanger: '#FF5F8C',
21
+ },
22
+ },
23
+ // Warm dusk: orange → hot pink.
24
+ sunset: {
25
+ gradient: { start: '#FF7A3C', end: '#FF3CA8' },
26
+ colors: {
27
+ accent: '#FFA552',
28
+ magenta: '#FF5FA0',
29
+ cyan: '#FFC27A',
30
+ green: '#7BD88F',
31
+ amber: '#FFB86B',
32
+ dim: '#6B5560',
33
+ ctxWarn: '#FFB86B',
34
+ ctxDanger: '#FF4D6D',
35
+ },
36
+ },
37
+ // Cool vaporwave: cyan → violet.
38
+ vaporwave: {
39
+ gradient: { start: '#2BD9FF', end: '#9A6BFF' },
40
+ colors: {
41
+ accent: '#6CC0FF',
42
+ magenta: '#C77DFF',
43
+ cyan: '#4BE4E8',
44
+ green: '#5BE8C2',
45
+ amber: '#8AB6FF',
46
+ dim: '#46557F',
47
+ ctxWarn: '#FFD27A',
48
+ ctxDanger: '#FF6B9D',
49
+ },
50
+ },
51
+ // Minimal monochrome: low-chroma greys with a single soft accent.
52
+ mono: {
53
+ gradient: { start: '#6E7488', end: '#C2C7D6' },
54
+ colors: {
55
+ accent: '#C2C7D6',
56
+ magenta: '#AEB3C2',
57
+ cyan: '#9AA0B3',
58
+ green: '#A8B6A0',
59
+ amber: '#C9BD93',
60
+ dim: '#4A4E5A',
61
+ ctxWarn: '#C9BD93',
62
+ ctxDanger: '#D88A8A',
63
+ },
64
+ },
65
+ };
package/dist/wizard.js ADDED
@@ -0,0 +1,195 @@
1
+ import * as p from '@clack/prompts';
2
+ import pc from 'picocolors';
3
+ import { SECTION_KEYS, NERDFONT_GLYPHS, UNICODE_GLYPHS, getDefaults, configSchema, } from './config.js';
4
+ import { PRESET_NAMES, presets } from './themes.js';
5
+ import { renderPreview } from './preview.js';
6
+ const HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
7
+ /** Aborts the wizard cleanly if the user cancels a prompt (Ctrl-C / Esc). */
8
+ function requireValue(value) {
9
+ if (p.isCancel(value)) {
10
+ p.cancel('Wizard cancelled — no changes were made.');
11
+ process.exit(0);
12
+ }
13
+ return value;
14
+ }
15
+ /** Human-readable label for each section, shown in the multiselect. */
16
+ const SECTION_LABELS = {
17
+ dir: 'Directory name',
18
+ git: 'Git branch & status',
19
+ model: 'Model name',
20
+ context: 'Context usage bar',
21
+ cost: 'Session cost',
22
+ tokens: 'Token count',
23
+ clock: 'Clock',
24
+ };
25
+ /**
26
+ * Runs the interactive wizard. `existing` pre-fills answers when editing an
27
+ * existing config; pass null for a from-scratch run. Returns the validated
28
+ * config, or null if the user chose not to save at the preview step.
29
+ */
30
+ export async function runWizard(existing) {
31
+ const base = existing ?? getDefaults();
32
+ p.intro(pc.magenta(pc.bold('space-statusline')) + pc.dim(' · synthwave status line'));
33
+ // ── 1. Theme ───────────────────────────────────────────────────────────────
34
+ const presetChoice = requireValue(await p.select({
35
+ message: 'Theme',
36
+ initialValue: base.theme.preset,
37
+ options: [
38
+ ...PRESET_NAMES.map((name) => ({
39
+ value: name,
40
+ label: name,
41
+ hint: name === 'outrun-horizon' ? 'default' : undefined,
42
+ })),
43
+ { value: 'custom', label: 'custom', hint: 'pick your own gradient' },
44
+ ],
45
+ }));
46
+ let theme;
47
+ if (presetChoice === 'custom') {
48
+ const start = requireValue(await p.text({
49
+ message: 'Gradient start color (hex)',
50
+ placeholder: '#7A3CFF',
51
+ initialValue: base.theme.gradient.start,
52
+ validate: (v) => (HEX_COLOR.test(v ?? '') ? undefined : 'Use a hex color like #7A3CFF'),
53
+ }));
54
+ const end = requireValue(await p.text({
55
+ message: 'Gradient end color (hex)',
56
+ placeholder: '#FF6CF0',
57
+ initialValue: base.theme.gradient.end,
58
+ validate: (v) => (HEX_COLOR.test(v ?? '') ? undefined : 'Use a hex color like #FF6CF0'),
59
+ }));
60
+ // Derive a palette: gradient endpoints drive accent/magenta, the rest keeps
61
+ // the proven Outrun semantic colors so git/context stay legible.
62
+ const fallback = presets['outrun-horizon'].colors;
63
+ theme = {
64
+ preset: 'custom',
65
+ gradient: { start, end },
66
+ colors: { ...fallback, accent: start, magenta: end },
67
+ };
68
+ }
69
+ else {
70
+ theme = { preset: presetChoice, ...presets[presetChoice] };
71
+ }
72
+ // ── 2. Sections: which are enabled, then their order ─────────────────────────
73
+ const enabledList = requireValue(await p.multiselect({
74
+ message: 'Sections to show',
75
+ options: SECTION_KEYS.map((key) => ({ value: key, label: SECTION_LABELS[key] })),
76
+ initialValues: SECTION_KEYS.filter((k) => base.sections.enabled[k]),
77
+ required: true,
78
+ }));
79
+ const orderInput = requireValue(await p.text({
80
+ message: 'Section order (comma-separated, all sections)',
81
+ placeholder: SECTION_KEYS.join(', '),
82
+ initialValue: base.sections.order.join(', '),
83
+ validate: (v) => {
84
+ const items = (v ?? '').split(',').map((s) => s.trim()).filter(Boolean);
85
+ const valid = new Set(SECTION_KEYS);
86
+ if (items.length !== SECTION_KEYS.length)
87
+ return `List all ${SECTION_KEYS.length} sections exactly once`;
88
+ if (new Set(items).size !== items.length)
89
+ return 'No duplicates allowed';
90
+ for (const it of items)
91
+ if (!valid.has(it))
92
+ return `Unknown section: ${it}`;
93
+ return undefined;
94
+ },
95
+ }));
96
+ const order = orderInput.split(',').map((s) => s.trim()).filter(Boolean);
97
+ const enabledSet = new Set(enabledList);
98
+ const enabled = Object.fromEntries(SECTION_KEYS.map((k) => [k, enabledSet.has(k)]));
99
+ // ── 3. Glyphs ────────────────────────────────────────────────────────────────
100
+ const glyphMode = requireValue(await p.select({
101
+ message: 'Glyph set',
102
+ initialValue: base.glyphs.mode,
103
+ options: [
104
+ { value: 'nerdfont', label: 'Nerd Font', hint: 'needs a patched font (e.g. Cascadia Code NF)' },
105
+ { value: 'unicode', label: 'Unicode', hint: 'works without a Nerd Font' },
106
+ ],
107
+ }));
108
+ let glyphs = glyphMode === 'nerdfont' ? { ...NERDFONT_GLYPHS } : { ...UNICODE_GLYPHS };
109
+ const customizeGlyphs = requireValue(await p.confirm({ message: 'Customize individual glyphs?', initialValue: false }));
110
+ if (customizeGlyphs) {
111
+ const keys = [
112
+ 'repo',
113
+ 'branch',
114
+ 'clock',
115
+ 'model',
116
+ ];
117
+ for (const key of keys) {
118
+ const value = requireValue(await p.text({
119
+ message: `Glyph for ${key}`,
120
+ initialValue: glyphs[key],
121
+ }));
122
+ glyphs = { ...glyphs, [key]: value };
123
+ }
124
+ }
125
+ // ── 4. Layout ──────────────────────────────────────────────────────────────
126
+ const lines = requireValue(await p.select({
127
+ message: 'Layout',
128
+ initialValue: base.layout.lines,
129
+ options: [
130
+ { value: 'multi', label: 'Multi-line', hint: 'directory · horizon · metrics' },
131
+ { value: 'single', label: 'Single line' },
132
+ ],
133
+ }));
134
+ const separator = requireValue(await p.text({
135
+ message: 'Section separator',
136
+ initialValue: base.layout.separator,
137
+ validate: (v) => ((v ?? '').length >= 1 ? undefined : 'Separator cannot be empty'),
138
+ }));
139
+ const horizonWidth = requireValue(await p.text({
140
+ message: 'Horizon width (1–120)',
141
+ initialValue: String(base.layout.horizonWidth),
142
+ validate: (v) => validateInt(v ?? '', 1, 120),
143
+ }));
144
+ const ctxBarWidth = requireValue(await p.text({
145
+ message: 'Context bar width (1–60)',
146
+ initialValue: String(base.layout.ctxBarWidth),
147
+ validate: (v) => validateInt(v ?? '', 1, 60),
148
+ }));
149
+ const uppercase = requireValue(await p.confirm({ message: 'Uppercase labels?', initialValue: base.layout.uppercase }));
150
+ const timeFormat = requireValue(await p.text({
151
+ message: 'Clock format (strftime)',
152
+ initialValue: base.layout.timeFormat,
153
+ validate: (v) => ((v ?? '').length >= 1 ? undefined : 'Format cannot be empty'),
154
+ }));
155
+ const layout = {
156
+ lines,
157
+ separator,
158
+ horizonWidth: Number(horizonWidth),
159
+ ctxBarWidth: Number(ctxBarWidth),
160
+ uppercase,
161
+ timeFormat,
162
+ };
163
+ // ── Assemble & validate ──────────────────────────────────────────────────────
164
+ const candidate = {
165
+ version: 1,
166
+ theme,
167
+ sections: { order, enabled },
168
+ glyphs,
169
+ layout,
170
+ thresholds: base.thresholds,
171
+ };
172
+ const parsed = configSchema.safeParse(candidate);
173
+ if (!parsed.success) {
174
+ p.cancel('Internal error: built an invalid config. Please report this.');
175
+ process.exit(1);
176
+ }
177
+ const config = parsed.data;
178
+ // ── 5. Live preview + confirm ────────────────────────────────────────────────
179
+ p.note(renderPreview(config), 'Preview');
180
+ const save = requireValue(await p.confirm({ message: 'Save this configuration?', initialValue: true }));
181
+ if (!save) {
182
+ p.outro(pc.dim('Nothing saved.'));
183
+ return null;
184
+ }
185
+ return config;
186
+ }
187
+ /** Validator: an integer within [min, max]. Returns an error string or undefined. */
188
+ function validateInt(value, min, max) {
189
+ const n = Number(value);
190
+ if (!Number.isInteger(n))
191
+ return 'Enter a whole number';
192
+ if (n < min || n > max)
193
+ return `Must be between ${min} and ${max}`;
194
+ return undefined;
195
+ }
package/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "space-statusline",
3
+ "version": "0.1.0",
4
+ "description": "Configurable space-synthwave (Outrun Horizon) status line for Claude Code, set up via an interactive CLI wizard.",
5
+ "type": "module",
6
+ "bin": {
7
+ "space-statusline": "dist/cli.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "runtime",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "engines": {
16
+ "node": ">=20"
17
+ },
18
+ "keywords": [
19
+ "claude-code",
20
+ "statusline",
21
+ "cli",
22
+ "synthwave",
23
+ "outrun",
24
+ "terminal",
25
+ "wizard"
26
+ ],
27
+ "author": "Oscar Angel",
28
+ "license": "MIT",
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/Oscaraag/Space-Statusline.git"
32
+ },
33
+ "bugs": {
34
+ "url": "https://github.com/Oscaraag/Space-Statusline/issues"
35
+ },
36
+ "homepage": "https://github.com/Oscaraag/Space-Statusline#readme",
37
+ "publishConfig": {
38
+ "access": "public"
39
+ },
40
+ "devEngines": {
41
+ "packageManager": {
42
+ "name": "pnpm",
43
+ "version": "^11.1.1",
44
+ "onFail": "download"
45
+ }
46
+ },
47
+ "dependencies": {
48
+ "@clack/prompts": "^1.5.1",
49
+ "picocolors": "^1.1.1",
50
+ "zod": "^4.4.3"
51
+ },
52
+ "devDependencies": {
53
+ "@eslint/js": "^10.0.1",
54
+ "@types/node": "^25.9.3",
55
+ "eslint": "^10.5.0",
56
+ "typescript": "^6.0.3",
57
+ "typescript-eslint": "^8.61.0"
58
+ },
59
+ "scripts": {
60
+ "build": "tsc",
61
+ "dev": "tsc --watch",
62
+ "typecheck": "tsc --noEmit",
63
+ "lint": "eslint src"
64
+ }
65
+ }
@@ -0,0 +1,298 @@
1
+ #!/usr/bin/env bash
2
+ # ════════════════════════════════════════════════════════════════
3
+ # OUTRUN HORIZON · status line para Claude Code
4
+ # Estilo space-synthwave · truecolor 24-bit · Nerd Font (Cascadia Code NF)
5
+ # Requiere: bash 4+, jq, git · Pensado para WSL2 / Linux / macOS
6
+ #
7
+ # Config-driven: lee su apariencia desde
8
+ # $SPACE_STATUSLINE_CONFIG o ${XDG_CONFIG_HOME:-~/.config}/space-statusline/config.json
9
+ # Sin config (o config inválida) usa los defaults embebidos (look Outrun).
10
+ #
11
+ # Probar sin Claude Code:
12
+ # echo '{"model":{"display_name":"Opus 4.1"},"workspace":{"current_dir":"'"$PWD"'"},"context_window":{"used_percentage":58},"cost":{"total_cost_usd":1.24}}' | bash runtime/statusline.sh
13
+ # ════════════════════════════════════════════════════════════════
14
+
15
+ input=$(cat)
16
+
17
+ # ── Fallback RNF2: sin jq → línea mínima, sin color, nunca rompe ─
18
+ if ! command -v jq >/dev/null 2>&1; then
19
+ m=$(printf '%s' "$input" | grep -oE '"display_name"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed -E 's/.*"([^"]*)"$/\1/')
20
+ d=$(printf '%s' "$input" | grep -oE '"current_dir"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed -E 's/.*"([^"]*)"$/\1/')
21
+ p=$(printf '%s' "$input" | grep -oE '"used_percentage"[[:space:]]*:[[:space:]]*[0-9]+' | head -1 | grep -oE '[0-9]+$')
22
+ printf '%s · %s · %s%%' "$(basename "${d:-$PWD}")" "${m:-Claude}" "${p:-0}"
23
+ exit 0
24
+ fi
25
+
26
+ # ── Resolución de la config (XDG) ───────────────────────────────
27
+ CONFIG_PATH="${SPACE_STATUSLINE_CONFIG:-${XDG_CONFIG_HOME:-$HOME/.config}/space-statusline/config.json}"
28
+
29
+ # Defaults embebidos (espejo de getDefaults() en src/config.ts). Glifos y
30
+ # separador como escapes \u para mantener el script ASCII-puro; jq los expande.
31
+ DEFAULTS='{
32
+ "theme": {
33
+ "gradient": { "start": "#7A3CFF", "end": "#FF6CF0" },
34
+ "colors": {
35
+ "accent": "#A96BFF", "magenta": "#FF6CF0", "cyan": "#4BE4E8",
36
+ "green": "#62E6B0", "amber": "#FFB86B", "dim": "#5C5180",
37
+ "ctxWarn": "#FFB86B", "ctxDanger": "#FF5F8C"
38
+ }
39
+ },
40
+ "sections": {
41
+ "order": ["dir","git","model","context","cost","tokens","clock"],
42
+ "enabled": { "dir": true, "git": true, "model": true, "context": true,
43
+ "cost": true, "tokens": true, "clock": true }
44
+ },
45
+ "glyphs": {
46
+ "mode": "nerdfont",
47
+ "repo": "\uf07b", "branch": "\ue0a0", "clock": "\uf017", "model": "\u2726",
48
+ "added": "\u271a", "modified": "\u00b1", "ahead": "\u21e1", "behind": "\u21e3"
49
+ },
50
+ "layout": {
51
+ "lines": "multi", "separator": "\u2591", "horizonWidth": 54,
52
+ "ctxBarWidth": 14, "uppercase": true, "timeFormat": "%H:%M"
53
+ },
54
+ "thresholds": { "ctxWarn": 50, "ctxDanger": 80 }
55
+ }'
56
+
57
+ # Lee la config del usuario (si existe). Si no es JSON válido, el primer jq
58
+ # falla y reintentamos con defaults — nunca rompe.
59
+ US=$'\x1f'
60
+ usercfg='{}'
61
+ [[ -r "$CONFIG_PATH" ]] && usercfg=$(cat "$CONFIG_PATH" 2>/dev/null)
62
+ [[ -z "$usercfg" ]] && usercfg='{}'
63
+
64
+ # ── Una sola pasada de jq ────────────────────────────────────────
65
+ # deep-merge (defaults*user) + parseo del stdin de Claude Code. Emite 4
66
+ # líneas US-joined: escalares de config, orden, habilitadas, datos del stdin.
67
+ read_all(){
68
+ jq -r --argjson def "$DEFAULTS" --argjson usr "$1" '
69
+ ($def * $usr) as $c
70
+ | ([ $c.theme.gradient.start, $c.theme.gradient.end,
71
+ $c.theme.colors.accent, $c.theme.colors.magenta, $c.theme.colors.cyan,
72
+ $c.theme.colors.green, $c.theme.colors.amber, $c.theme.colors.dim,
73
+ $c.theme.colors.ctxWarn, $c.theme.colors.ctxDanger,
74
+ ($c.thresholds.ctxWarn|tostring), ($c.thresholds.ctxDanger|tostring),
75
+ ($c.layout.horizonWidth|tostring), ($c.layout.ctxBarWidth|tostring),
76
+ $c.layout.separator, $c.layout.timeFormat,
77
+ ($c.layout.uppercase|tostring), $c.layout.lines,
78
+ $c.glyphs.mode, $c.glyphs.repo, $c.glyphs.branch, $c.glyphs.clock,
79
+ $c.glyphs.model, $c.glyphs.added, $c.glyphs.modified,
80
+ $c.glyphs.ahead, $c.glyphs.behind
81
+ ] | join("")),
82
+ ($c.sections.order | join("")),
83
+ ($c.sections.enabled | to_entries | map(select(.value).key) | join("")),
84
+ ([ (.model.display_name // "Claude"),
85
+ (.workspace.current_dir // .cwd // ""),
86
+ ((.context_window.used_percentage // 0)|floor|tostring),
87
+ ((.cost.total_cost_usd // 0)|tostring),
88
+ ((.context_window.total_input_tokens // 0)|tostring),
89
+ ((.context_window.total_output_tokens // 0)|tostring)
90
+ ] | join(""))
91
+ ' <<<"$input" 2>/dev/null
92
+ }
93
+
94
+ cfg=$(read_all "$usercfg")
95
+ [[ -z "$cfg" ]] && cfg=$(read_all '{}')
96
+
97
+ mapfile -t CFG_LINES <<<"$cfg"
98
+ IFS="$US" read -r \
99
+ GS GE C_ACCENT C_MAG C_CYAN C_GREEN C_AMBER C_DIM C_CTXW C_CTXD \
100
+ T_WARN T_DANGER HORIZON_W CTXBAR_W SEPARATOR TIME_FMT UPPER LINES_MODE \
101
+ G_MODE G_REPO G_BR G_CLK G_MDL G_ADD G_MOD G_AH G_BH \
102
+ <<<"${CFG_LINES[0]}"
103
+ IFS="$US" read -ra ORDER <<<"${CFG_LINES[1]:-}"
104
+ IFS="$US" read -ra ENABLED <<<"${CFG_LINES[2]:-}"
105
+ IFS="$US" read -r MODEL CWD PCT COST TIN TOUT <<<"${CFG_LINES[3]:-}"
106
+ : "${G_MODE:=nerdfont}"
107
+ : "${MODEL:=Claude}"; : "${PCT:=0}"; : "${COST:=0}"; : "${TIN:=0}"; : "${TOUT:=0}"
108
+ TOK=$(( TIN + TOUT ))
109
+
110
+ # ── Capacidades: truecolor (degradar a 256 si COLORTERM lo niega) ─
111
+ TRUECOLOR=1
112
+ case "${COLORTERM:-}" in
113
+ truecolor|24bit|"") TRUECOLOR=1 ;;
114
+ *) TRUECOLOR=0 ;;
115
+ esac
116
+
117
+ # ── Helpers de color ─────────────────────────────────────────────
118
+ RESET=$'\033[0m'
119
+ BOLD=$'\033[1m'
120
+
121
+ # Print an SGR foreground escape for an RGB triple (24-bit, or the xterm-256
122
+ # color cube when truecolor is unavailable). Prints inline — no subshell.
123
+ fg(){
124
+ if (( TRUECOLOR )); then printf '\033[38;2;%d;%d;%dm' "$1" "$2" "$3"
125
+ else printf '\033[38;5;%dm' "$(( 16 + 36*($1*5/255) + 6*($2*5/255) + ($3*5/255) ))"; fi
126
+ }
127
+ # Same, but assign the escape into a variable via printf -v (avoids the $() fork).
128
+ mkfg(){
129
+ if (( TRUECOLOR )); then printf -v "$1" '\033[38;2;%d;%d;%dm' "$2" "$3" "$4"
130
+ else printf -v "$1" '\033[38;5;%dm' "$(( 16 + 36*($2*5/255) + 6*($3*5/255) + ($4*5/255) ))"; fi
131
+ }
132
+
133
+ # Parse #RRGGBB into R G B globals; falls back to $2 $3 $4 on a bad value.
134
+ hex2rgb(){
135
+ local h=${1#\#}
136
+ if [[ $h =~ ^[0-9A-Fa-f]{6}$ ]]; then
137
+ R=$(( 16#${h:0:2} )); G=$(( 16#${h:2:2} )); B=$(( 16#${h:4:2} ))
138
+ else
139
+ R=${2:-255}; G=${3:-255}; B=${4:-255}
140
+ fi
141
+ }
142
+
143
+ # ── Gradiente: endpoints desde la config ─────────────────────────
144
+ hex2rgb "$GS" 122 60 255; GS_R=$R GS_G=$G GS_B=$B
145
+ hex2rgb "$GE" 255 108 240; GE_R=$R GE_G=$G GE_B=$B
146
+
147
+ lerp(){ # $1 = t (0..1000) -> define R G B sobre el gradiente
148
+ R=$(( GS_R + (GE_R-GS_R)*$1/1000 ))
149
+ G=$(( GS_G + (GE_G-GS_G)*$1/1000 ))
150
+ B=$(( GS_B + (GE_B-GS_B)*$1/1000 ))
151
+ }
152
+
153
+ # Texto con gradiente, carácter a carácter.
154
+ grad(){
155
+ local s=$1 n=${#1} i t
156
+ (( n==0 )) && return
157
+ for (( i=0; i<n; i++ )); do
158
+ t=$(( n>1 ? i*1000/(n-1) : 0 )); lerp "$t"
159
+ fg "$R" "$G" "$B"; printf '%s' "${s:i:1}"
160
+ done
161
+ printf '%s' "$RESET"
162
+ }
163
+
164
+ # Regla-horizonte: gradiente + desvanecido en los extremos.
165
+ horizon(){
166
+ local w=${1:-46} i t d f m=$(( (${1:-46}-1)/2 ))
167
+ for (( i=0; i<w; i++ )); do
168
+ t=$(( w>1 ? i*1000/(w-1) : 0 )); lerp "$t"
169
+ d=$(( i>m ? i-m : m-i ))
170
+ f=$(( 100 - d*38/(m>0?m:1) ))
171
+ fg $(( R*f/100 )) $(( G*f/100 )) $(( B*f/100 )); printf '─'
172
+ done
173
+ printf '%s' "$RESET"
174
+ }
175
+
176
+ # Barra de contexto: medios-bloques (sub-resolución) + gradiente.
177
+ ctxbar(){
178
+ local pct=$1 w=${2:-14} parts=("" "▏" "▎" "▍" "▌" "▋" "▊" "▉") i cell t
179
+ local eighths=$(( pct*w*8/100 ))
180
+ for (( i=0; i<w; i++ )); do
181
+ t=$(( w>1 ? i*1000/(w-1) : 0 )); lerp "$t"
182
+ cell=$(( eighths - i*8 ))
183
+ if (( cell >= 8 )); then fg "$R" "$G" "$B"; printf '█'
184
+ elif (( cell > 0 )); then fg "$R" "$G" "$B"; printf '%s' "${parts[cell]}"
185
+ else printf '%s░' "$TRACK"
186
+ fi
187
+ done
188
+ printf '%s' "$RESET"
189
+ }
190
+
191
+ fmtk(){ local n=$1; (( n>=1000 )) && printf '%d.%dk' $(( n/1000 )) $(( (n%1000)/100 )) || printf '%d' "$n"; }
192
+ # Uppercase a variable in place when layout.uppercase is on (bash builtin, no fork).
193
+ upcase(){ local v=${!1}; [[ $UPPER == true ]] && v=${v^^}; printf -v "$1" '%s' "$v"; }
194
+
195
+ # ── Paleta (desde la config) ─────────────────────────────────────
196
+ hex2rgb "$C_ACCENT" 169 107 255; mkfg PUR "$R" "$G" "$B"
197
+ hex2rgb "$C_MAG" 255 108 240; mkfg MAG "$R" "$G" "$B"
198
+ hex2rgb "$C_CYAN" 75 228 232; mkfg CYAN "$R" "$G" "$B"
199
+ hex2rgb "$C_GREEN" 98 230 176; mkfg GRN "$R" "$G" "$B"
200
+ hex2rgb "$C_AMBER" 255 184 107; mkfg AMB "$R" "$G" "$B"
201
+ hex2rgb "$C_DIM" 92 81 128; mkfg DIM "$R" "$G" "$B"; DIM_R=$R DIM_G=$G DIM_B=$B
202
+ hex2rgb "$C_CTXW" 255 184 107; mkfg CTXW "$R" "$G" "$B"
203
+ hex2rgb "$C_CTXD" 255 95 140; mkfg CTXD "$R" "$G" "$B"
204
+ # Empty context-bar track: a dimmer shade of `dim`.
205
+ mkfg TRACK $(( DIM_R*63/100 )) $(( DIM_G*63/100 )) $(( DIM_B*63/100 ))
206
+ SEP=" ${DIM}${SEPARATOR}${RESET} "
207
+
208
+ # Color del % de contexto según umbrales.
209
+ if (( PCT >= T_DANGER )); then PC=$CTXD
210
+ elif (( PCT >= T_WARN )); then PC=$CTXW
211
+ else PC=$GRN
212
+ fi
213
+
214
+ # ── Carpeta / repo ───────────────────────────────────────────────
215
+ NAME=${CWD%/}; NAME=${NAME##*/}; [ -z "$NAME" ] && NAME="~"
216
+ upcase NAME
217
+ upcase MODEL
218
+
219
+ # ── Datos de git ────────────────────────────────────────────────
220
+ GIT=0; BRANCH=""; NEWC=0; MODC=0; AHEAD=0; BEHIND=0
221
+ if git -C "$CWD" rev-parse --is-inside-work-tree &>/dev/null; then
222
+ GIT=1
223
+ BRANCH=$(git -C "$CWD" branch --show-current 2>/dev/null)
224
+ [ -z "$BRANCH" ] && BRANCH=$(git -C "$CWD" rev-parse --short HEAD 2>/dev/null)
225
+ st=$(git -C "$CWD" status --porcelain 2>/dev/null)
226
+ if [ -n "$st" ]; then
227
+ NEWC=$(grep -c '^??' <<<"$st")
228
+ MODC=$(( $(grep -c . <<<"$st") - NEWC ))
229
+ fi
230
+ ab=$(git -C "$CWD" rev-list --left-right --count '@{u}...HEAD' 2>/dev/null)
231
+ if [ -n "$ab" ]; then BEHIND=${ab%%[[:space:]]*}; AHEAD=${ab##*[[:space:]]}; fi
232
+ upcase BRANCH
233
+ fi
234
+
235
+ # ── Fragmentos por sección ───────────────────────────────────────
236
+ sec_dir(){ printf '%s%s %s' "$PUR" "$G_REPO" "${RESET}${BOLD}"; grad "$NAME"; }
237
+ sec_git(){
238
+ (( GIT )) || return
239
+ printf '%s%s %s%s' "$MAG" "$G_BR" "$BRANCH" "$RESET"
240
+ local g=""
241
+ (( NEWC > 0 )) && g+=" ${AMB}${G_ADD}${NEWC}${RESET}"
242
+ (( MODC > 0 )) && g+=" ${CYAN}${G_MOD}${MODC}${RESET}"
243
+ (( AHEAD > 0 )) && g+=" ${GRN}${G_AH}${AHEAD}${RESET}"
244
+ (( BEHIND > 0 )) && g+=" ${AMB}${G_BH}${BEHIND}${RESET}"
245
+ [ -n "$g" ] && printf ' %s' "${g# }"
246
+ }
247
+ sec_model(){ printf '%s%s %s%s' "${PUR}${BOLD}" "$G_MDL" "$MODEL" "$RESET"; }
248
+ sec_context(){ printf '%sCTX%s ' "$DIM" "$RESET"; ctxbar "$PCT" "$CTXBAR_W"; printf ' %s%s%%%s' "$PC" "$PCT" "$RESET"; }
249
+ sec_cost(){ printf '%s$%.2f%s' "$GRN" "$COST" "$RESET"; }
250
+ sec_tokens(){ printf '%s' "$DIM"; fmtk "$TOK"; printf '%s' "$RESET"; }
251
+ sec_clock(){ printf '%s%s ' "$CYAN" "$G_CLK"; printf "%(${TIME_FMT})T" -1; printf '%s' "$RESET"; }
252
+
253
+ render_section(){
254
+ case "$1" in
255
+ dir) sec_dir ;;
256
+ git) sec_git ;;
257
+ model) sec_model ;;
258
+ context) sec_context ;;
259
+ cost) sec_cost ;;
260
+ tokens) sec_tokens ;;
261
+ clock) sec_clock ;;
262
+ esac
263
+ }
264
+
265
+ # Print the given sections (by name) joined with the separator, directly to
266
+ # stdout — no per-section subshell.
267
+ print_group(){
268
+ local first=1 s
269
+ for s in "$@"; do
270
+ (( first )) || printf '%s' "$SEP"
271
+ first=0
272
+ render_section "$s"
273
+ done
274
+ }
275
+
276
+ # ── Ensamblado según order/enabled + layout ──────────────────────
277
+ declare -A IS_EN; for s in "${ENABLED[@]}"; do IS_EN[$s]=1; done
278
+
279
+ # Sections to render, in order; git collapses to nothing when not a repo.
280
+ # loc = location group (line 1), met = metrics group (line 3) for multiline.
281
+ visible=(); loc=(); met=()
282
+ for s in "${ORDER[@]}"; do
283
+ [[ ${IS_EN[$s]:-} ]] || continue
284
+ [[ $s == git && $GIT -eq 0 ]] && continue
285
+ visible+=("$s")
286
+ case "$s" in
287
+ dir|git) loc+=("$s") ;;
288
+ *) met+=("$s") ;;
289
+ esac
290
+ done
291
+
292
+ if [[ $LINES_MODE == single ]]; then
293
+ print_group "${visible[@]}"
294
+ else
295
+ if (( ${#loc[@]} )); then print_group "${loc[@]}"; printf '\n'; fi
296
+ horizon "$HORIZON_W"; printf '\n'
297
+ print_group "${met[@]}"
298
+ fi