pi-powerline-footer 0.2.11 → 0.2.13

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/CHANGELOG.md CHANGED
@@ -2,6 +2,44 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.2.13] - 2026-01-27
6
+
7
+ ### Added
8
+ - **Theme system** — Colors now integrate with pi's theme system instead of hardcoded values
9
+ - Each preset defines its own color scheme with semantic color names
10
+ - Optional `theme.json` file for user customization (power user feature)
11
+ - Colors can be theme names (`accent`, `primary`, `muted`) or hex values (`#ff5500`)
12
+ - Added `theme.example.json` documenting all available color options
13
+
14
+ ### Changed
15
+ - Segments now use pi's `Theme` object for color rendering
16
+ - Removed hardcoded ANSI color codes in favor of theme-based colors
17
+ - Presets include both layout AND color scheme for cohesive looks
18
+ - Simplified thinking level colors to use semantic `thinking` color (rainbow preserved for high/xhigh)
19
+
20
+ ## [0.2.12] - 2026-01-27
21
+
22
+ ### Added
23
+ - **Responsive segment layout** — Segments dynamically flow between top bar and secondary row based on terminal width
24
+ - When terminal is wide: all segments fit in top bar, secondary row hidden
25
+ - When terminal is narrow: overflow segments move to secondary row automatically
26
+
27
+ ### Changed
28
+ - **Default preset reordered** — New order: π → folder → model → think → git → context% → cache → cost
29
+ - Path now appears before model name for better visual hierarchy
30
+ - Thinking level now appears right after model name
31
+ - Added git, cache_read, and cost to primary row in default preset
32
+ - **Thinking label shortened** — `thinking:level` → `think:level` to save 3 characters
33
+
34
+ ### Fixed
35
+ - **Narrow terminal crash** — Welcome screen now gracefully skips rendering on terminals < 44 columns wide
36
+ - **Editor crash on very narrow terminals** — Falls back to original render when width < 10
37
+ - **Streaming footer crash** — Truncation now properly handles edge cases and won't render content that exceeds terminal width
38
+ - **Secondary widget crash** — Content width is now validated before rendering
39
+ - **Layout cache invalidation** — Cache now properly clears when preset changes or powerline is toggled off
40
+
41
+ ## [0.2.11] - 2026-01-26
42
+
5
43
  ### Changed
6
44
  - Added `pi` manifest to package.json for pi v0.50.0 package system compliance
7
45
  - Added `pi-package` keyword for npm discoverability
package/README.md CHANGED
@@ -75,3 +75,44 @@ Configure via preset options: `path: { mode: "full" }`
75
75
  ## Separators
76
76
 
77
77
  `powerline` · `powerline-thin` · `slash` · `pipe` · `dot` · `chevron` · `star` · `block` · `none` · `ascii`
78
+
79
+ ## Theming
80
+
81
+ Colors are configurable via pi's theme system. Each preset defines its own color scheme, and you can override individual colors with a `theme.json` file in the extension directory.
82
+
83
+ ### Default Colors
84
+
85
+ | Semantic | Theme Color | Description |
86
+ |----------|-------------|-------------|
87
+ | `pi` | `accent` | Pi icon |
88
+ | `model` | `primary` | Model name |
89
+ | `path` | `muted` | Directory path |
90
+ | `gitClean` | `success` | Git branch (clean) |
91
+ | `gitDirty` | `warning` | Git branch (dirty) |
92
+ | `thinking` | `muted` | Thinking level |
93
+ | `context` | `dim` | Context usage |
94
+ | `contextWarn` | `warning` | Context usage >70% |
95
+ | `contextError` | `error` | Context usage >90% |
96
+ | `cost` | `primary` | Cost display |
97
+ | `tokens` | `muted` | Token counts |
98
+
99
+ ### Custom Theme Override
100
+
101
+ Create `~/.pi/agent/extensions/powerline-footer/theme.json`:
102
+
103
+ ```json
104
+ {
105
+ "colors": {
106
+ "pi": "#ff5500",
107
+ "model": "accent",
108
+ "path": "#00afaf",
109
+ "gitClean": "success"
110
+ }
111
+ }
112
+ ```
113
+
114
+ Colors can be:
115
+ - **Theme color names**: `accent`, `primary`, `muted`, `dim`, `text`, `success`, `warning`, `error`, `borderMuted`
116
+ - **Hex colors**: `#ff5500`, `#d787af`
117
+
118
+ See `theme.example.json` for all available options.
package/index.ts CHANGED
@@ -1,16 +1,17 @@
1
- import type { ExtensionAPI, ReadonlyFooterDataProvider } from "@mariozechner/pi-coding-agent";
1
+ import type { ExtensionAPI, ReadonlyFooterDataProvider, Theme } from "@mariozechner/pi-coding-agent";
2
2
  import type { AssistantMessage } from "@mariozechner/pi-ai";
3
3
  import { visibleWidth } from "@mariozechner/pi-tui";
4
4
  import { readFileSync, existsSync } from "node:fs";
5
5
  import { join } from "node:path";
6
6
 
7
- import type { SegmentContext, StatusLinePreset } from "./types.js";
7
+ import type { ColorScheme, SegmentContext, StatusLinePreset, StatusLineSegmentId } from "./types.js";
8
8
  import { getPreset, PRESETS } from "./presets.js";
9
9
  import { getSeparator } from "./separators.js";
10
10
  import { renderSegment } from "./segments.js";
11
11
  import { getGitStatus, invalidateGitStatus, invalidateGitBranch } from "./git-status.js";
12
12
  import { ansi, getFgAnsiCode } from "./colors.js";
13
13
  import { WelcomeComponent, WelcomeHeader, discoverLoadedCounts, getRecentSessions } from "./welcome.js";
14
+ import { getDefaultColors } from "./theme.js";
14
15
 
15
16
  // ═══════════════════════════════════════════════════════════════════════════
16
17
  // Configuration
@@ -43,36 +44,122 @@ function isQuietStartup(): boolean {
43
44
  // Status Line Builder (for top border)
44
45
  // ═══════════════════════════════════════════════════════════════════════════
45
46
 
46
- /** Build just the status content (segments with separators, no borders) */
47
- function buildStatusContent(ctx: SegmentContext, presetDef: ReturnType<typeof getPreset>): string {
47
+ /** Render a single segment and return its content with width */
48
+ function renderSegmentWithWidth(
49
+ segId: StatusLineSegmentId,
50
+ ctx: SegmentContext
51
+ ): { content: string; width: number; visible: boolean } {
52
+ const rendered = renderSegment(segId, ctx);
53
+ if (!rendered.visible || !rendered.content) {
54
+ return { content: "", width: 0, visible: false };
55
+ }
56
+ return { content: rendered.content, width: visibleWidth(rendered.content), visible: true };
57
+ }
58
+
59
+ /** Build status content from a list of segment IDs */
60
+ function buildStatusContentFromSegments(
61
+ segmentIds: StatusLineSegmentId[],
62
+ ctx: SegmentContext,
63
+ presetDef: ReturnType<typeof getPreset>
64
+ ): string {
48
65
  const separatorDef = getSeparator(presetDef.separator);
49
66
  const sepAnsi = getFgAnsiCode("sep");
50
67
 
51
68
  // Collect visible segment contents
52
- const leftParts: string[] = [];
53
- for (const segId of presetDef.leftSegments) {
54
- const rendered = renderSegment(segId, ctx);
55
- if (rendered.visible && rendered.content) {
56
- leftParts.push(rendered.content);
57
- }
58
- }
59
-
60
- const rightParts: string[] = [];
61
- for (const segId of presetDef.rightSegments) {
69
+ const parts: string[] = [];
70
+ for (const segId of segmentIds) {
62
71
  const rendered = renderSegment(segId, ctx);
63
72
  if (rendered.visible && rendered.content) {
64
- rightParts.push(rendered.content);
73
+ parts.push(rendered.content);
65
74
  }
66
75
  }
67
76
 
68
- if (leftParts.length === 0 && rightParts.length === 0) {
77
+ if (parts.length === 0) {
69
78
  return "";
70
79
  }
71
80
 
72
81
  // Build content with powerline separators (no background)
73
82
  const sep = separatorDef.left;
74
- const allParts = [...leftParts, ...rightParts];
75
- return " " + allParts.join(` ${sepAnsi}${sep}${ansi.reset} `) + ansi.reset + " ";
83
+ return " " + parts.join(` ${sepAnsi}${sep}${ansi.reset} `) + ansi.reset + " ";
84
+ }
85
+
86
+ /** Build content string from pre-rendered parts */
87
+ function buildContentFromParts(
88
+ parts: string[],
89
+ presetDef: ReturnType<typeof getPreset>
90
+ ): string {
91
+ if (parts.length === 0) return "";
92
+ const separatorDef = getSeparator(presetDef.separator);
93
+ const sepAnsi = getFgAnsiCode("sep");
94
+ const sep = separatorDef.left;
95
+ return " " + parts.join(` ${sepAnsi}${sep}${ansi.reset} `) + ansi.reset + " ";
96
+ }
97
+
98
+ /**
99
+ * Responsive segment layout - fits segments into top bar, overflows to secondary row.
100
+ * When terminal is wide enough, secondary segments move up to top bar.
101
+ * When narrow, top bar segments overflow down to secondary row.
102
+ */
103
+ function computeResponsiveLayout(
104
+ ctx: SegmentContext,
105
+ presetDef: ReturnType<typeof getPreset>,
106
+ availableWidth: number
107
+ ): { topContent: string; secondaryContent: string } {
108
+ const separatorDef = getSeparator(presetDef.separator);
109
+ const sepWidth = visibleWidth(separatorDef.left) + 2; // separator + spaces around it
110
+
111
+ // Get all segments: primary first, then secondary
112
+ const primaryIds = [...presetDef.leftSegments, ...presetDef.rightSegments];
113
+ const secondaryIds = presetDef.secondarySegments ?? [];
114
+ const allSegmentIds = [...primaryIds, ...secondaryIds];
115
+
116
+ // Render all segments and get their widths
117
+ const renderedSegments: { id: StatusLineSegmentId; content: string; width: number }[] = [];
118
+ for (const segId of allSegmentIds) {
119
+ const { content, width, visible } = renderSegmentWithWidth(segId, ctx);
120
+ if (visible) {
121
+ renderedSegments.push({ id: segId, content, width });
122
+ }
123
+ }
124
+
125
+ if (renderedSegments.length === 0) {
126
+ return { topContent: "", secondaryContent: "" };
127
+ }
128
+
129
+ // Calculate how many segments fit in top bar
130
+ // Account for: leading space (1) + trailing space (1) = 2 chars overhead
131
+ const baseOverhead = 2;
132
+ let currentWidth = baseOverhead;
133
+ let topSegments: string[] = [];
134
+ let secondarySegments: string[] = [];
135
+ let overflow = false;
136
+
137
+ for (let i = 0; i < renderedSegments.length; i++) {
138
+ const seg = renderedSegments[i];
139
+ // Width needed: segment width + separator (except for first segment)
140
+ const neededWidth = seg.width + (topSegments.length > 0 ? sepWidth : 0);
141
+
142
+ if (!overflow && currentWidth + neededWidth <= availableWidth) {
143
+ // Fits in top bar
144
+ topSegments.push(seg.content);
145
+ currentWidth += neededWidth;
146
+ } else {
147
+ // Overflow to secondary row
148
+ overflow = true;
149
+ secondarySegments.push(seg.content);
150
+ }
151
+ }
152
+
153
+ return {
154
+ topContent: buildContentFromParts(topSegments, presetDef),
155
+ secondaryContent: buildContentFromParts(secondarySegments, presetDef),
156
+ };
157
+ }
158
+
159
+ /** Build primary status content (for top border) - legacy, used during streaming */
160
+ function buildStatusContent(ctx: SegmentContext, presetDef: ReturnType<typeof getPreset>): string {
161
+ const allSegments = [...presetDef.leftSegments, ...presetDef.rightSegments];
162
+ return buildStatusContentFromSegments(allSegments, ctx, presetDef);
76
163
  }
77
164
 
78
165
  // ═══════════════════════════════════════════════════════════════════════════
@@ -90,6 +177,11 @@ export default function powerlineFooter(pi: ExtensionAPI) {
90
177
  let dismissWelcomeOverlay: (() => void) | null = null; // Callback to dismiss welcome overlay
91
178
  let welcomeHeaderActive = false; // Track if welcome header should be cleared on first input
92
179
  let welcomeOverlayShouldDismiss = false; // Track early dismissal request (before overlay setup completes)
180
+
181
+ // Cache for responsive layout (shared between editor and widget for consistency)
182
+ let lastLayoutWidth = 0;
183
+ let lastLayoutResult: { topContent: string; secondaryContent: string } | null = null;
184
+ let lastLayoutTimestamp = 0;
93
185
 
94
186
  // Track session start
95
187
  pi.on("session_start", async (_event, ctx) => {
@@ -208,8 +300,11 @@ export default function powerlineFooter(pi: ExtensionAPI) {
208
300
  ctx.ui.setEditorComponent(undefined);
209
301
  ctx.ui.setFooter(undefined);
210
302
  ctx.ui.setHeader(undefined);
303
+ ctx.ui.setWidget("powerline-secondary", undefined);
211
304
  footerDataRef = null;
212
305
  tuiRef = null;
306
+ // Clear layout cache
307
+ lastLayoutResult = null;
213
308
  ctx.ui.notify("Defaults restored", "info");
214
309
  }
215
310
  return;
@@ -219,6 +314,8 @@ export default function powerlineFooter(pi: ExtensionAPI) {
219
314
  const preset = args.trim().toLowerCase() as StatusLinePreset;
220
315
  if (preset in PRESETS) {
221
316
  config.preset = preset;
317
+ // Invalidate layout cache since preset changed
318
+ lastLayoutResult = null;
222
319
  if (enabled) {
223
320
  setupCustomEditor(ctx);
224
321
  }
@@ -232,8 +329,9 @@ export default function powerlineFooter(pi: ExtensionAPI) {
232
329
  },
233
330
  });
234
331
 
235
- function buildSegmentContext(ctx: any, width: number): SegmentContext {
332
+ function buildSegmentContext(ctx: any, width: number, theme: Theme): SegmentContext {
236
333
  const presetDef = getPreset(config.preset);
334
+ const colors: ColorScheme = presetDef.colors ?? getDefaultColors();
237
335
 
238
336
  // Build usage stats and get thinking level from session
239
337
  let input = 0, output = 0, cacheRead = 0, cacheWrite = 0, cost = 0;
@@ -291,9 +389,34 @@ export default function powerlineFooter(pi: ExtensionAPI) {
291
389
  extensionStatuses: footerDataRef?.getExtensionStatuses() ?? new Map(),
292
390
  options: presetDef.segmentOptions ?? {},
293
391
  width,
392
+ theme,
393
+ colors,
294
394
  };
295
395
  }
296
396
 
397
+ /**
398
+ * Get cached responsive layout or compute fresh one.
399
+ * Layout is cached per render cycle (same width = same layout).
400
+ */
401
+ function getResponsiveLayout(width: number, theme: Theme): { topContent: string; secondaryContent: string } {
402
+ const now = Date.now();
403
+ // Cache is valid if same width and within 50ms (same render cycle)
404
+ if (lastLayoutResult && lastLayoutWidth === width && now - lastLayoutTimestamp < 50) {
405
+ return lastLayoutResult;
406
+ }
407
+
408
+ const presetDef = getPreset(config.preset);
409
+ const segmentCtx = buildSegmentContext(currentCtx, width, theme);
410
+ // Available width for top bar content (minus box corners: ╭─ and ─╮ = 4 chars)
411
+ const topBarAvailable = width - 4;
412
+
413
+ lastLayoutWidth = width;
414
+ lastLayoutResult = computeResponsiveLayout(segmentCtx, presetDef, topBarAvailable);
415
+ lastLayoutTimestamp = now;
416
+
417
+ return lastLayoutResult;
418
+ }
419
+
297
420
  function setupCustomEditor(ctx: any) {
298
421
  // Import CustomEditor dynamically and create wrapper
299
422
  import("@mariozechner/pi-coding-agent").then(({ CustomEditor }) => {
@@ -318,6 +441,12 @@ export default function powerlineFooter(pi: ExtensionAPI) {
318
441
  // ╰─ ─╯
319
442
  // + autocomplete items (if showing)
320
443
  editor.render = (width: number): string[] => {
444
+ // Minimum width for box layout: borders (4) + minimal content (1) = 5
445
+ // Fall back to original render on extremely narrow terminals
446
+ if (width < 10) {
447
+ return originalRender(width);
448
+ }
449
+
321
450
  const bc = (s: string) => `${getFgAnsiCode("border")}${s}${ansi.reset}`;
322
451
 
323
452
  // Box drawing chars
@@ -347,43 +476,14 @@ export default function powerlineFooter(pi: ExtensionAPI) {
347
476
  const result: string[] = [];
348
477
 
349
478
  // Top border: ╭─ status ────────────╮
350
- const presetDef = getPreset(config.preset);
351
- const segmentCtx = buildSegmentContext(currentCtx, width);
352
- const statusContent = buildStatusContent(segmentCtx, presetDef);
479
+ // Use responsive layout - overflow goes to secondary row
480
+ const layout = getResponsiveLayout(width, theme);
481
+ const statusContent = layout.topContent;
353
482
  const statusWidth = visibleWidth(statusContent);
354
483
  const topFillWidth = width - 4; // Reserve 4 for corners (╭─ and ─╮)
355
484
 
356
- if (statusWidth <= topFillWidth) {
357
- const fillWidth = topFillWidth - statusWidth;
358
- result.push(topLeft + statusContent + bc("─".repeat(fillWidth)) + topRight);
359
- } else {
360
- // Status too wide - truncate by removing segments from the end
361
- // Build progressively shorter content until it fits
362
- let truncatedContent = "";
363
-
364
- for (let numSegments = presetDef.leftSegments.length - 1; numSegments >= 1; numSegments--) {
365
- const limitedPreset = {
366
- ...presetDef,
367
- leftSegments: presetDef.leftSegments.slice(0, numSegments),
368
- rightSegments: [],
369
- };
370
- truncatedContent = buildStatusContent(segmentCtx, limitedPreset);
371
- const truncWidth = visibleWidth(truncatedContent);
372
- if (truncWidth <= topFillWidth - 1) { // -1 for ellipsis
373
- truncatedContent += "…";
374
- break;
375
- }
376
- }
377
-
378
- const truncWidth = visibleWidth(truncatedContent);
379
- if (truncWidth <= topFillWidth) {
380
- const fillWidth = topFillWidth - truncWidth;
381
- result.push(topLeft + truncatedContent + bc("─".repeat(fillWidth)) + topRight);
382
- } else {
383
- // Still too wide, show minimal
384
- result.push(topLeft + bc("─".repeat(Math.max(0, topFillWidth))) + topRight);
385
- }
386
- }
485
+ const fillWidth = Math.max(0, topFillWidth - statusWidth);
486
+ result.push(topLeft + statusContent + bc("─".repeat(fillWidth)) + topRight);
387
487
 
388
488
  // Content lines (between top border at 0 and bottom border)
389
489
  for (let i = 1; i < bottomBorderIndex; i++) {
@@ -418,8 +518,8 @@ export default function powerlineFooter(pi: ExtensionAPI) {
418
518
  return editor;
419
519
  });
420
520
 
421
- // Also set up footer data provider access via a minimal footer
422
- ctx.ui.setFooter((tui: any, _theme: any, footerData: ReadonlyFooterDataProvider) => {
521
+ // Set up footer data provider access via a minimal footer
522
+ ctx.ui.setFooter((tui: any, theme: Theme, footerData: ReadonlyFooterDataProvider) => {
423
523
  footerDataRef = footerData;
424
524
  tuiRef = tui; // Store TUI reference for re-renders on git branch changes
425
525
  const unsub = footerData.onBranchChange(() => tui.requestRender());
@@ -430,43 +530,76 @@ export default function powerlineFooter(pi: ExtensionAPI) {
430
530
  // No cache to clear - render is always fresh
431
531
  },
432
532
  render(width: number): string[] {
433
- // Only show status in footer during streaming (editor hidden)
434
- // When editor is visible, status shows in editor top border instead
435
- if (!isStreaming || !currentCtx) return [];
533
+ if (!currentCtx) return [];
436
534
 
437
535
  const presetDef = getPreset(config.preset);
438
- const segmentCtx = buildSegmentContext(currentCtx, width);
439
- const statusContent = buildStatusContent(segmentCtx, presetDef);
440
-
441
- if (!statusContent) return [];
536
+ const segmentCtx = buildSegmentContext(currentCtx, width, theme);
537
+ const lines: string[] = [];
442
538
 
443
- // Single line with status content, padded/truncated to width
444
- const statusWidth = visibleWidth(statusContent);
445
- if (statusWidth <= width) {
446
- return [statusContent + " ".repeat(width - statusWidth)];
447
- } else {
448
- // Truncate by removing segments (same logic as editor)
449
- let truncatedContent = "";
450
-
451
- for (let numSegments = presetDef.leftSegments.length - 1; numSegments >= 1; numSegments--) {
452
- const limitedPreset = {
453
- ...presetDef,
454
- leftSegments: presetDef.leftSegments.slice(0, numSegments),
455
- rightSegments: [],
456
- };
457
- truncatedContent = buildStatusContent(segmentCtx, limitedPreset);
458
- const truncWidth = visibleWidth(truncatedContent);
459
- if (truncWidth <= width - 1) {
460
- truncatedContent += "…";
461
- break;
539
+ // During streaming, show primary status in footer (editor hidden)
540
+ if (isStreaming) {
541
+ const statusContent = buildStatusContent(segmentCtx, presetDef);
542
+ if (statusContent) {
543
+ const statusWidth = visibleWidth(statusContent);
544
+ if (statusWidth <= width) {
545
+ lines.push(statusContent + " ".repeat(width - statusWidth));
546
+ } else {
547
+ // Truncate by removing segments until it fits
548
+ // Start from leftSegments.length to try "just leftSegments" when rightSegments exists
549
+ let truncatedContent = "";
550
+ let foundFit = false;
551
+ for (let numSegments = presetDef.leftSegments.length; numSegments >= 1; numSegments--) {
552
+ const limitedPreset = {
553
+ ...presetDef,
554
+ leftSegments: presetDef.leftSegments.slice(0, numSegments),
555
+ rightSegments: [],
556
+ };
557
+ truncatedContent = buildStatusContent(segmentCtx, limitedPreset);
558
+ const truncWidth = visibleWidth(truncatedContent);
559
+ if (truncWidth <= width - 1) {
560
+ truncatedContent += "…";
561
+ foundFit = true;
562
+ break;
563
+ }
564
+ }
565
+ // Only push if we found a fit, otherwise skip (don't crash on very narrow terminals)
566
+ if (foundFit) {
567
+ lines.push(truncatedContent);
568
+ }
462
569
  }
463
570
  }
464
-
465
- return [truncatedContent];
466
571
  }
572
+
573
+ return lines;
467
574
  },
468
575
  };
469
576
  });
577
+
578
+ // Set up secondary row as a widget below editor (above sub bar)
579
+ // Shows overflow segments when top bar is too narrow
580
+ ctx.ui.setWidget("powerline-secondary", (tui: any, theme: Theme) => {
581
+ return {
582
+ dispose() {},
583
+ invalidate() {},
584
+ render(width: number): string[] {
585
+ if (!currentCtx) return [];
586
+
587
+ // Use responsive layout - secondary row shows overflow from top bar
588
+ const layout = getResponsiveLayout(width, theme);
589
+
590
+ // Only show secondary row if there's overflow content that fits
591
+ if (layout.secondaryContent) {
592
+ const contentWidth = visibleWidth(layout.secondaryContent);
593
+ // Don't render if content exceeds terminal width (graceful degradation)
594
+ if (contentWidth <= width) {
595
+ return [layout.secondaryContent];
596
+ }
597
+ }
598
+
599
+ return [];
600
+ },
601
+ };
602
+ }, { placement: "belowEditor" });
470
603
  });
471
604
  }
472
605
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-powerline-footer",
3
- "version": "0.2.11",
3
+ "version": "0.2.13",
4
4
  "description": "Powerline-style status bar extension for pi coding agent",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,6 +9,7 @@
9
9
  "files": [
10
10
  "*.ts",
11
11
  "*.md",
12
+ "*.json",
12
13
  "cli.js"
13
14
  ],
14
15
  "keywords": [
package/presets.ts CHANGED
@@ -1,10 +1,36 @@
1
- import type { PresetDef, StatusLinePreset } from "./types.js";
1
+ import type { ColorScheme, PresetDef, StatusLinePreset } from "./types.js";
2
+ import { getDefaultColors } from "./theme.js";
3
+
4
+ // Get base colors from theme.ts (single source of truth)
5
+ const DEFAULT_COLORS: ColorScheme = getDefaultColors();
6
+
7
+ // Minimal - more muted, less colorful
8
+ const MINIMAL_COLORS: ColorScheme = {
9
+ ...DEFAULT_COLORS,
10
+ pi: "dim",
11
+ model: "text",
12
+ path: "text",
13
+ git: "dim",
14
+ gitClean: "dim",
15
+ };
16
+
17
+ // Nerd - vibrant colors
18
+ const NERD_COLORS: ColorScheme = {
19
+ ...DEFAULT_COLORS,
20
+ pi: "accent",
21
+ model: "accent",
22
+ path: "success",
23
+ tokens: "primary",
24
+ cost: "warning",
25
+ };
2
26
 
3
27
  export const PRESETS: Record<StatusLinePreset, PresetDef> = {
4
28
  default: {
5
- leftSegments: ["pi", "model", "thinking", "path", "git", "context_pct", "token_total", "cost", "extension_statuses"],
29
+ leftSegments: ["pi", "path", "model", "thinking", "git", "context_pct", "cache_read", "cost"],
6
30
  rightSegments: [],
31
+ secondarySegments: ["extension_statuses"],
7
32
  separator: "powerline-thin",
33
+ colors: DEFAULT_COLORS,
8
34
  segmentOptions: {
9
35
  model: { showThinkingLevel: false },
10
36
  path: { mode: "basename" },
@@ -16,6 +42,7 @@ export const PRESETS: Record<StatusLinePreset, PresetDef> = {
16
42
  leftSegments: ["path", "git"],
17
43
  rightSegments: ["context_pct"],
18
44
  separator: "slash",
45
+ colors: MINIMAL_COLORS,
19
46
  segmentOptions: {
20
47
  path: { mode: "basename" },
21
48
  git: { showBranch: true, showStaged: false, showUnstaged: false, showUntracked: false },
@@ -26,6 +53,7 @@ export const PRESETS: Record<StatusLinePreset, PresetDef> = {
26
53
  leftSegments: ["model", "git"],
27
54
  rightSegments: ["cost", "context_pct"],
28
55
  separator: "powerline-thin",
56
+ colors: DEFAULT_COLORS,
29
57
  segmentOptions: {
30
58
  model: { showThinkingLevel: false },
31
59
  git: { showBranch: true, showStaged: true, showUnstaged: true, showUntracked: false },
@@ -36,6 +64,7 @@ export const PRESETS: Record<StatusLinePreset, PresetDef> = {
36
64
  leftSegments: ["pi", "hostname", "model", "thinking", "path", "git", "subagents"],
37
65
  rightSegments: ["token_in", "token_out", "cache_read", "cost", "context_pct", "time_spent", "time", "extension_statuses"],
38
66
  separator: "powerline",
67
+ colors: DEFAULT_COLORS,
39
68
  segmentOptions: {
40
69
  model: { showThinkingLevel: false },
41
70
  path: { mode: "abbreviated", maxLength: 50 },
@@ -48,6 +77,7 @@ export const PRESETS: Record<StatusLinePreset, PresetDef> = {
48
77
  leftSegments: ["pi", "hostname", "model", "thinking", "path", "git", "session", "subagents"],
49
78
  rightSegments: ["token_in", "token_out", "cache_read", "cache_write", "cost", "context_pct", "context_total", "time_spent", "time", "extension_statuses"],
50
79
  separator: "powerline",
80
+ colors: NERD_COLORS,
51
81
  segmentOptions: {
52
82
  model: { showThinkingLevel: false },
53
83
  path: { mode: "abbreviated", maxLength: 60 },
@@ -60,6 +90,7 @@ export const PRESETS: Record<StatusLinePreset, PresetDef> = {
60
90
  leftSegments: ["model", "path", "git"],
61
91
  rightSegments: ["token_total", "cost", "context_pct"],
62
92
  separator: "ascii",
93
+ colors: MINIMAL_COLORS,
63
94
  segmentOptions: {
64
95
  model: { showThinkingLevel: true },
65
96
  path: { mode: "abbreviated", maxLength: 40 },
@@ -71,6 +102,7 @@ export const PRESETS: Record<StatusLinePreset, PresetDef> = {
71
102
  leftSegments: ["model", "path", "git"],
72
103
  rightSegments: ["token_total", "cost", "context_pct"],
73
104
  separator: "powerline-thin",
105
+ colors: DEFAULT_COLORS,
74
106
  segmentOptions: {},
75
107
  },
76
108
  };
package/segments.ts CHANGED
@@ -1,9 +1,14 @@
1
1
  import { hostname as osHostname } from "node:os";
2
2
  import { basename } from "node:path";
3
- import type { RenderedSegment, SegmentContext, StatusLineSegment, StatusLineSegmentId } from "./types.js";
4
- import { fgOnly, rainbow } from "./colors.js";
3
+ import type { RenderedSegment, SegmentContext, SemanticColor, StatusLineSegment, StatusLineSegmentId } from "./types.js";
4
+ import { fg, rainbow, applyColor } from "./theme.js";
5
5
  import { getIcons, SEP_DOT, getThinkingText } from "./icons.js";
6
6
 
7
+ // Helper to apply semantic color from context
8
+ function color(ctx: SegmentContext, semantic: SemanticColor, text: string): string {
9
+ return fg(ctx.theme, semantic, text, ctx.colors);
10
+ }
11
+
7
12
  // ═══════════════════════════════════════════════════════════════════════════
8
13
  // Helpers
9
14
  // ═══════════════════════════════════════════════════════════════════════════
@@ -36,11 +41,11 @@ function formatDuration(ms: number): string {
36
41
 
37
42
  const piSegment: StatusLineSegment = {
38
43
  id: "pi",
39
- render(_ctx) {
44
+ render(ctx) {
40
45
  const icons = getIcons();
41
46
  if (!icons.pi) return { content: "", visible: false };
42
47
  const content = `${icons.pi} `;
43
- return { content: fgOnly("accent", content), visible: true };
48
+ return { content: color(ctx, "pi", content), visible: true };
44
49
  },
45
50
  };
46
51
 
@@ -69,7 +74,7 @@ const modelSegment: StatusLineSegment = {
69
74
  }
70
75
  }
71
76
 
72
- return { content: fgOnly("model", content), visible: true };
77
+ return { content: color(ctx, "model", content), visible: true };
73
78
  },
74
79
  };
75
80
 
@@ -107,7 +112,7 @@ const pathSegment: StatusLineSegment = {
107
112
  }
108
113
 
109
114
  const content = withIcon(icons.folder, pwd);
110
- return { content: fgOnly("path", content), visible: true };
115
+ return { content: color(ctx, "path", content), visible: true };
111
116
  },
112
117
  };
113
118
 
@@ -125,29 +130,32 @@ const gitSegment: StatusLineSegment = {
125
130
 
126
131
  const isDirty = gitStatus && (gitStatus.staged > 0 || gitStatus.unstaged > 0 || gitStatus.untracked > 0);
127
132
  const showBranch = opts.showBranch !== false;
133
+ const branchColor: SemanticColor = isDirty ? "gitDirty" : "gitClean";
128
134
 
129
- // Build content
135
+ // Build content - color branch separately from indicators
130
136
  let content = "";
131
137
  if (showBranch && branch) {
132
- content = withIcon(icons.branch, branch);
138
+ // Color just the branch name (icon + branch text)
139
+ content = color(ctx, branchColor, withIcon(icons.branch, branch));
133
140
  }
134
141
 
135
- // Add status indicators
142
+ // Add status indicators (each with their own color, not wrapped)
136
143
  if (gitStatus) {
137
144
  const indicators: string[] = [];
138
145
  if (opts.showUnstaged !== false && gitStatus.unstaged > 0) {
139
- indicators.push(fgOnly("unstaged", `*${gitStatus.unstaged}`));
146
+ indicators.push(applyColor(ctx.theme, "warning", `*${gitStatus.unstaged}`));
140
147
  }
141
148
  if (opts.showStaged !== false && gitStatus.staged > 0) {
142
- indicators.push(fgOnly("staged", `+${gitStatus.staged}`));
149
+ indicators.push(applyColor(ctx.theme, "success", `+${gitStatus.staged}`));
143
150
  }
144
151
  if (opts.showUntracked !== false && gitStatus.untracked > 0) {
145
- indicators.push(fgOnly("untracked", `?${gitStatus.untracked}`));
152
+ indicators.push(applyColor(ctx.theme, "muted", `?${gitStatus.untracked}`));
146
153
  }
147
154
  if (indicators.length > 0) {
148
155
  const indicatorText = indicators.join(" ");
149
156
  if (!content && showBranch === false) {
150
- content = withIcon(icons.git, indicatorText);
157
+ // No branch shown, color the git icon with branch color
158
+ content = color(ctx, branchColor, icons.git ? `${icons.git} ` : "") + indicatorText;
151
159
  } else {
152
160
  content += content ? ` ${indicatorText}` : indicatorText;
153
161
  }
@@ -156,9 +164,7 @@ const gitSegment: StatusLineSegment = {
156
164
 
157
165
  if (!content) return { content: "", visible: false };
158
166
 
159
- // Wrap entire content in branch color
160
- const colorName = isDirty ? "gitDirty" : "gitClean";
161
- return { content: fgOnly(colorName, content), visible: true };
167
+ return { content, visible: true };
162
168
  },
163
169
  };
164
170
 
@@ -177,23 +183,15 @@ const thinkingSegment: StatusLineSegment = {
177
183
  xhigh: "xhigh",
178
184
  };
179
185
  const label = levelText[level] || level;
180
- const content = `thinking:${label}`;
186
+ const content = `think:${label}`;
181
187
 
182
188
  // Use rainbow effect for high/xhigh (like Claude Code ultrathink)
183
189
  if (level === "high" || level === "xhigh") {
184
190
  return { content: rainbow(content), visible: true };
185
191
  }
186
192
 
187
- // Use dedicated thinking colors (gradient: gray → purple → blue → teal)
188
- const colorMap: Record<string, "thinkingOff" | "thinkingMinimal" | "thinkingLow" | "thinkingMedium"> = {
189
- off: "thinkingOff",
190
- minimal: "thinkingMinimal",
191
- low: "thinkingLow",
192
- medium: "thinkingMedium",
193
- };
194
- const color = colorMap[level] || "thinkingOff";
195
-
196
- return { content: fgOnly(color, content), visible: true };
193
+ // Use thinking color for lower levels
194
+ return { content: color(ctx, "thinking", content), visible: true };
197
195
  },
198
196
  };
199
197
 
@@ -215,7 +213,7 @@ const tokenInSegment: StatusLineSegment = {
215
213
  if (!input) return { content: "", visible: false };
216
214
 
217
215
  const content = withIcon(icons.input, formatTokens(input));
218
- return { content: fgOnly("spend", content), visible: true };
216
+ return { content: color(ctx, "tokens", content), visible: true };
219
217
  },
220
218
  };
221
219
 
@@ -227,7 +225,7 @@ const tokenOutSegment: StatusLineSegment = {
227
225
  if (!output) return { content: "", visible: false };
228
226
 
229
227
  const content = withIcon(icons.output, formatTokens(output));
230
- return { content: fgOnly("output", content), visible: true };
228
+ return { content: color(ctx, "tokens", content), visible: true };
231
229
  },
232
230
  };
233
231
 
@@ -240,7 +238,7 @@ const tokenTotalSegment: StatusLineSegment = {
240
238
  if (!total) return { content: "", visible: false };
241
239
 
242
240
  const content = withIcon(icons.tokens, formatTokens(total));
243
- return { content: fgOnly("spend", content), visible: true };
241
+ return { content: color(ctx, "tokens", content), visible: true };
244
242
  },
245
243
  };
246
244
 
@@ -255,7 +253,7 @@ const costSegment: StatusLineSegment = {
255
253
  }
256
254
 
257
255
  const costDisplay = usingSubscription ? "(sub)" : `$${cost.toFixed(2)}`;
258
- return { content: fgOnly("cost", costDisplay), visible: true };
256
+ return { content: color(ctx, "cost", costDisplay), visible: true };
259
257
  },
260
258
  };
261
259
 
@@ -269,14 +267,14 @@ const contextPctSegment: StatusLineSegment = {
269
267
  const autoIcon = ctx.autoCompactEnabled && icons.auto ? ` ${icons.auto}` : "";
270
268
  const text = `${pct.toFixed(1)}%/${formatTokens(window)}${autoIcon}`;
271
269
 
272
- // Icon outside color, text inside
270
+ // Icon outside color, text inside - use semantic colors for thresholds
273
271
  let content: string;
274
272
  if (pct > 90) {
275
- content = withIcon(icons.context, fgOnly("error", text));
273
+ content = withIcon(icons.context, color(ctx, "contextError", text));
276
274
  } else if (pct > 70) {
277
- content = withIcon(icons.context, fgOnly("warning", text));
275
+ content = withIcon(icons.context, color(ctx, "contextWarn", text));
278
276
  } else {
279
- content = withIcon(icons.context, fgOnly("context", text));
277
+ content = withIcon(icons.context, color(ctx, "context", text));
280
278
  }
281
279
 
282
280
  return { content, visible: true };
@@ -291,7 +289,7 @@ const contextTotalSegment: StatusLineSegment = {
291
289
  if (!window) return { content: "", visible: false };
292
290
 
293
291
  return {
294
- content: fgOnly("context", withIcon(icons.context, formatTokens(window))),
292
+ content: color(ctx, "context", withIcon(icons.context, formatTokens(window))),
295
293
  visible: true,
296
294
  };
297
295
  },
@@ -367,7 +365,7 @@ const cacheReadSegment: StatusLineSegment = {
367
365
  // Space-separated parts
368
366
  const parts = [icons.cache, icons.input, formatTokens(cacheRead)].filter(Boolean);
369
367
  const content = parts.join(" ");
370
- return { content: fgOnly("spend", content), visible: true };
368
+ return { content: color(ctx, "tokens", content), visible: true };
371
369
  },
372
370
  };
373
371
 
@@ -381,7 +379,7 @@ const cacheWriteSegment: StatusLineSegment = {
381
379
  // Space-separated parts
382
380
  const parts = [icons.cache, icons.output, formatTokens(cacheWrite)].filter(Boolean);
383
381
  const content = parts.join(" ");
384
- return { content: fgOnly("output", content), visible: true };
382
+ return { content: color(ctx, "tokens", content), visible: true };
385
383
  },
386
384
  };
387
385
 
@@ -0,0 +1,19 @@
1
+ {
2
+ "colors": {
3
+ "pi": "accent",
4
+ "model": "primary",
5
+ "path": "muted",
6
+ "git": "success",
7
+ "gitDirty": "warning",
8
+ "gitClean": "success",
9
+ "thinking": "muted",
10
+ "thinkingHigh": "accent",
11
+ "context": "dim",
12
+ "contextWarn": "warning",
13
+ "contextError": "error",
14
+ "cost": "primary",
15
+ "tokens": "muted",
16
+ "separator": "dim",
17
+ "border": "borderMuted"
18
+ }
19
+ }
package/theme.ts ADDED
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Theme system for powerline-footer
3
+ *
4
+ * Colors are resolved in order:
5
+ * 1. User overrides from theme.json (if exists)
6
+ * 2. Preset colors
7
+ * 3. Default colors
8
+ */
9
+
10
+ import type { Theme, ThemeColor } from "@mariozechner/pi-coding-agent";
11
+ import { existsSync, readFileSync } from "node:fs";
12
+ import { join, dirname } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+ import type { ColorScheme, ColorValue, SemanticColor } from "./types.js";
15
+
16
+ // Default color scheme (uses pi theme colors)
17
+ const DEFAULT_COLORS: Required<ColorScheme> = {
18
+ pi: "accent",
19
+ model: "primary",
20
+ path: "muted",
21
+ git: "success",
22
+ gitDirty: "warning",
23
+ gitClean: "success",
24
+ thinking: "muted",
25
+ thinkingHigh: "accent",
26
+ context: "dim",
27
+ contextWarn: "warning",
28
+ contextError: "error",
29
+ cost: "primary",
30
+ tokens: "muted",
31
+ separator: "dim",
32
+ border: "borderMuted",
33
+ };
34
+
35
+ // Rainbow colors for high thinking levels
36
+ const RAINBOW_COLORS = [
37
+ "#b281d6", "#d787af", "#febc38", "#e4c00f",
38
+ "#89d281", "#00afaf", "#178fb9", "#b281d6",
39
+ ];
40
+
41
+ // Cache for user theme overrides
42
+ let userThemeCache: ColorScheme | null = null;
43
+ let userThemeCacheTime = 0;
44
+ const CACHE_TTL = 5000; // 5 seconds
45
+
46
+ /**
47
+ * Get the path to the theme.json file
48
+ */
49
+ function getThemePath(): string {
50
+ const extDir = dirname(fileURLToPath(import.meta.url));
51
+ return join(extDir, "theme.json");
52
+ }
53
+
54
+ /**
55
+ * Load user theme overrides from theme.json
56
+ */
57
+ function loadUserTheme(): ColorScheme {
58
+ const now = Date.now();
59
+ if (userThemeCache && now - userThemeCacheTime < CACHE_TTL) {
60
+ return userThemeCache;
61
+ }
62
+
63
+ const themePath = getThemePath();
64
+ try {
65
+ if (existsSync(themePath)) {
66
+ const content = readFileSync(themePath, "utf-8");
67
+ const parsed = JSON.parse(content);
68
+ userThemeCache = parsed.colors ?? {};
69
+ userThemeCacheTime = now;
70
+ return userThemeCache;
71
+ }
72
+ } catch {
73
+ // Ignore errors, use defaults
74
+ }
75
+
76
+ userThemeCache = {};
77
+ userThemeCacheTime = now;
78
+ return userThemeCache;
79
+ }
80
+
81
+ /**
82
+ * Resolve a semantic color to an actual color value
83
+ */
84
+ export function resolveColor(
85
+ semantic: SemanticColor,
86
+ presetColors?: ColorScheme
87
+ ): ColorValue {
88
+ const userTheme = loadUserTheme();
89
+
90
+ // Priority: user overrides > preset colors > defaults
91
+ return userTheme[semantic]
92
+ ?? presetColors?.[semantic]
93
+ ?? DEFAULT_COLORS[semantic];
94
+ }
95
+
96
+ /**
97
+ * Check if a color value is a hex color
98
+ */
99
+ function isHexColor(color: ColorValue): color is `#${string}` {
100
+ return typeof color === "string" && color.startsWith("#");
101
+ }
102
+
103
+ /**
104
+ * Convert hex color to ANSI escape code
105
+ */
106
+ function hexToAnsi(hex: string): string {
107
+ const h = hex.replace("#", "");
108
+ const r = parseInt(h.slice(0, 2), 16);
109
+ const g = parseInt(h.slice(2, 4), 16);
110
+ const b = parseInt(h.slice(4, 6), 16);
111
+ return `\x1b[38;2;${r};${g};${b}m`;
112
+ }
113
+
114
+ /**
115
+ * Apply a color to text using the pi theme or custom hex
116
+ */
117
+ export function applyColor(
118
+ theme: Theme,
119
+ color: ColorValue,
120
+ text: string
121
+ ): string {
122
+ if (isHexColor(color)) {
123
+ return `${hexToAnsi(color)}${text}\x1b[0m`;
124
+ }
125
+ return theme.fg(color as ThemeColor, text);
126
+ }
127
+
128
+ /**
129
+ * Apply a semantic color to text
130
+ */
131
+ export function fg(
132
+ theme: Theme,
133
+ semantic: SemanticColor,
134
+ text: string,
135
+ presetColors?: ColorScheme
136
+ ): string {
137
+ const color = resolveColor(semantic, presetColors);
138
+ return applyColor(theme, color, text);
139
+ }
140
+
141
+ /**
142
+ * Apply rainbow gradient to text (for high thinking levels)
143
+ */
144
+ export function rainbow(text: string): string {
145
+ let result = "";
146
+ let colorIndex = 0;
147
+ for (const char of text) {
148
+ if (char === " " || char === ":") {
149
+ result += char;
150
+ } else {
151
+ result += hexToAnsi(RAINBOW_COLORS[colorIndex % RAINBOW_COLORS.length]) + char;
152
+ colorIndex++;
153
+ }
154
+ }
155
+ return result + "\x1b[0m";
156
+ }
157
+
158
+ /**
159
+ * Get the default color scheme
160
+ */
161
+ export function getDefaultColors(): Required<ColorScheme> {
162
+ return { ...DEFAULT_COLORS };
163
+ }
164
+
165
+ /**
166
+ * Clear the user theme cache (for reloading)
167
+ */
168
+ export function clearThemeCache(): void {
169
+ userThemeCache = null;
170
+ userThemeCacheTime = 0;
171
+ }
package/types.ts CHANGED
@@ -1,3 +1,29 @@
1
+ import type { Theme, ThemeColor } from "@mariozechner/pi-coding-agent";
2
+
3
+ // Theme color - either a pi theme color name or a custom hex color
4
+ export type ColorValue = ThemeColor | `#${string}`;
5
+
6
+ // Semantic color names for segments
7
+ export type SemanticColor =
8
+ | "pi"
9
+ | "model"
10
+ | "path"
11
+ | "git"
12
+ | "gitDirty"
13
+ | "gitClean"
14
+ | "thinking"
15
+ | "thinkingHigh"
16
+ | "context"
17
+ | "contextWarn"
18
+ | "contextError"
19
+ | "cost"
20
+ | "tokens"
21
+ | "separator"
22
+ | "border";
23
+
24
+ // Color scheme mapping semantic names to actual colors
25
+ export type ColorScheme = Partial<Record<SemanticColor, ColorValue>>;
26
+
1
27
  // Segment identifiers
2
28
  export type StatusLineSegmentId =
3
29
  | "pi"
@@ -58,8 +84,12 @@ export interface StatusLineSegmentOptions {
58
84
  export interface PresetDef {
59
85
  leftSegments: StatusLineSegmentId[];
60
86
  rightSegments: StatusLineSegmentId[];
87
+ /** Secondary row segments (shown in footer, above sub bar) */
88
+ secondarySegments?: StatusLineSegmentId[];
61
89
  separator: StatusLineSeparatorStyle;
62
90
  segmentOptions?: StatusLineSegmentOptions;
91
+ /** Color scheme for this preset */
92
+ colors?: ColorScheme;
63
93
  }
64
94
 
65
95
  // Separator definition
@@ -114,6 +144,10 @@ export interface SegmentContext {
114
144
  // Options
115
145
  options: StatusLineSegmentOptions;
116
146
  width: number;
147
+
148
+ // Theming
149
+ theme: Theme;
150
+ colors: ColorScheme;
117
151
  }
118
152
 
119
153
  // Rendered segment output
package/welcome.ts CHANGED
@@ -182,11 +182,20 @@ function renderWelcomeBox(
182
182
  termWidth: number,
183
183
  bottomLine: string,
184
184
  ): string[] {
185
+ // Minimum width for two-column layout: leftCol(26) + separator(3) + minRightCol(15) = 44
186
+ const minLayoutWidth = 44;
187
+
188
+ // If terminal is too narrow for the layout, return empty (skip welcome box)
189
+ if (termWidth < minLayoutWidth) {
190
+ return [];
191
+ }
192
+
185
193
  const minWidth = 76;
186
194
  const maxWidth = 96;
187
- const boxWidth = Math.max(minWidth, Math.min(termWidth - 2, maxWidth));
195
+ // Clamp to termWidth to prevent crash on narrow terminals
196
+ const boxWidth = Math.min(termWidth, Math.max(minWidth, Math.min(termWidth - 2, maxWidth)));
188
197
  const leftCol = 26;
189
- const rightCol = boxWidth - leftCol - 3;
198
+ const rightCol = Math.max(1, boxWidth - leftCol - 3); // Ensure rightCol is at least 1
190
199
 
191
200
  const hChar = "─";
192
201
  const v = dim("│");
@@ -251,9 +260,16 @@ export class WelcomeComponent implements Component {
251
260
  invalidate(): void {}
252
261
 
253
262
  render(termWidth: number): string[] {
263
+ // Minimum width for two-column layout (must match renderWelcomeBox)
264
+ const minLayoutWidth = 44;
265
+ if (termWidth < minLayoutWidth) {
266
+ return [];
267
+ }
268
+
254
269
  const minWidth = 76;
255
270
  const maxWidth = 96;
256
- const boxWidth = Math.max(minWidth, Math.min(termWidth - 2, maxWidth));
271
+ // Clamp to termWidth to prevent crash on narrow terminals
272
+ const boxWidth = Math.min(termWidth, Math.max(minWidth, Math.min(termWidth - 2, maxWidth)));
257
273
 
258
274
  // Bottom line with countdown
259
275
  const countdownText = ` Press any key to continue (${this.countdown}s) `;
@@ -290,18 +306,27 @@ export class WelcomeHeader implements Component {
290
306
  invalidate(): void {}
291
307
 
292
308
  render(termWidth: number): string[] {
309
+ // Minimum width for two-column layout (must match renderWelcomeBox)
310
+ const minLayoutWidth = 44;
311
+ if (termWidth < minLayoutWidth) {
312
+ return [];
313
+ }
314
+
293
315
  const minWidth = 76;
294
316
  const maxWidth = 96;
295
- const boxWidth = Math.max(minWidth, Math.min(termWidth - 2, maxWidth));
317
+ // Clamp to termWidth to prevent crash on narrow terminals
318
+ const boxWidth = Math.min(termWidth, Math.max(minWidth, Math.min(termWidth - 2, maxWidth)));
296
319
  const hChar = "─";
297
320
 
298
321
  // Bottom line with column separator (leftCol=26, rightCol=boxWidth-29)
299
322
  const leftCol = 26;
300
- const rightCol = boxWidth - leftCol - 3;
323
+ const rightCol = Math.max(1, boxWidth - leftCol - 3);
301
324
  const bottomLine = dim(hChar.repeat(leftCol)) + dim("┴") + dim(hChar.repeat(rightCol));
302
325
 
303
326
  const lines = renderWelcomeBox(this.data, termWidth, bottomLine);
304
- lines.push(""); // Add empty line for spacing
327
+ if (lines.length > 0) {
328
+ lines.push(""); // Add empty line for spacing only if we rendered content
329
+ }
305
330
  return lines;
306
331
  }
307
332
  }