@npow/oh-my-claude 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/README.md +317 -0
- package/bin/omc.js +403 -0
- package/docs/architecture.md +198 -0
- package/docs/segment-contract.md +186 -0
- package/docs/theme-format.md +156 -0
- package/package.json +35 -0
- package/src/cache.js +102 -0
- package/src/color.js +105 -0
- package/src/compositor.js +163 -0
- package/src/config.js +146 -0
- package/src/plugins.js +72 -0
- package/src/runner.js +160 -0
- package/src/segments/achievement.js +68 -0
- package/src/segments/api-timer.js +55 -0
- package/src/segments/battle-log.js +55 -0
- package/src/segments/cat.js +89 -0
- package/src/segments/coffee-cup.js +81 -0
- package/src/segments/commit-msg.js +95 -0
- package/src/segments/context-bar.js +50 -0
- package/src/segments/context-percent.js +40 -0
- package/src/segments/context-tokens.js +52 -0
- package/src/segments/cost-budget.js +43 -0
- package/src/segments/coworker.js +137 -0
- package/src/segments/custom-text.js +25 -0
- package/src/segments/directory.js +75 -0
- package/src/segments/emoji-story.js +99 -0
- package/src/segments/flex-space.js +25 -0
- package/src/segments/fortune-cookie.js +131 -0
- package/src/segments/garden.js +57 -0
- package/src/segments/git-branch.js +36 -0
- package/src/segments/git-status.js +56 -0
- package/src/segments/horoscope.js +134 -0
- package/src/segments/index.js +65 -0
- package/src/segments/lines-changed.js +29 -0
- package/src/segments/model-name.js +28 -0
- package/src/segments/narrator.js +129 -0
- package/src/segments/output-style.js +25 -0
- package/src/segments/rpg-stats.js +119 -0
- package/src/segments/separator-arrow.js +22 -0
- package/src/segments/separator-pipe.js +22 -0
- package/src/segments/separator-space.js +22 -0
- package/src/segments/session-cost.js +72 -0
- package/src/segments/session-timer.js +53 -0
- package/src/segments/smart-nudge.js +97 -0
- package/src/segments/soundtrack.js +133 -0
- package/src/segments/speedrun.js +94 -0
- package/src/segments/stock-ticker.js +71 -0
- package/src/segments/streak.js +131 -0
- package/src/segments/tamagotchi.js +95 -0
- package/src/segments/token-sparkline.js +73 -0
- package/src/segments/version.js +27 -0
- package/src/segments/vibe-check.js +109 -0
- package/src/segments/vim-mode.js +29 -0
- package/src/segments/weather-report.js +88 -0
- package/themes/default.json +59 -0
- package/themes/minimal.json +37 -0
- package/themes/powerline.json +73 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// src/compositor.js — Layout engine
|
|
2
|
+
// Zero dependencies. Node 18+ ESM.
|
|
3
|
+
|
|
4
|
+
import { stylize, stripAnsi } from './color.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Detect terminal width. Safe methods only — no /dev/tty access, no shell
|
|
8
|
+
* subprocesses. Those can corrupt the terminal when run from a piped subprocess.
|
|
9
|
+
*
|
|
10
|
+
* @returns {number}
|
|
11
|
+
*/
|
|
12
|
+
function detectWidth() {
|
|
13
|
+
// 1. Explicit env override (user can set OMC_WIDTH in settings.json env)
|
|
14
|
+
if (process.env.OMC_WIDTH) {
|
|
15
|
+
const w = parseInt(process.env.OMC_WIDTH, 10);
|
|
16
|
+
if (w > 0) return w;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// 2. COLUMNS env var (set by some shells, inherited by subprocesses)
|
|
20
|
+
if (process.env.COLUMNS) {
|
|
21
|
+
const w = parseInt(process.env.COLUMNS, 10);
|
|
22
|
+
if (w > 0) return w;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// 3. stdout/stderr columns (works if either is still a TTY)
|
|
26
|
+
if (process.stdout.columns) return process.stdout.columns;
|
|
27
|
+
if (process.stderr.columns) return process.stderr.columns;
|
|
28
|
+
|
|
29
|
+
// 4. Fallback — most modern terminals are wider than 80
|
|
30
|
+
return 120;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Render an array of segment results into a styled string joined by separator.
|
|
35
|
+
*
|
|
36
|
+
* @param {Array<{text: string, style: string}>} parts - Segment outputs
|
|
37
|
+
* @param {string} separator - Plain separator string (will not be styled)
|
|
38
|
+
* @returns {{ rendered: string, plainLength: number }}
|
|
39
|
+
*/
|
|
40
|
+
function renderSide(parts, separator) {
|
|
41
|
+
if (!parts || !Array.isArray(parts) || parts.length === 0) {
|
|
42
|
+
return { rendered: '', plainLength: 0 };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const styledParts = [];
|
|
46
|
+
for (const part of parts) {
|
|
47
|
+
if (!part || part.text == null) continue;
|
|
48
|
+
const text = String(part.text);
|
|
49
|
+
if (!text) continue;
|
|
50
|
+
styledParts.push(stylize(text, part.style || ''));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (styledParts.length === 0) {
|
|
54
|
+
return { rendered: '', plainLength: 0 };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const sep = separator || ' | ';
|
|
58
|
+
const rendered = styledParts.join(sep);
|
|
59
|
+
const plainLength = stripAnsi(rendered).length;
|
|
60
|
+
|
|
61
|
+
return { rendered, plainLength };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Compose the final statusline output from line definitions.
|
|
66
|
+
*
|
|
67
|
+
* @param {Array<{left: Array, right: Array}>} lines - Line objects with left/right segment arrays
|
|
68
|
+
* @param {number} [terminalWidth] - Terminal width override (defaults to process.stdout.columns or 80)
|
|
69
|
+
* @param {string} [separator] - Separator between segments (default: ' | ')
|
|
70
|
+
* @returns {string} Final formatted output string (lines joined by newline)
|
|
71
|
+
*/
|
|
72
|
+
export function compose(lines, terminalWidth, separator) {
|
|
73
|
+
if (!lines || !Array.isArray(lines) || lines.length === 0) return '';
|
|
74
|
+
|
|
75
|
+
const width = terminalWidth || detectWidth();
|
|
76
|
+
const sep = separator || ' | ';
|
|
77
|
+
const outputLines = [];
|
|
78
|
+
|
|
79
|
+
for (const line of lines) {
|
|
80
|
+
if (!line) {
|
|
81
|
+
outputLines.push('');
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const left = renderSide(line.left, sep);
|
|
86
|
+
const right = renderSide(line.right, sep);
|
|
87
|
+
|
|
88
|
+
// If both sides are empty, skip this line entirely
|
|
89
|
+
if (!left.rendered && !right.rendered) continue;
|
|
90
|
+
|
|
91
|
+
// If only one side has content
|
|
92
|
+
if (!right.rendered) {
|
|
93
|
+
outputLines.push(left.rendered);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (!left.rendered) {
|
|
97
|
+
// Right-align: pad with spaces
|
|
98
|
+
const padding = Math.max(0, width - right.plainLength);
|
|
99
|
+
outputLines.push(' '.repeat(padding) + right.rendered);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Both sides have content — fill middle with spaces
|
|
104
|
+
const usedWidth = left.plainLength + right.plainLength;
|
|
105
|
+
const gap = width - usedWidth;
|
|
106
|
+
|
|
107
|
+
if (gap >= 1) {
|
|
108
|
+
// Plenty of room — pad the middle
|
|
109
|
+
outputLines.push(left.rendered + ' '.repeat(gap) + right.rendered);
|
|
110
|
+
} else {
|
|
111
|
+
// Terminal too narrow — truncate left side to make room for right
|
|
112
|
+
// We need at least right.plainLength + 1 char for "..." truncation indicator
|
|
113
|
+
const available = width - right.plainLength - 1;
|
|
114
|
+
|
|
115
|
+
if (available <= 0) {
|
|
116
|
+
// Not enough room for anything on the left — just show right
|
|
117
|
+
outputLines.push(right.rendered);
|
|
118
|
+
} else {
|
|
119
|
+
// Truncate the left side's plain text and re-render
|
|
120
|
+
// We walk the rendered string character by character, tracking visible length
|
|
121
|
+
const truncated = truncateAnsi(left.rendered, available);
|
|
122
|
+
outputLines.push(truncated + '\x1b[0m' + ' ' + right.rendered);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return outputLines.join('\n');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Truncate an ANSI-styled string to a given visible character count.
|
|
132
|
+
* Preserves ANSI codes but cuts visible characters.
|
|
133
|
+
*
|
|
134
|
+
* @param {string} str - ANSI-styled string
|
|
135
|
+
* @param {number} maxVisible - Maximum visible character count
|
|
136
|
+
* @returns {string} Truncated string
|
|
137
|
+
*/
|
|
138
|
+
function truncateAnsi(str, maxVisible) {
|
|
139
|
+
if (!str) return '';
|
|
140
|
+
if (maxVisible <= 0) return '';
|
|
141
|
+
|
|
142
|
+
let visible = 0;
|
|
143
|
+
let result = '';
|
|
144
|
+
let i = 0;
|
|
145
|
+
|
|
146
|
+
while (i < str.length && visible < maxVisible) {
|
|
147
|
+
// Check for ANSI escape sequence
|
|
148
|
+
if (str[i] === '\x1b' && str[i + 1] === '[') {
|
|
149
|
+
// Find end of CSI sequence (ends at a letter)
|
|
150
|
+
let j = i + 2;
|
|
151
|
+
while (j < str.length && !/[A-Za-z]/.test(str[j])) j++;
|
|
152
|
+
if (j < str.length) j++; // include the terminating letter
|
|
153
|
+
result += str.slice(i, j);
|
|
154
|
+
i = j;
|
|
155
|
+
} else {
|
|
156
|
+
result += str[i];
|
|
157
|
+
visible++;
|
|
158
|
+
i++;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return result;
|
|
163
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// src/config.js — Configuration loader
|
|
2
|
+
// Zero dependencies. Node 18+ ESM.
|
|
3
|
+
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
5
|
+
import { join, dirname } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { homedir } from 'node:os';
|
|
8
|
+
|
|
9
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const PACKAGE_ROOT = join(__dirname, '..');
|
|
11
|
+
const THEMES_DIR = join(PACKAGE_ROOT, 'themes');
|
|
12
|
+
const DEFAULT_USER_CONFIG_PATH = join(homedir(), '.claude', 'oh-my-claude', 'config.json');
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Deep merge source into target (mutates target).
|
|
16
|
+
* Arrays are replaced, not merged.
|
|
17
|
+
*
|
|
18
|
+
* @param {object} target
|
|
19
|
+
* @param {object} source
|
|
20
|
+
* @returns {object} The merged target
|
|
21
|
+
*/
|
|
22
|
+
function deepMerge(target, source) {
|
|
23
|
+
if (!source || typeof source !== 'object' || Array.isArray(source)) return target;
|
|
24
|
+
if (!target || typeof target !== 'object' || Array.isArray(target)) return source;
|
|
25
|
+
|
|
26
|
+
for (const key of Object.keys(source)) {
|
|
27
|
+
const srcVal = source[key];
|
|
28
|
+
const tgtVal = target[key];
|
|
29
|
+
|
|
30
|
+
if (
|
|
31
|
+
srcVal && typeof srcVal === 'object' && !Array.isArray(srcVal) &&
|
|
32
|
+
tgtVal && typeof tgtVal === 'object' && !Array.isArray(tgtVal)
|
|
33
|
+
) {
|
|
34
|
+
target[key] = deepMerge(tgtVal, srcVal);
|
|
35
|
+
} else {
|
|
36
|
+
target[key] = srcVal;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return target;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Read and parse a JSON file. Returns null on any failure.
|
|
45
|
+
*
|
|
46
|
+
* @param {string} filepath
|
|
47
|
+
* @returns {object|null}
|
|
48
|
+
*/
|
|
49
|
+
function readJson(filepath) {
|
|
50
|
+
try {
|
|
51
|
+
const raw = readFileSync(filepath, 'utf8');
|
|
52
|
+
return JSON.parse(raw);
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Returns the hardcoded default theme configuration.
|
|
60
|
+
* Used as ultimate fallback if no theme files are found.
|
|
61
|
+
*
|
|
62
|
+
* @returns {object}
|
|
63
|
+
*/
|
|
64
|
+
export function getDefaultConfig() {
|
|
65
|
+
return {
|
|
66
|
+
separator: ' | ',
|
|
67
|
+
lines: [
|
|
68
|
+
{
|
|
69
|
+
left: ['model', 'context', 'cost'],
|
|
70
|
+
right: ['project'],
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
segments: {
|
|
74
|
+
model: {
|
|
75
|
+
style: 'bold cyan',
|
|
76
|
+
},
|
|
77
|
+
context: {
|
|
78
|
+
style: 'yellow',
|
|
79
|
+
warn_threshold: 60,
|
|
80
|
+
critical_threshold: 80,
|
|
81
|
+
warn_style: 'bold yellow',
|
|
82
|
+
critical_style: 'bold red',
|
|
83
|
+
},
|
|
84
|
+
cost: {
|
|
85
|
+
style: 'green',
|
|
86
|
+
warn_threshold: 5,
|
|
87
|
+
critical_threshold: 10,
|
|
88
|
+
warn_style: 'bold yellow',
|
|
89
|
+
critical_style: 'bold red',
|
|
90
|
+
},
|
|
91
|
+
project: {
|
|
92
|
+
style: 'blue',
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Load a theme file by name from the themes/ directory.
|
|
100
|
+
*
|
|
101
|
+
* @param {string} themeName - Theme name (without .json extension)
|
|
102
|
+
* @returns {object|null} Parsed theme config or null
|
|
103
|
+
*/
|
|
104
|
+
function loadTheme(themeName) {
|
|
105
|
+
if (!themeName || typeof themeName !== 'string') return null;
|
|
106
|
+
const themePath = join(THEMES_DIR, `${themeName}.json`);
|
|
107
|
+
return readJson(themePath);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Load the full configuration by merging theme + user overrides.
|
|
112
|
+
*
|
|
113
|
+
* Resolution order:
|
|
114
|
+
* 1. Start with hardcoded default config
|
|
115
|
+
* 2. Load theme file specified by user config (or "default" theme)
|
|
116
|
+
* 3. Deep merge theme on top of defaults
|
|
117
|
+
* 4. Deep merge user's per-segment config on top
|
|
118
|
+
* 5. Return final config
|
|
119
|
+
*
|
|
120
|
+
* @param {string} [userConfigPath] - Path to user config file (defaults to ~/.claude/oh-my-claude/config.json)
|
|
121
|
+
* @returns {object} Final merged configuration
|
|
122
|
+
*/
|
|
123
|
+
export function loadConfig(userConfigPath) {
|
|
124
|
+
const configPath = userConfigPath || DEFAULT_USER_CONFIG_PATH;
|
|
125
|
+
|
|
126
|
+
// Start with hardcoded defaults
|
|
127
|
+
let config = getDefaultConfig();
|
|
128
|
+
|
|
129
|
+
// Read user config to determine theme name
|
|
130
|
+
const userConfig = readJson(configPath);
|
|
131
|
+
const themeName = (userConfig && userConfig.theme) || 'default';
|
|
132
|
+
|
|
133
|
+
// Load and merge theme
|
|
134
|
+
const themeConfig = loadTheme(themeName);
|
|
135
|
+
if (themeConfig) {
|
|
136
|
+
config = deepMerge(config, themeConfig);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Merge user per-segment overrides
|
|
140
|
+
if (userConfig && userConfig.segments) {
|
|
141
|
+
if (!config.segments) config.segments = {};
|
|
142
|
+
config.segments = deepMerge(config.segments, userConfig.segments);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return config;
|
|
146
|
+
}
|
package/src/plugins.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// src/plugins.js — Plugin discovery and loading
|
|
2
|
+
// Zero dependencies. Node 18+ ESM.
|
|
3
|
+
//
|
|
4
|
+
// Scans ~/.claude/oh-my-claude/plugins/<name>/segment.js for user-defined segments.
|
|
5
|
+
// Each plugin must export `meta` (object with `name` string) and `render` (function).
|
|
6
|
+
// Bad or missing plugins are silently skipped — never crash the statusline.
|
|
7
|
+
|
|
8
|
+
import { readdirSync, statSync } from 'node:fs';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { homedir } from 'node:os';
|
|
11
|
+
import { pathToFileURL } from 'node:url';
|
|
12
|
+
|
|
13
|
+
const PLUGINS_DIR = join(homedir(), '.claude', 'oh-my-claude', 'plugins');
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Discover and load all valid plugins from the plugins directory.
|
|
17
|
+
*
|
|
18
|
+
* @returns {Promise<Record<string, { meta: object, render: function }>>}
|
|
19
|
+
* A map of { [meta.name]: module } for all valid plugins.
|
|
20
|
+
* Returns an empty object if the directory doesn't exist or no valid plugins are found.
|
|
21
|
+
*/
|
|
22
|
+
export async function discoverPlugins() {
|
|
23
|
+
const plugins = {};
|
|
24
|
+
|
|
25
|
+
// 1. List subdirectories — bail silently if dir doesn't exist
|
|
26
|
+
let entries;
|
|
27
|
+
try {
|
|
28
|
+
entries = readdirSync(PLUGINS_DIR);
|
|
29
|
+
} catch {
|
|
30
|
+
return plugins;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// 2. For each entry, check if it's a directory with a segment.js
|
|
34
|
+
for (const entry of entries) {
|
|
35
|
+
try {
|
|
36
|
+
const entryPath = join(PLUGINS_DIR, entry);
|
|
37
|
+
const stat = statSync(entryPath);
|
|
38
|
+
if (!stat.isDirectory()) continue;
|
|
39
|
+
|
|
40
|
+
const segmentPath = join(entryPath, 'segment.js');
|
|
41
|
+
|
|
42
|
+
// Verify segment.js exists before attempting import
|
|
43
|
+
try {
|
|
44
|
+
statSync(segmentPath);
|
|
45
|
+
} catch {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// 3. Dynamic import — use file:// URL for cross-platform ESM compatibility
|
|
50
|
+
const mod = await import(pathToFileURL(segmentPath).href);
|
|
51
|
+
|
|
52
|
+
// 4. Validate exports: must have meta.name (string) and render (function)
|
|
53
|
+
if (!mod.meta || typeof mod.meta.name !== 'string' || !mod.meta.name) continue;
|
|
54
|
+
if (typeof mod.render !== 'function') continue;
|
|
55
|
+
|
|
56
|
+
plugins[mod.meta.name] = mod;
|
|
57
|
+
} catch {
|
|
58
|
+
// Bad plugin — skip silently, never crash the statusline
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return plugins;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Return the plugins directory path (used by CLI commands).
|
|
68
|
+
* @returns {string}
|
|
69
|
+
*/
|
|
70
|
+
export function getPluginsDir() {
|
|
71
|
+
return PLUGINS_DIR;
|
|
72
|
+
}
|
package/src/runner.js
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// src/runner.js — Main entry point
|
|
2
|
+
// Zero dependencies. Node 18+ ESM.
|
|
3
|
+
// Claude Code pipes JSON to stdin, we output a formatted statusline to stdout.
|
|
4
|
+
//
|
|
5
|
+
// CRITICAL: This script runs as a piped subprocess. Claude Code may kill the
|
|
6
|
+
// pipe at any time (e.g., user starts typing, debounce fires). We MUST handle
|
|
7
|
+
// EPIPE gracefully and ensure ANSI state is always reset to avoid corrupting
|
|
8
|
+
// the terminal.
|
|
9
|
+
|
|
10
|
+
import { loadConfig } from './config.js';
|
|
11
|
+
import { compose } from './compositor.js';
|
|
12
|
+
import { segments } from './segments/index.js';
|
|
13
|
+
import { discoverPlugins } from './plugins.js';
|
|
14
|
+
|
|
15
|
+
// Silently ignore EPIPE — Claude Code closing the pipe is normal, not an error.
|
|
16
|
+
process.stdout.on('error', (err) => {
|
|
17
|
+
if (err.code === 'EPIPE') process.exit(0);
|
|
18
|
+
});
|
|
19
|
+
process.on('uncaughtException', (err) => {
|
|
20
|
+
if (err.code === 'EPIPE') process.exit(0);
|
|
21
|
+
process.exit(0); // Any other crash: exit cleanly, never hang
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Read all of stdin as a string.
|
|
26
|
+
* @returns {Promise<string>}
|
|
27
|
+
*/
|
|
28
|
+
function readStdin() {
|
|
29
|
+
return new Promise((resolve) => {
|
|
30
|
+
let data = '';
|
|
31
|
+
|
|
32
|
+
// If stdin is a TTY (no piped data), resolve immediately with empty string
|
|
33
|
+
if (process.stdin.isTTY) {
|
|
34
|
+
resolve('');
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
process.stdin.setEncoding('utf8');
|
|
39
|
+
|
|
40
|
+
process.stdin.on('data', (chunk) => {
|
|
41
|
+
data += chunk;
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
process.stdin.on('end', () => {
|
|
45
|
+
resolve(data);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
process.stdin.on('error', () => {
|
|
49
|
+
resolve('');
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Main execution.
|
|
56
|
+
*/
|
|
57
|
+
async function main() {
|
|
58
|
+
try {
|
|
59
|
+
// 1. Read stdin JSON
|
|
60
|
+
const raw = await readStdin();
|
|
61
|
+
if (!raw.trim()) {
|
|
62
|
+
process.stdout.write('');
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 2. Parse JSON
|
|
67
|
+
let data;
|
|
68
|
+
try {
|
|
69
|
+
data = JSON.parse(raw);
|
|
70
|
+
} catch {
|
|
71
|
+
process.stdout.write('');
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 3. Load config
|
|
76
|
+
const config = loadConfig();
|
|
77
|
+
const separator = config.separator || ' | ';
|
|
78
|
+
|
|
79
|
+
// 4. Discover plugins and merge with built-in segments
|
|
80
|
+
// Plugins override built-ins if they share the same name.
|
|
81
|
+
let allSegments = segments;
|
|
82
|
+
try {
|
|
83
|
+
const plugins = await discoverPlugins();
|
|
84
|
+
if (Object.keys(plugins).length > 0) {
|
|
85
|
+
allSegments = { ...segments, ...plugins };
|
|
86
|
+
}
|
|
87
|
+
} catch {
|
|
88
|
+
// Plugin discovery failed — continue with built-ins only
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// 5. Process each line from config
|
|
92
|
+
const lineResults = [];
|
|
93
|
+
|
|
94
|
+
const configLines = config.lines;
|
|
95
|
+
if (!configLines || !Array.isArray(configLines)) {
|
|
96
|
+
process.stdout.write('');
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
for (const lineDef of configLines) {
|
|
101
|
+
if (!lineDef) continue;
|
|
102
|
+
|
|
103
|
+
const leftParts = renderSegments(lineDef.left, data, config, allSegments);
|
|
104
|
+
const rightParts = renderSegments(lineDef.right, data, config, allSegments);
|
|
105
|
+
|
|
106
|
+
lineResults.push({ left: leftParts, right: rightParts });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// 6. Compose and output — always end with ANSI reset to prevent state leaking
|
|
110
|
+
const output = compose(lineResults, undefined, separator);
|
|
111
|
+
process.stdout.write(output + '\x1b[0m');
|
|
112
|
+
} catch {
|
|
113
|
+
// If ANYTHING fails, reset ANSI and exit cleanly
|
|
114
|
+
try { process.stdout.write('\x1b[0m'); } catch {}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Render an array of segment names into an array of {text, style} results.
|
|
120
|
+
*
|
|
121
|
+
* @param {string[]} segmentNames - Array of segment names from config
|
|
122
|
+
* @param {object} data - Parsed stdin JSON data
|
|
123
|
+
* @param {object} config - Full merged config
|
|
124
|
+
* @param {Record<string, { render: function }>} segmentRegistry - Map of segment name to module
|
|
125
|
+
* @returns {Array<{text: string, style: string}>}
|
|
126
|
+
*/
|
|
127
|
+
function renderSegments(segmentNames, data, config, segmentRegistry) {
|
|
128
|
+
if (!segmentNames || !Array.isArray(segmentNames)) return [];
|
|
129
|
+
|
|
130
|
+
const results = [];
|
|
131
|
+
|
|
132
|
+
for (const name of segmentNames) {
|
|
133
|
+
if (!name || typeof name !== 'string') continue;
|
|
134
|
+
|
|
135
|
+
const segment = segmentRegistry[name];
|
|
136
|
+
if (!segment || typeof segment.render !== 'function') continue;
|
|
137
|
+
|
|
138
|
+
const segmentConfig = (config.segments && config.segments[name]) || {};
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
const result = segment.render(data, segmentConfig);
|
|
142
|
+
if (result == null) continue;
|
|
143
|
+
if (typeof result === 'string') {
|
|
144
|
+
results.push({ text: result, style: segmentConfig.style || '' });
|
|
145
|
+
} else if (result.text != null) {
|
|
146
|
+
results.push({
|
|
147
|
+
text: String(result.text),
|
|
148
|
+
style: result.style || segmentConfig.style || '',
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
} catch {
|
|
152
|
+
// Segment threw — skip it silently
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return results;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
main();
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// src/segments/achievement.js — Unlockable achievement badges for session milestones
|
|
2
|
+
// Zero dependencies. Node 18+ ESM.
|
|
3
|
+
|
|
4
|
+
const ACHIEVEMENTS = [
|
|
5
|
+
{ id: 'first-blood', check: d => (d?.cost?.total_lines_added ?? 0) > 0, text: '\u{1F3C6} First Blood', desc: 'first edit' },
|
|
6
|
+
{ id: 'centurion', check: d => (d?.cost?.total_lines_added ?? 0) >= 100, text: '\u{1F4AF} Centurion', desc: '100+ lines' },
|
|
7
|
+
{ id: 'architect', check: d => (d?.cost?.total_lines_added ?? 0) >= 500, text: '\u{1F3D7}\uFE0F Architect', desc: '500+ lines' },
|
|
8
|
+
{ id: 'novelist', check: d => (d?.cost?.total_lines_added ?? 0) >= 1000, text: '\u{1F4D6} Novelist', desc: '1000+ lines' },
|
|
9
|
+
{ id: 'marie-kondo', check: d => (d?.cost?.total_lines_removed ?? 0) > (d?.cost?.total_lines_added ?? 0) && (d?.cost?.total_lines_removed ?? 0) > 10, text: '\u{1F9F9} Marie Kondo', desc: 'net negative' },
|
|
10
|
+
{ id: 'big-spender', check: d => (d?.cost?.total_cost_usd ?? 0) >= 5, text: '\u{1F4B0} Big Spender', desc: '$5+ session' },
|
|
11
|
+
{ id: 'whale', check: d => (d?.cost?.total_cost_usd ?? 0) >= 20, text: '\u{1F40B} Whale', desc: '$20+ session' },
|
|
12
|
+
{ id: 'marathon', check: d => (d?.cost?.total_duration_ms ?? 0) >= 3600000, text: '\u{1F3C3} Marathon', desc: '1hr+ session' },
|
|
13
|
+
{ id: 'ultramarathon', check: d => (d?.cost?.total_duration_ms ?? 0) >= 7200000, text: '\u{1F3C5} Ultramarathon', desc: '2hr+ session' },
|
|
14
|
+
{ id: 'half-full', check: d => (d?.context_window?.used_percentage ?? 0) >= 50, text: '\u23F3 Half Full', desc: '50% context' },
|
|
15
|
+
{ id: 'danger-zone', check: d => (d?.context_window?.used_percentage ?? 0) >= 80, text: '\u26A0\uFE0F Danger Zone', desc: '80% context' },
|
|
16
|
+
{ id: 'the-brink', check: d => (d?.context_window?.used_percentage ?? 0) >= 95, text: '\u{1F480} The Brink', desc: '95% context' },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
// Module-level state: tracks which achievements have already been displayed
|
|
20
|
+
const shown = new Set();
|
|
21
|
+
let currentDisplay = null;
|
|
22
|
+
let displayCount = 0;
|
|
23
|
+
|
|
24
|
+
export const meta = {
|
|
25
|
+
name: 'achievement',
|
|
26
|
+
description: 'Shows achievement badges that unlock based on session milestones',
|
|
27
|
+
requires: [],
|
|
28
|
+
defaultConfig: {
|
|
29
|
+
style: 'bold cyan',
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {object} data - Parsed stdin JSON from Claude Code
|
|
35
|
+
* @param {object} config - Per-segment config from theme
|
|
36
|
+
* @returns {{text: string, style: string}|null}
|
|
37
|
+
*/
|
|
38
|
+
export function render(data, config) {
|
|
39
|
+
const cfg = { ...meta.defaultConfig, ...config };
|
|
40
|
+
|
|
41
|
+
// Find the first achievement that is (a) passing its check and (b) not yet shown
|
|
42
|
+
let newUnlock = null;
|
|
43
|
+
for (const achievement of ACHIEVEMENTS) {
|
|
44
|
+
if (!shown.has(achievement.id) && achievement.check(data)) {
|
|
45
|
+
newUnlock = achievement;
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (newUnlock) {
|
|
51
|
+
shown.add(newUnlock.id);
|
|
52
|
+
currentDisplay = newUnlock;
|
|
53
|
+
displayCount = 0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (currentDisplay == null) return null;
|
|
57
|
+
|
|
58
|
+
displayCount++;
|
|
59
|
+
|
|
60
|
+
// After being displayed for 3+ renders, fade away
|
|
61
|
+
if (displayCount > 3) {
|
|
62
|
+
currentDisplay = null;
|
|
63
|
+
displayCount = 0;
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return { text: currentDisplay.text, style: cfg.style };
|
|
68
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// src/segments/api-timer.js — Display cumulative API wait time
|
|
2
|
+
// Zero dependencies. Node 18+ ESM.
|
|
3
|
+
|
|
4
|
+
export const meta = {
|
|
5
|
+
name: 'api-timer',
|
|
6
|
+
description: 'Shows cumulative API duration (total_api_duration_ms)',
|
|
7
|
+
requires: [],
|
|
8
|
+
defaultConfig: {
|
|
9
|
+
style: 'dim',
|
|
10
|
+
label: 'api',
|
|
11
|
+
icon: false,
|
|
12
|
+
},
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Format milliseconds into a human-friendly duration string.
|
|
17
|
+
* Examples: "3s", "1m 24s", "2h 5m"
|
|
18
|
+
*
|
|
19
|
+
* @param {number} ms
|
|
20
|
+
* @returns {string}
|
|
21
|
+
*/
|
|
22
|
+
function formatDuration(ms) {
|
|
23
|
+
const totalSeconds = Math.floor(ms / 1000);
|
|
24
|
+
if (totalSeconds < 1) return '0s';
|
|
25
|
+
|
|
26
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
27
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
28
|
+
const seconds = totalSeconds % 60;
|
|
29
|
+
|
|
30
|
+
if (hours > 0) {
|
|
31
|
+
return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
|
|
32
|
+
}
|
|
33
|
+
if (minutes > 0) {
|
|
34
|
+
return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
|
|
35
|
+
}
|
|
36
|
+
return `${seconds}s`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {object} data - Parsed stdin JSON from Claude Code
|
|
41
|
+
* @param {object} config - Per-segment config from theme
|
|
42
|
+
* @returns {{text: string, style: string}|null}
|
|
43
|
+
*/
|
|
44
|
+
export function render(data, config) {
|
|
45
|
+
const cfg = { ...meta.defaultConfig, ...config };
|
|
46
|
+
|
|
47
|
+
const ms = data?.cost?.total_api_duration_ms;
|
|
48
|
+
if (!ms) return null;
|
|
49
|
+
|
|
50
|
+
const duration = formatDuration(ms);
|
|
51
|
+
let text = cfg.label ? `${cfg.label} ${duration}` : duration;
|
|
52
|
+
if (cfg.icon) text = `\u{F0527} ${text}`;
|
|
53
|
+
|
|
54
|
+
return { text, style: cfg.style };
|
|
55
|
+
}
|