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 +38 -0
- package/README.md +41 -0
- package/index.ts +216 -83
- package/package.json +2 -1
- package/presets.ts +34 -2
- package/segments.ts +36 -38
- package/theme.example.json +19 -0
- package/theme.ts +171 -0
- package/types.ts +34 -0
- package/welcome.ts +31 -6
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
|
-
/**
|
|
47
|
-
function
|
|
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
|
|
53
|
-
for (const segId of
|
|
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
|
-
|
|
73
|
+
parts.push(rendered.content);
|
|
65
74
|
}
|
|
66
75
|
}
|
|
67
76
|
|
|
68
|
-
if (
|
|
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
|
-
|
|
75
|
-
|
|
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
|
-
|
|
351
|
-
const
|
|
352
|
-
const statusContent =
|
|
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
|
-
|
|
357
|
-
|
|
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
|
-
//
|
|
422
|
-
ctx.ui.setFooter((tui: any,
|
|
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
|
-
|
|
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
|
|
440
|
-
|
|
441
|
-
if (!statusContent) return [];
|
|
536
|
+
const segmentCtx = buildSegmentContext(currentCtx, width, theme);
|
|
537
|
+
const lines: string[] = [];
|
|
442
538
|
|
|
443
|
-
//
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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.
|
|
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", "
|
|
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 {
|
|
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(
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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(
|
|
146
|
+
indicators.push(applyColor(ctx.theme, "warning", `*${gitStatus.unstaged}`));
|
|
140
147
|
}
|
|
141
148
|
if (opts.showStaged !== false && gitStatus.staged > 0) {
|
|
142
|
-
indicators.push(
|
|
149
|
+
indicators.push(applyColor(ctx.theme, "success", `+${gitStatus.staged}`));
|
|
143
150
|
}
|
|
144
151
|
if (opts.showUntracked !== false && gitStatus.untracked > 0) {
|
|
145
|
-
indicators.push(
|
|
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
|
-
|
|
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
|
-
|
|
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 = `
|
|
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
|
|
188
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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,
|
|
273
|
+
content = withIcon(icons.context, color(ctx, "contextError", text));
|
|
276
274
|
} else if (pct > 70) {
|
|
277
|
-
content = withIcon(icons.context,
|
|
275
|
+
content = withIcon(icons.context, color(ctx, "contextWarn", text));
|
|
278
276
|
} else {
|
|
279
|
-
content = withIcon(icons.context,
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
}
|