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.
@@ -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.13.0"
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.13.0",
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.13.0"
35
+ "version": "1.15.0"
36
36
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lumira",
3
- "version": "1.13.0",
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
- mkdirSync(dirname(settingsPath), { recursive: true });
267
- const tmp = `${settingsPath}.${process.pid}.${Date.now()}.lumira.tmp`;
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
- const uninstTmp = `${settingsPath}.${process.pid}.${Date.now()}.lumira.tmp`;
343
- try {
344
- const fd = openSync(uninstTmp, 'wx', 0o600);
345
- writeSync(fd, JSON.stringify(uninstSettings, null, 2) + '\n');
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')]) {
@@ -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
- // Directory
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
- right.push(hyperlink(`https://www.npmjs.com/package/@anthropic-ai/claude-code/v/${encodeURIComponent(input.version)}`, c.dim(`v${input.version}`)));
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
- return fitSegments(left, right, SEP, cols);
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.directory && input.cwd) {
73
- const dirName = basename(input.cwd) || input.cwd;
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(pathToFileURL(input.cwd).href, truncField(dirName, 30)),
76
- icon: icons.folder,
79
+ text: hyperlink(url, truncField(`${owner}/${name}`, 36)),
80
+ icon: icons.repo,
77
81
  bg: palette.dirBg,
78
82
  fg: palette.fg,
79
- priority: 60,
83
+ priority: 61,
80
84
  });
81
85
  }
82
- if (display.repo && input.repo) {
83
- const { owner, name, url } = input.repo;
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(url, truncField(`${owner}/${name}`, 36)),
89
- icon: icons.repo,
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: 58,
93
+ priority: 60,
93
94
  });
94
95
  }
95
96
  if (display.addedDirs && input.addedDirsCount != null && input.addedDirsCount > 0) {
@@ -15,7 +15,11 @@ export function displayWidth(str) {
15
15
  w += 1;
16
16
  continue;
17
17
  }
18
- if (cp >= 0x1F000 || (cp >= 0x2300 && cp <= 0x257F) || (cp >= 0x25A0 && cp <= 0x25FF) ||
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 safeCols = Math.max(1, cols - 4);
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 > safeCols)
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 <= safeCols) {
71
- const gap = Math.max(1, safeCols - leftW - rightW);
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] ?? ''), safeCols);
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.
@@ -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 still trust the resolved rawCols since proc-tree / COLUMNS / tput give the
50
- // real terminal width. The small 0.9 factor leaves 10% headroom for any chrome
51
- // the host renderer adds (separators, gutters) without aggressively starving
52
- // segments. Was 0.7 historically too conservative for CC, where the
53
- // statusline uses the full terminal width.
54
- export function getLayoutCols(rawCols, isTTY, factor = 0.9) {
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.13.0",
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",