lumira 1.13.0 → 1.15.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/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +26 -1
- package/dist/commands/subagent.js +129 -0
- package/dist/config.js +2 -0
- package/dist/index.js +15 -0
- package/dist/installer.js +58 -31
- package/dist/render/line1.js +30 -15
- package/dist/render/powerline-line1.js +14 -13
- package/dist/render/text.js +18 -6
- package/dist/types.js +1 -0
- package/dist/utils/terminal.js +11 -6
- package/package.json +1 -1
|
@@ -5,14 +5,14 @@
|
|
|
5
5
|
},
|
|
6
6
|
"metadata": {
|
|
7
7
|
"description": "Real-time statusline HUD for Claude Code and Qwen Code — analytics, quota projection, themes, powerline",
|
|
8
|
-
"version": "1.
|
|
8
|
+
"version": "1.15.0"
|
|
9
9
|
},
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "lumira",
|
|
13
13
|
"source": "./",
|
|
14
14
|
"description": "Real-time statusline HUD for Claude Code and Qwen Code. Session analytics, API latency widget, 7-day quota projection, auto-compact warnings, 7 themes, powerline support. Zero runtime dependencies. Run /lumira:setup after install to activate.",
|
|
15
|
-
"version": "1.
|
|
15
|
+
"version": "1.15.0",
|
|
16
16
|
"author": {
|
|
17
17
|
"name": "Carlos Cativo"
|
|
18
18
|
},
|
|
@@ -32,5 +32,5 @@
|
|
|
32
32
|
]
|
|
33
33
|
}
|
|
34
34
|
],
|
|
35
|
-
"version": "1.
|
|
35
|
+
"version": "1.15.0"
|
|
36
36
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lumira",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.15.0",
|
|
4
4
|
"description": "Real-time statusline HUD for Claude Code and Qwen Code — session analytics, API latency, 7-day quota projection, auto-compact warnings, 7 themes, powerline. Zero runtime deps.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Carlos Cativo"
|
package/README.md
CHANGED
|
@@ -111,6 +111,7 @@ See [`docs/competitive-comparison.md`](docs/competitive-comparison.md) for the f
|
|
|
111
111
|
- **Pace delta** — `usedPct − elapsedPct` of the 5h window. Turtle when behind pace (healthy), car with time-to-exhaustion when ahead. Color escalates green → yellow → orange → blinkRed. Toggle independently via `display.paceDelta`.
|
|
112
112
|
- **7d quota projection** — when the current burn rate would exhaust the 7d quota before the window resets, the 7d segment grows a warning: `⚠ ~24h`, `⚠ ~2d`, `⚠ Tue`, or `🔥 ~8h` (critical icon under 12h). Default on. Toggle via `display.quotaProjection`; off in the `minimal` preset. Different from pace delta — pace looks backwards at the 5h window's actual vs proportional burn, projection looks forwards at the 7d window's exhaustion ETA.
|
|
113
113
|
- **Active agents** — live count of running subagents (`⚡N agents`) plus types parsed from the transcript. Toggle via `display.agents`.
|
|
114
|
+
- **Subagent panel rows** — custom rendering for each row in Claude Code's agent panel via [`lumira subagent`](#subagent-rows): a state glyph (running / done / error), what each agent is doing, and its token count, in your configured theme and icon set. Opt-in at install.
|
|
114
115
|
- **Cache hit rate** — prompt cache efficiency for the current turn (`87%⚡`). Alarm-mode: hidden while ≥90% (Anthropic's prompt cache pins this near 99% in healthy steady state, so an always-on number is wallpaper, not signal). Surfaces as yellow/orange/blinkRed only when the cache is actually degrading. Same hide-when-healthy pattern as rate-limits and agent count.
|
|
115
116
|
- **GSD integration** — current task and update notifications (opt-in).
|
|
116
117
|
- **Config health widget** — surfaces silent fallbacks (theme/powerline degrading in named-ANSI, missing GSD STATE.md). Opt-in.
|
|
@@ -259,6 +260,29 @@ lumira stats
|
|
|
259
260
|
|
|
260
261
|
**Qwen Code sessions** are parsed the same way, but cost and burn-rate lines are suppressed when the transcript lacks usage blocks (`hasCostData: false` in the JSON output) — no misleading `$0.00`.
|
|
261
262
|
|
|
263
|
+
## Subagent rows
|
|
264
|
+
|
|
265
|
+
Claude Code (≥ 2.1.x) exposes a [`subagentStatusLine`](https://code.claude.com/docs/en/statusline#subagent-status-lines) hook that lets a command customize how each subagent renders in the agent panel — replacing the default `name · description · token count` row. `lumira subagent` reads the visible tasks from stdin and renders each row in your configured theme and icon set:
|
|
266
|
+
|
|
267
|
+
```
|
|
268
|
+
reviewing auth module · 15k tok · running
|
|
269
|
+
✓ exploring the codebase · 3k tok · done
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
The **glyph and colour are driven by state** — running (cyan clock), done (green check), error (red warning) — so a glance tells you what's live, what finished, what failed.
|
|
273
|
+
|
|
274
|
+
The **row label** identifies each agent. In practice Claude Code reports every Task subagent with the generic `type: "local_agent"` and no `name`, so the per-agent `description` is the only field that distinguishes one row from another — lumira uses it. The full fallback is `name → a meaningful type → description → id`: an explicit `name` or a real agent type wins when Claude Code provides one, otherwise the `description` carries the row. Rows respect your `icons` (`nerd`/`emoji`/`none`) and `colors.mode` config, and are truncated to the width Claude Code budgets.
|
|
275
|
+
|
|
276
|
+
**Enable it:** the installer offers to register `subagentStatusLine` alongside the main statusline (opt-in — it never writes the second key without asking). To wire it manually, add to `~/.claude/settings.json`:
|
|
277
|
+
|
|
278
|
+
```json
|
|
279
|
+
{
|
|
280
|
+
"subagentStatusLine": { "type": "command", "command": "lumira subagent", "padding": 0 }
|
|
281
|
+
}
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
It's robust by design: on any unreadable or malformed payload it emits nothing and exits 0, so Claude Code falls back to its default rows — a bad render never breaks the panel. Set `LUMIRA_DEBUG=1` to log the raw payload Claude Code sends to stderr.
|
|
285
|
+
|
|
262
286
|
## Powerline
|
|
263
287
|
|
|
264
288
|
`style: "powerline"` (or `--powerline`) renders the statusline with colored segment backgrounds and glyph separators inspired by powerline-go / oh-my-posh. Available separator presets via `powerline.style` (or `--powerline-style=<name>`):
|
|
@@ -297,6 +321,7 @@ Create `~/.config/lumira/config.json`:
|
|
|
297
321
|
"theme": "tokyo-night",
|
|
298
322
|
"icons": "nerd",
|
|
299
323
|
"style": "classic",
|
|
324
|
+
"line1Align": "justified",
|
|
300
325
|
"powerline": { "style": "auto" },
|
|
301
326
|
"gsd": false,
|
|
302
327
|
"colors": { "mode": "auto" },
|
|
@@ -340,7 +365,7 @@ Create `~/.config/lumira/config.json`:
|
|
|
340
365
|
}
|
|
341
366
|
```
|
|
342
367
|
|
|
343
|
-
All fields are optional — defaults are shown above. `display.health` defaults to `false` (opt-in widget).
|
|
368
|
+
All fields are optional — defaults are shown above. `display.health` defaults to `false` (opt-in widget). `line1Align` controls line 1's classic layout: `"justified"` (default) pins the left cluster to the start and the right cluster to the end; `"packed"` packs every segment tightly to the left with no forced middle gap, except the app version string, which stays pinned to the true right edge (the same idiom line 2 uses for its small right-anchored indicators). On a terminal too narrow to fit the version alongside the packed content, the version disappears entirely rather than truncating — an intentional all-or-nothing tradeoff. Powerline mode always packs.
|
|
344
369
|
|
|
345
370
|
**Context bar thresholds** — `contextWarningThreshold` (default 70) and `contextCriticalThreshold` (default 85) control when the bar transitions through yellow/orange/red. Both are clamped to `[0, 100]` and `warning < critical` is required (invalid pairs fall back to defaults with a one-shot stderr warning). Lower them for earlier warnings, raise them if your workflow tolerates fuller buffers.
|
|
346
371
|
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `lumira subagent` subcommand (issue #176 — `subagentStatusLine` renderer).
|
|
3
|
+
*
|
|
4
|
+
* Claude Code (≥ 2.1.x) lets a command customize how each subagent row renders
|
|
5
|
+
* in the agent panel. CC pipes a JSON object on stdin describing every visible
|
|
6
|
+
* subagent and expects ONE JSON line back per row (`{ id, content }`). Omitting
|
|
7
|
+
* an id keeps CC's default rendering for that row; empty content hides it.
|
|
8
|
+
*
|
|
9
|
+
* Input schema (distinct from the main statusline's `RawInput`):
|
|
10
|
+
* { columns: number, tasks: [ { id, name, type, status, description,
|
|
11
|
+
* label, startTime, tokenCount, ... } ] }
|
|
12
|
+
*
|
|
13
|
+
* Icon choice is by STATE, not agent type. CC's `type` field is open-ended and
|
|
14
|
+
* user-defined (dozens of agent types in a real setup), so there's no stable
|
|
15
|
+
* type→glyph map to maintain — but `running/completed/error` map cleanly onto
|
|
16
|
+
* glyphs lumira already ships across all three icon sets. The state also drives
|
|
17
|
+
* the colour, so a glance tells you what's live vs done vs failed.
|
|
18
|
+
*
|
|
19
|
+
* Robustness contract: this runs inside CC's render loop. On ANY bad input
|
|
20
|
+
* (unreadable stdin, malformed JSON, missing fields) it emits nothing and exits
|
|
21
|
+
* 0 — CC then falls back to its default rows. It must never crash the panel.
|
|
22
|
+
*/
|
|
23
|
+
import { formatTokens } from '../utils/format.js';
|
|
24
|
+
import { displayWidth, truncField } from '../render/text.js';
|
|
25
|
+
import { resolveIcons } from '../render/icons.js';
|
|
26
|
+
import { createColors, detectColorMode } from '../render/colors.js';
|
|
27
|
+
import { loadConfig } from '../config.js';
|
|
28
|
+
import { readStdin } from '../stdin.js';
|
|
29
|
+
import { debug } from '../utils/debug.js';
|
|
30
|
+
/** Per-state presentation: glyph + colour + human label. */
|
|
31
|
+
function styleFor(status, icons, colors) {
|
|
32
|
+
switch (status) {
|
|
33
|
+
case 'running':
|
|
34
|
+
return { icon: icons.clock, color: colors.cyan, label: 'running' };
|
|
35
|
+
case 'completed':
|
|
36
|
+
return { icon: icons.checkmark, color: colors.green, label: 'done' };
|
|
37
|
+
case 'error':
|
|
38
|
+
case 'failed':
|
|
39
|
+
return { icon: icons.warning, color: colors.red, label: 'error' };
|
|
40
|
+
default:
|
|
41
|
+
// Unknown/absent state: no glyph, dim, pass the raw status through as the
|
|
42
|
+
// label so a future CC state is still legible instead of being dropped.
|
|
43
|
+
return { icon: '', color: colors.dim, label: status ?? '' };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Render the `content` string for a single subagent row:
|
|
48
|
+
* `<glyph> <name> · <tokens> · <state-label>`
|
|
49
|
+
*
|
|
50
|
+
* The glyph and label are state-coloured; tokens are dim. When `columns` is
|
|
51
|
+
* given, the name is truncated (with an ellipsis) so the visible row fits,
|
|
52
|
+
* reserving width for the fixed glyph/meta first.
|
|
53
|
+
*/
|
|
54
|
+
export function renderSubagentContent(task, icons, colors, columns) {
|
|
55
|
+
const { icon, color, label } = styleFor(task.status, icons, colors);
|
|
56
|
+
// Identity fallback. CC reports every Task subagent as the generic
|
|
57
|
+
// `type: "local_agent"` with no `name`, so `description` (the dispatch arg) is
|
|
58
|
+
// the only field that distinguishes rows — it must win over a generic type.
|
|
59
|
+
// A *meaningful* type (a real agent_type, if CC ever exposes one) still wins
|
|
60
|
+
// over description, and an explicit `name` wins over everything.
|
|
61
|
+
const meaningfulType = task.type && task.type !== 'local_agent' ? task.type : '';
|
|
62
|
+
const name = (task.name || meaningfulType || task.description || task.id || 'agent').trim();
|
|
63
|
+
const tokens = Number.isFinite(task.tokenCount) ? `${formatTokens(task.tokenCount)} tok` : '';
|
|
64
|
+
const prefix = icon ? `${color(icon)} ` : '';
|
|
65
|
+
const prefixW = icon ? displayWidth(icon) + 1 : 0;
|
|
66
|
+
// Plain (uncoloured) meta used both for measuring and for assembling the
|
|
67
|
+
// coloured version, so width math and output never drift apart.
|
|
68
|
+
const metaPlain = [tokens, label].filter(Boolean);
|
|
69
|
+
const metaW = metaPlain.length ? displayWidth(` · ${metaPlain.join(' · ')}`) : 0;
|
|
70
|
+
// Fit the name to whatever width is left after the fixed glyph + meta.
|
|
71
|
+
let shownName = name;
|
|
72
|
+
if (columns !== undefined && columns > 0) {
|
|
73
|
+
const budget = columns - prefixW - metaW;
|
|
74
|
+
if (budget < displayWidth(name))
|
|
75
|
+
shownName = budget > 1 ? truncField(name, budget) : '';
|
|
76
|
+
}
|
|
77
|
+
const metaColored = [];
|
|
78
|
+
if (tokens)
|
|
79
|
+
metaColored.push(colors.dim(tokens));
|
|
80
|
+
if (label)
|
|
81
|
+
metaColored.push(color(label));
|
|
82
|
+
const meta = metaColored.join(' · ');
|
|
83
|
+
// Avoid a dangling " · " when the name was truncated away entirely.
|
|
84
|
+
if (!shownName)
|
|
85
|
+
return `${prefix}${meta}`.trimEnd();
|
|
86
|
+
return meta ? `${prefix}${shownName} · ${meta}` : `${prefix}${shownName}`;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Render the full CC response: one `{ id, content }` JSON line per addressable
|
|
90
|
+
* task. Tasks without an id are skipped (CC keys rows by id, so a row we can't
|
|
91
|
+
* address can't be overridden). Empty/missing task list → empty string.
|
|
92
|
+
*/
|
|
93
|
+
export function renderSubagentTasks(input, icons, colors) {
|
|
94
|
+
const tasks = Array.isArray(input?.tasks) ? input.tasks : [];
|
|
95
|
+
const columns = Number.isFinite(input?.columns) ? input.columns : undefined;
|
|
96
|
+
const lines = [];
|
|
97
|
+
for (const task of tasks) {
|
|
98
|
+
if (!task || typeof task.id !== 'string' || task.id === '')
|
|
99
|
+
continue;
|
|
100
|
+
lines.push(JSON.stringify({ id: task.id, content: renderSubagentContent(task, icons, colors, columns) }));
|
|
101
|
+
}
|
|
102
|
+
return lines.join('\n');
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Wire stdin → config → render. Reuses the main `readStdin` (its only schema
|
|
106
|
+
* assertion is "is a plain object", which our payload satisfies). Any failure
|
|
107
|
+
* degrades to empty stdout + exit 0 so CC's panel is never broken.
|
|
108
|
+
*/
|
|
109
|
+
export async function runSubagentCommand(opts = {}) {
|
|
110
|
+
const log = debug('subagent');
|
|
111
|
+
let raw;
|
|
112
|
+
try {
|
|
113
|
+
raw = await readStdin(opts.stream ?? process.stdin);
|
|
114
|
+
}
|
|
115
|
+
catch (e) {
|
|
116
|
+
log('stdin read failed:', e.message);
|
|
117
|
+
return { stdout: '', stderr: '', exitCode: 0 };
|
|
118
|
+
}
|
|
119
|
+
if (log.enabled)
|
|
120
|
+
log('payload:', raw);
|
|
121
|
+
const config = opts.config ?? loadConfig();
|
|
122
|
+
const icons = resolveIcons(config.icons);
|
|
123
|
+
const mode = opts.colorMode
|
|
124
|
+
?? (config.colors?.mode && config.colors.mode !== 'auto' ? config.colors.mode : detectColorMode());
|
|
125
|
+
const colors = createColors(mode);
|
|
126
|
+
const out = renderSubagentTasks(raw, icons, colors);
|
|
127
|
+
return { stdout: out ? `${out}\n` : '', stderr: '', exitCode: 0 };
|
|
128
|
+
}
|
|
129
|
+
//# sourceMappingURL=subagent.js.map
|
package/dist/config.js
CHANGED
|
@@ -196,6 +196,7 @@ function mergeConfig(rawIn) {
|
|
|
196
196
|
raw = { ...raw, preset: 'minimal' };
|
|
197
197
|
}
|
|
198
198
|
const layout = ['multiline', 'singleline', 'auto'].includes(raw.layout) ? raw.layout : DEFAULT_CONFIG.layout;
|
|
199
|
+
const line1Align = ['justified', 'packed'].includes(raw.line1Align) ? raw.line1Align : DEFAULT_CONFIG.line1Align;
|
|
199
200
|
const colors = { ...DEFAULT_CONFIG.colors };
|
|
200
201
|
if (raw.colors && typeof raw.colors === 'object') {
|
|
201
202
|
const m = raw.colors.mode;
|
|
@@ -204,6 +205,7 @@ function mergeConfig(rawIn) {
|
|
|
204
205
|
}
|
|
205
206
|
const result = {
|
|
206
207
|
layout,
|
|
208
|
+
line1Align,
|
|
207
209
|
gsd: typeof raw.gsd === 'boolean' ? raw.gsd : DEFAULT_CONFIG.gsd,
|
|
208
210
|
display: { ...DEFAULT_DISPLAY },
|
|
209
211
|
colors,
|
package/dist/index.js
CHANGED
|
@@ -20,6 +20,7 @@ import { runThemesCommand } from './commands/themes.js';
|
|
|
20
20
|
import { runStatsCommand } from './commands/stats.js';
|
|
21
21
|
import { runCustomRefreshFromStdin } from './commands/custom-refresh.js';
|
|
22
22
|
import { runCustomCommand } from './commands/custom.js';
|
|
23
|
+
import { runSubagentCommand } from './commands/subagent.js';
|
|
23
24
|
import { EMPTY_TRANSCRIPT } from './types.js';
|
|
24
25
|
import { normalize } from './normalize.js';
|
|
25
26
|
const defaultDeps = {
|
|
@@ -121,6 +122,7 @@ Commands:
|
|
|
121
122
|
stats [path] Show session statistics
|
|
122
123
|
themes Browse and preview themes
|
|
123
124
|
custom Manage custom commands
|
|
125
|
+
subagent Render Claude Code agent-panel rows (reads tasks JSON from stdin)
|
|
124
126
|
|
|
125
127
|
Options:
|
|
126
128
|
--help, -h Show this help
|
|
@@ -181,6 +183,19 @@ if (isDirectRun()) {
|
|
|
181
183
|
process.exit(1);
|
|
182
184
|
});
|
|
183
185
|
}
|
|
186
|
+
else if (cmd === 'subagent') {
|
|
187
|
+
// CC's subagentStatusLine hook: reads the tasks JSON from its own stdin and
|
|
188
|
+
// emits one JSON line per row. Degrades to empty output on any error so a
|
|
189
|
+
// bad payload never breaks CC's agent panel.
|
|
190
|
+
runSubagentCommand().then(r => {
|
|
191
|
+
if (r.stdout)
|
|
192
|
+
process.stdout.write(r.stdout);
|
|
193
|
+
if (r.stderr)
|
|
194
|
+
process.stderr.write(r.stderr);
|
|
195
|
+
if (r.exitCode !== 0)
|
|
196
|
+
process.exit(r.exitCode);
|
|
197
|
+
}).catch(() => process.exit(0));
|
|
198
|
+
}
|
|
184
199
|
else if (cmd === '__custom-refresh') {
|
|
185
200
|
// Internal: invoked by the renderer as a detached child to refresh a
|
|
186
201
|
// single custom command's cache entry without keeping the renderer's
|
package/dist/installer.js
CHANGED
|
@@ -25,6 +25,25 @@ const header = () => `\n${CYAN} lumira installer${RST}\n`;
|
|
|
25
25
|
function makeStatusLine(command) {
|
|
26
26
|
return { type: 'command', command, padding: 0 };
|
|
27
27
|
}
|
|
28
|
+
/** Atomically write settings.json: temp file + fsync + rename, mode 0600. */
|
|
29
|
+
function writeSettingsAtomic(settings, settingsPath) {
|
|
30
|
+
mkdirSync(dirname(settingsPath), { recursive: true });
|
|
31
|
+
const tmp = `${settingsPath}.${process.pid}.${Date.now()}.lumira.tmp`;
|
|
32
|
+
try {
|
|
33
|
+
const fd = openSync(tmp, 'wx', 0o600);
|
|
34
|
+
writeSync(fd, JSON.stringify(settings, null, 2) + '\n');
|
|
35
|
+
fsyncSync(fd);
|
|
36
|
+
closeSync(fd);
|
|
37
|
+
renameSync(tmp, settingsPath);
|
|
38
|
+
}
|
|
39
|
+
catch (e) {
|
|
40
|
+
try {
|
|
41
|
+
unlinkSync(tmp);
|
|
42
|
+
}
|
|
43
|
+
catch { }
|
|
44
|
+
throw e;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
28
47
|
// Rank a statusLine command by per-render speed (higher = faster).
|
|
29
48
|
// 3 = bare `lumira` binary — always resolves to the current installed version
|
|
30
49
|
// 2 = node /path/dist/index.js or plugin-cache path — fast but version-pinned
|
|
@@ -153,6 +172,31 @@ function emitFooter(lines, homeOverride) {
|
|
|
153
172
|
lines.push(`\n Restart Claude Code to see your statusline.\n`);
|
|
154
173
|
}
|
|
155
174
|
// ── Install ─────────────────────────────────────────────────────────
|
|
175
|
+
/**
|
|
176
|
+
* Optionally register Claude Code's `subagentStatusLine` hook (CC ≥ 2.1.x)
|
|
177
|
+
* alongside the main statusLine, pointing it at `<cmd> subagent`.
|
|
178
|
+
*
|
|
179
|
+
* Opt-in and interactive-only: we never add a second settings key without
|
|
180
|
+
* explicit consent, and skip silently in non-TTY runs (so CI/scripted installs
|
|
181
|
+
* stay predictable). No-op when it already points at lumira. Returns true when
|
|
182
|
+
* the key was added, so the caller knows it must flush settings to disk.
|
|
183
|
+
*/
|
|
184
|
+
async function maybeRegisterSubagent(args) {
|
|
185
|
+
const { settings, baseCmd, confirm, isTTY, lines } = args;
|
|
186
|
+
// Only register when the key is absent. If it's already lumira there's nothing
|
|
187
|
+
// to do; if it's a *foreign* command we leave it untouched rather than clobber
|
|
188
|
+
// a user's own subagent renderer without an explicit backup/replace flow.
|
|
189
|
+
if (settings.subagentStatusLine != null)
|
|
190
|
+
return false;
|
|
191
|
+
if (!isTTY)
|
|
192
|
+
return false;
|
|
193
|
+
const accepted = await confirm('Customize subagent panel rows too? (subagentStatusLine)');
|
|
194
|
+
if (!accepted)
|
|
195
|
+
return false;
|
|
196
|
+
settings.subagentStatusLine = makeStatusLine(`${baseCmd} subagent`);
|
|
197
|
+
lines.push(ok(`Configured subagentStatusLine → ${DIM}${baseCmd} subagent${RST}`));
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
156
200
|
export async function install(opts = {}) {
|
|
157
201
|
const settingsPath = opts.settingsPath ?? defaultSettingsPath();
|
|
158
202
|
const configPath = opts.configPath ?? defaultConfigPath();
|
|
@@ -243,6 +287,11 @@ export async function install(opts = {}) {
|
|
|
243
287
|
// here — it may point to a stale version and should be migrated to `lumira`.
|
|
244
288
|
if (existingIsLumira && commandSpeed(existingCmd) >= 3) {
|
|
245
289
|
lines.push(ok('lumira is already configured (optimal command)'));
|
|
290
|
+
// statusLine needs no rewrite, but a returning user may still want to opt
|
|
291
|
+
// into the subagent hook — offer it and flush only if they accept.
|
|
292
|
+
const added = await maybeRegisterSubagent({ settings, baseCmd: existingCmd, confirm, isTTY: !!stdin?.isTTY, lines });
|
|
293
|
+
if (added)
|
|
294
|
+
writeSettingsAtomic(settings, settingsPath);
|
|
246
295
|
return finalize();
|
|
247
296
|
}
|
|
248
297
|
// Resolve the fastest per-render command this environment can offer.
|
|
@@ -254,6 +303,9 @@ export async function install(opts = {}) {
|
|
|
254
303
|
// so we never downgrade a user's direct binary to npx.
|
|
255
304
|
if (commandSpeed(resolvedCmd) <= commandSpeed(existingCmd)) {
|
|
256
305
|
lines.push(ok('lumira is already configured'));
|
|
306
|
+
const added = await maybeRegisterSubagent({ settings, baseCmd: existingCmd, confirm, isTTY: !!stdin?.isTTY, lines });
|
|
307
|
+
if (added)
|
|
308
|
+
writeSettingsAtomic(settings, settingsPath);
|
|
257
309
|
return finalize();
|
|
258
310
|
}
|
|
259
311
|
}
|
|
@@ -263,22 +315,8 @@ export async function install(opts = {}) {
|
|
|
263
315
|
lines.push(ok(`Backed up existing settings → ${DIM}settings.json.lumira.bak${RST}`));
|
|
264
316
|
}
|
|
265
317
|
settings.statusLine = makeStatusLine(resolvedCmd);
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
try {
|
|
269
|
-
const fd = openSync(tmp, 'wx', 0o600);
|
|
270
|
-
writeSync(fd, JSON.stringify(settings, null, 2) + '\n');
|
|
271
|
-
fsyncSync(fd);
|
|
272
|
-
closeSync(fd);
|
|
273
|
-
renameSync(tmp, settingsPath);
|
|
274
|
-
}
|
|
275
|
-
catch (e) {
|
|
276
|
-
try {
|
|
277
|
-
unlinkSync(tmp);
|
|
278
|
-
}
|
|
279
|
-
catch { }
|
|
280
|
-
throw e;
|
|
281
|
-
}
|
|
318
|
+
await maybeRegisterSubagent({ settings, baseCmd: resolvedCmd, confirm, isTTY: !!stdin?.isTTY, lines });
|
|
319
|
+
writeSettingsAtomic(settings, settingsPath);
|
|
282
320
|
lines.push(ok(existingIsLumira
|
|
283
321
|
? `Upgraded statusline command → ${DIM}${resolvedCmd}${RST} (faster)`
|
|
284
322
|
: 'Configured lumira as statusline'));
|
|
@@ -339,21 +377,10 @@ export function uninstall(opts = {}) {
|
|
|
339
377
|
return lines.join('\n') + '\n';
|
|
340
378
|
}
|
|
341
379
|
delete uninstSettings.statusLine;
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
fsyncSync(fd);
|
|
347
|
-
closeSync(fd);
|
|
348
|
-
renameSync(uninstTmp, settingsPath);
|
|
349
|
-
}
|
|
350
|
-
catch (e) {
|
|
351
|
-
try {
|
|
352
|
-
unlinkSync(uninstTmp);
|
|
353
|
-
}
|
|
354
|
-
catch { }
|
|
355
|
-
throw e;
|
|
356
|
-
}
|
|
380
|
+
// Only remove the subagent hook if it's ours — never wipe a foreign renderer.
|
|
381
|
+
if (isLumira(uninstSettings.subagentStatusLine))
|
|
382
|
+
delete uninstSettings.subagentStatusLine;
|
|
383
|
+
writeSettingsAtomic(uninstSettings, settingsPath);
|
|
357
384
|
lines.push(ok('Removed lumira statusline from settings'));
|
|
358
385
|
// Remove skill from both destinations (best effort)
|
|
359
386
|
for (const root of [join(home, '.claude'), join(home, '.qwen')]) {
|
package/dist/render/line1.js
CHANGED
|
@@ -6,7 +6,7 @@ import { hyperlink } from './hyperlink.js';
|
|
|
6
6
|
import { formatDuration } from '../utils/format.js';
|
|
7
7
|
import { isNamedAgentType } from '../parsers/subagents.js';
|
|
8
8
|
export function renderLine1(ctx, c) {
|
|
9
|
-
const { input, git, transcript, config: { display }, cols, icons, memory, tokenSpeed } = ctx;
|
|
9
|
+
const { input, git, transcript, config: { display, line1Align }, cols, icons, memory, tokenSpeed } = ctx;
|
|
10
10
|
const left = [];
|
|
11
11
|
const right = [];
|
|
12
12
|
// Model
|
|
@@ -27,7 +27,19 @@ export function renderLine1(ctx, c) {
|
|
|
27
27
|
}
|
|
28
28
|
left.push(branchStr);
|
|
29
29
|
}
|
|
30
|
-
//
|
|
30
|
+
// Repo segment — owner/name from workspace.repo, clickable to open the repo
|
|
31
|
+
// on its host. Sits right after branch: both are git-identity (where you are
|
|
32
|
+
// in the DAG + which remote it tracks), so they stay grouped. Distinct from
|
|
33
|
+
// the directory breadcrumb below (a local path): this is the canonical remote
|
|
34
|
+
// identity, and surfaces the owner that the bare cwd basename can't.
|
|
35
|
+
if (display.repo && input.repo) {
|
|
36
|
+
const { owner, name, url } = input.repo;
|
|
37
|
+
const repoLen = cols < 80 ? 14 : cols < 120 ? 24 : 36;
|
|
38
|
+
const label = c.brightBlue(`${icons.repo} ${truncField(`${owner}/${name}`, repoLen)}`);
|
|
39
|
+
left.push(hyperlink(url, label));
|
|
40
|
+
}
|
|
41
|
+
// Directory — local filesystem context (where on disk), orthogonal to the
|
|
42
|
+
// git identity above. Rendered after repo so the two git segments group.
|
|
31
43
|
if (display.directory) {
|
|
32
44
|
const cwd = input.cwd;
|
|
33
45
|
if (cwd) {
|
|
@@ -40,16 +52,6 @@ export function renderLine1(ctx, c) {
|
|
|
40
52
|
left.push(hyperlink(pathToFileURL(cwd).href, label));
|
|
41
53
|
}
|
|
42
54
|
}
|
|
43
|
-
// Repo segment — owner/name from workspace.repo, clickable to open the repo
|
|
44
|
-
// on its host. Distinct from the directory breadcrumb above (a local path):
|
|
45
|
-
// this is the canonical remote identity, and surfaces the owner that the
|
|
46
|
-
// bare cwd basename can't.
|
|
47
|
-
if (display.repo && input.repo) {
|
|
48
|
-
const { owner, name, url } = input.repo;
|
|
49
|
-
const repoLen = cols < 80 ? 14 : cols < 120 ? 24 : 36;
|
|
50
|
-
const label = c.brightBlue(`${icons.repo} ${truncField(`${owner}/${name}`, repoLen)}`);
|
|
51
|
-
left.push(hyperlink(url, label));
|
|
52
|
-
}
|
|
53
55
|
// Added dirs badge — only when count > 0; warning color at >= 5
|
|
54
56
|
if (display.addedDirs && input.addedDirsCount != null && input.addedDirsCount > 0) {
|
|
55
57
|
const badge = `+${input.addedDirsCount} dirs`;
|
|
@@ -118,8 +120,11 @@ export function renderLine1(ctx, c) {
|
|
|
118
120
|
right.push(c.gray(input.outputStyle));
|
|
119
121
|
}
|
|
120
122
|
// Version — link to the Claude Code npm page for quick changelog lookup.
|
|
123
|
+
// Kept as its own segment (not pushed straight into `right`) because packed
|
|
124
|
+
// mode pins it alone to the true right edge while everything else packs left.
|
|
125
|
+
let versionSeg = null;
|
|
121
126
|
if (display.version && input.version) {
|
|
122
|
-
|
|
127
|
+
versionSeg = hyperlink(`https://www.npmjs.com/package/@anthropic-ai/claude-code/v/${encodeURIComponent(input.version)}`, c.dim(`v${input.version}`));
|
|
123
128
|
}
|
|
124
129
|
// Custom commands (issue #143 phase 3) — appended last on the left so they
|
|
125
130
|
// sit after core widgets and evict first under fitSegments' narrow-cols
|
|
@@ -129,8 +134,18 @@ export function renderLine1(ctx, c) {
|
|
|
129
134
|
if (seg)
|
|
130
135
|
left.push(seg);
|
|
131
136
|
}
|
|
132
|
-
if (left.length === 0 && right.length === 0)
|
|
137
|
+
if (left.length === 0 && right.length === 0 && !versionSeg)
|
|
133
138
|
return '';
|
|
134
|
-
|
|
139
|
+
// packed: pack every non-version segment tightly to the left with a single
|
|
140
|
+
// separator (no forced middle gap), leaving ONLY the version string pinned to
|
|
141
|
+
// the true right edge — the same idiom line2 uses for its small right-anchored
|
|
142
|
+
// cluster. fitSegments already leaves a gap before a short right array, and
|
|
143
|
+
// drops it whole when it can't fit (version vanishes rather than truncating on
|
|
144
|
+
// a narrow terminal — an accepted all-or-nothing tradeoff, see README).
|
|
145
|
+
if (line1Align === 'packed') {
|
|
146
|
+
return fitSegments(left.concat(right), versionSeg ? [versionSeg] : [], SEP, cols);
|
|
147
|
+
}
|
|
148
|
+
// justified: version rejoins the tail of the right cluster, pinned to the edge.
|
|
149
|
+
return fitSegments(left, versionSeg ? [...right, versionSeg] : right, SEP, cols);
|
|
135
150
|
}
|
|
136
151
|
//# sourceMappingURL=line1.js.map
|
|
@@ -69,27 +69,28 @@ function buildSegments(ctx, palette, c) {
|
|
|
69
69
|
priority: 80,
|
|
70
70
|
});
|
|
71
71
|
}
|
|
72
|
-
if (display.
|
|
73
|
-
const
|
|
72
|
+
if (display.repo && input.repo) {
|
|
73
|
+
const { owner, name, url } = input.repo;
|
|
74
|
+
// Priority 61 — above directory@60. Repo is git identity (owner/name of the
|
|
75
|
+
// remote), grouped with branch, so it survives narrow-terminal pressure a
|
|
76
|
+
// step longer than the local directory. Rendered before directory to keep
|
|
77
|
+
// the git-identity segments adjacent. Same dir-family background.
|
|
74
78
|
segments.push({
|
|
75
|
-
text: hyperlink(
|
|
76
|
-
icon: icons.
|
|
79
|
+
text: hyperlink(url, truncField(`${owner}/${name}`, 36)),
|
|
80
|
+
icon: icons.repo,
|
|
77
81
|
bg: palette.dirBg,
|
|
78
82
|
fg: palette.fg,
|
|
79
|
-
priority:
|
|
83
|
+
priority: 61,
|
|
80
84
|
});
|
|
81
85
|
}
|
|
82
|
-
if (display.
|
|
83
|
-
const
|
|
84
|
-
// Priority 58 — below directory@60 and the addedDirs badge@59. Repo is the
|
|
85
|
-
// canonical remote identity (annotates the local dir), so it cedes first
|
|
86
|
-
// under narrow-terminal pressure. Same dir-family background as directory.
|
|
86
|
+
if (display.directory && input.cwd) {
|
|
87
|
+
const dirName = basename(input.cwd) || input.cwd;
|
|
87
88
|
segments.push({
|
|
88
|
-
text: hyperlink(
|
|
89
|
-
icon: icons.
|
|
89
|
+
text: hyperlink(pathToFileURL(input.cwd).href, truncField(dirName, 30)),
|
|
90
|
+
icon: icons.folder,
|
|
90
91
|
bg: palette.dirBg,
|
|
91
92
|
fg: palette.fg,
|
|
92
|
-
priority:
|
|
93
|
+
priority: 60,
|
|
93
94
|
});
|
|
94
95
|
}
|
|
95
96
|
if (display.addedDirs && input.addedDirsCount != null && input.addedDirsCount > 0) {
|
package/dist/render/text.js
CHANGED
|
@@ -15,7 +15,11 @@ export function displayWidth(str) {
|
|
|
15
15
|
w += 1;
|
|
16
16
|
continue;
|
|
17
17
|
}
|
|
18
|
-
|
|
18
|
+
// 0x2300–0x24FF covers wide Misc-Technical / Enclosed-Alphanumeric symbols
|
|
19
|
+
// but STOPS before the Box Drawing block (U+2500–U+257F), whose glyphs —
|
|
20
|
+
// including │ (U+2502), lumira's default SEP separator — are single-cell.
|
|
21
|
+
// Counting them as 2 over-reserved width on every separated line.
|
|
22
|
+
if (cp >= 0x1F000 || (cp >= 0x2300 && cp <= 0x24FF) || (cp >= 0x25A0 && cp <= 0x25FF) ||
|
|
19
23
|
(cp >= 0x2600 && cp <= 0x27BF) || (cp >= 0x2B00 && cp <= 0x2BFF) ||
|
|
20
24
|
(cp >= 0x4E00 && cp <= 0x9FFF) || (cp >= 0x3000 && cp <= 0x303F) || (cp >= 0xFF00 && cp <= 0xFFEF)) {
|
|
21
25
|
w += 2;
|
|
@@ -53,13 +57,21 @@ export function truncatePath(str, maxLen = 20) {
|
|
|
53
57
|
return filename.slice(0, maxLen - 3) + '...';
|
|
54
58
|
return '.../' + filename;
|
|
55
59
|
}
|
|
60
|
+
// The usable width for a status line: total cols minus a 4-cell right margin.
|
|
61
|
+
// The margin absorbs terminals that reserve a trailing cell and prevents the
|
|
62
|
+
// off-by-one wraps that motivated it. Kept as the single source of this
|
|
63
|
+
// constant so callers computing leftover/fill width can't drift out of sync
|
|
64
|
+
// with fitSegments' own bound.
|
|
65
|
+
export function safeCols(cols) {
|
|
66
|
+
return Math.max(1, cols - 4);
|
|
67
|
+
}
|
|
56
68
|
export function fitSegments(left, right, sep, cols) {
|
|
57
|
-
const
|
|
69
|
+
const safe = safeCols(cols);
|
|
58
70
|
for (let l = left.length; l >= 1; l--) {
|
|
59
71
|
const lSlice = left.slice(0, l);
|
|
60
72
|
const leftStr = lSlice.join(sep);
|
|
61
73
|
const leftW = displayWidth(leftStr);
|
|
62
|
-
if (leftW >
|
|
74
|
+
if (leftW > safe)
|
|
63
75
|
continue;
|
|
64
76
|
for (let r = right.length; r >= 0; r--) {
|
|
65
77
|
const rSlice = right.slice(0, r);
|
|
@@ -67,8 +79,8 @@ export function fitSegments(left, right, sep, cols) {
|
|
|
67
79
|
return leftStr;
|
|
68
80
|
const rightStr = rSlice.join(sep);
|
|
69
81
|
const rightW = displayWidth(rightStr);
|
|
70
|
-
if (leftW + 1 + rightW <=
|
|
71
|
-
const gap = Math.max(1,
|
|
82
|
+
if (leftW + 1 + rightW <= safe) {
|
|
83
|
+
const gap = Math.max(1, safe - leftW - rightW);
|
|
72
84
|
return leftStr + ' '.repeat(gap) + rightStr;
|
|
73
85
|
}
|
|
74
86
|
}
|
|
@@ -77,7 +89,7 @@ export function fitSegments(left, right, sep, cols) {
|
|
|
77
89
|
// Safe because left[0] is the model name (~20 chars) — callers must ensure
|
|
78
90
|
// the first segment is short enough to truncate gracefully.
|
|
79
91
|
// Strip ANSI before hard-truncating to avoid cutting mid-escape-sequence.
|
|
80
|
-
return truncField(stripAnsi(left[0] ?? ''),
|
|
92
|
+
return truncField(stripAnsi(left[0] ?? ''), safe);
|
|
81
93
|
}
|
|
82
94
|
export function padLine(left, right, cols) {
|
|
83
95
|
const leftW = displayWidth(left);
|
package/dist/types.js
CHANGED
|
@@ -123,6 +123,7 @@ export const DEFAULT_DISPLAY = {
|
|
|
123
123
|
};
|
|
124
124
|
export const DEFAULT_CONFIG = {
|
|
125
125
|
layout: 'auto',
|
|
126
|
+
line1Align: 'justified',
|
|
126
127
|
// GSD on by default, mirroring GSD's own always-on statusline. Self-gates to
|
|
127
128
|
// nothing when there's no .planning/STATE.md and no update-check cache, so
|
|
128
129
|
// non-GSD users see no extra line and pay only a few cheap existsSync checks.
|
package/dist/utils/terminal.js
CHANGED
|
@@ -46,12 +46,17 @@ export function getTermCols() {
|
|
|
46
46
|
return 120;
|
|
47
47
|
}
|
|
48
48
|
// When stdout isn't a TTY (the statusline case — Claude Code pipes our output)
|
|
49
|
-
// we
|
|
50
|
-
//
|
|
51
|
-
// the
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
|
|
49
|
+
// we trust the resolved rawCols since proc-tree / COLUMNS / tput give the real
|
|
50
|
+
// terminal width, and use the full width (factor 1.0). Earlier versions reserved
|
|
51
|
+
// headroom (0.7, then 0.9) on the theory that CC appends chrome (separators,
|
|
52
|
+
// gutters) to the statusline's own row. Inspecting a real render showed that is
|
|
53
|
+
// not the case: CC's own hints ("bypass permissions …", "PR #…", "← for agents")
|
|
54
|
+
// render on a SEPARATE line below the statusline, never appended to its row — so
|
|
55
|
+
// the reservation was speculative and only ever showed up as empty space at the
|
|
56
|
+
// far-right edge. Every renderer that treats `cols` as a width budget already
|
|
57
|
+
// protects itself: line1/line2 via fitSegments and the powerline lines via
|
|
58
|
+
// renderPowerline both subtract a proven 4-column margin (see text.ts / powerline.ts).
|
|
59
|
+
export function getLayoutCols(rawCols, isTTY, factor = 1.0) {
|
|
55
60
|
if (isTTY)
|
|
56
61
|
return rawCols;
|
|
57
62
|
const clamped = Math.min(Math.max(factor, 0.3), 1.0);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lumira",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.15.0",
|
|
4
4
|
"description": "Real-time statusline HUD for Claude Code and Qwen Code. Includes session analytics CLI, API latency overhead widget, 7d quota projection, auto-compact proximity warnings, themes, and powerline. Zero deps.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|