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 +21 -0
- package/README.md +150 -0
- package/dist/claude.js +98 -0
- package/dist/cli.js +148 -0
- package/dist/config.js +176 -0
- package/dist/doctor.js +33 -0
- package/dist/preview.js +45 -0
- package/dist/themes.js +65 -0
- package/dist/wizard.js +195 -0
- package/package.json +65 -0
- package/runtime/statusline.sh +298 -0
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
|
+
}
|
package/dist/preview.js
ADDED
|
@@ -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
|