@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.
Files changed (57) hide show
  1. package/README.md +317 -0
  2. package/bin/omc.js +403 -0
  3. package/docs/architecture.md +198 -0
  4. package/docs/segment-contract.md +186 -0
  5. package/docs/theme-format.md +156 -0
  6. package/package.json +35 -0
  7. package/src/cache.js +102 -0
  8. package/src/color.js +105 -0
  9. package/src/compositor.js +163 -0
  10. package/src/config.js +146 -0
  11. package/src/plugins.js +72 -0
  12. package/src/runner.js +160 -0
  13. package/src/segments/achievement.js +68 -0
  14. package/src/segments/api-timer.js +55 -0
  15. package/src/segments/battle-log.js +55 -0
  16. package/src/segments/cat.js +89 -0
  17. package/src/segments/coffee-cup.js +81 -0
  18. package/src/segments/commit-msg.js +95 -0
  19. package/src/segments/context-bar.js +50 -0
  20. package/src/segments/context-percent.js +40 -0
  21. package/src/segments/context-tokens.js +52 -0
  22. package/src/segments/cost-budget.js +43 -0
  23. package/src/segments/coworker.js +137 -0
  24. package/src/segments/custom-text.js +25 -0
  25. package/src/segments/directory.js +75 -0
  26. package/src/segments/emoji-story.js +99 -0
  27. package/src/segments/flex-space.js +25 -0
  28. package/src/segments/fortune-cookie.js +131 -0
  29. package/src/segments/garden.js +57 -0
  30. package/src/segments/git-branch.js +36 -0
  31. package/src/segments/git-status.js +56 -0
  32. package/src/segments/horoscope.js +134 -0
  33. package/src/segments/index.js +65 -0
  34. package/src/segments/lines-changed.js +29 -0
  35. package/src/segments/model-name.js +28 -0
  36. package/src/segments/narrator.js +129 -0
  37. package/src/segments/output-style.js +25 -0
  38. package/src/segments/rpg-stats.js +119 -0
  39. package/src/segments/separator-arrow.js +22 -0
  40. package/src/segments/separator-pipe.js +22 -0
  41. package/src/segments/separator-space.js +22 -0
  42. package/src/segments/session-cost.js +72 -0
  43. package/src/segments/session-timer.js +53 -0
  44. package/src/segments/smart-nudge.js +97 -0
  45. package/src/segments/soundtrack.js +133 -0
  46. package/src/segments/speedrun.js +94 -0
  47. package/src/segments/stock-ticker.js +71 -0
  48. package/src/segments/streak.js +131 -0
  49. package/src/segments/tamagotchi.js +95 -0
  50. package/src/segments/token-sparkline.js +73 -0
  51. package/src/segments/version.js +27 -0
  52. package/src/segments/vibe-check.js +109 -0
  53. package/src/segments/vim-mode.js +29 -0
  54. package/src/segments/weather-report.js +88 -0
  55. package/themes/default.json +59 -0
  56. package/themes/minimal.json +37 -0
  57. 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
+ }