pi-editor-footer 0.1.0 → 0.1.2
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 +12 -0
- package/dist/config.js +131 -0
- package/dist/detail-render.js +93 -0
- package/dist/footer.js +282 -0
- package/dist/git.js +128 -0
- package/dist/header.js +117 -0
- package/dist/icons.js +165 -0
- package/dist/index.js +451 -0
- package/dist/model-info.js +243 -0
- package/dist/runtime.js +291 -0
- package/dist/state.js +61 -0
- package/dist/telemetry.js +274 -0
- package/dist/theme-settings.js +355 -0
- package/dist/tracking-editor.js +267 -0
- package/dist/utils-workspace.js +48 -0
- package/dist/utils.js +323 -0
- package/dist/window-presentation.js +38 -0
- package/package.json +22 -3
- package/AGENTS.md +0 -19
- package/CONTEXT.md +0 -29
- package/test/config.test.ts +0 -146
- package/test/detail-render.test.ts +0 -202
- package/test/footer.test.ts +0 -86
- package/test/git.test.ts +0 -45
- package/test/header.test.ts +0 -169
- package/test/icons.test.ts +0 -24
- package/test/runtime.test.ts +0 -71
- package/test/telemetry.test.ts +0 -199
- package/test/utils.test.ts +0 -71
- package/test/window-presentation.test.ts +0 -73
- package/tsconfig.json +0 -13
package/CHANGELOG.md
CHANGED
|
@@ -4,8 +4,20 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.1.2] - 2026-08-20
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
- `npm:pi-editor-footer` not being discovered when installed via `pi install npm:pi-editor-footer` — added `pi.extensions` (`dist/index.js`) and `keywords` so pi loads the theme from `~/.pi/agent/npm/node_modules`
|
|
11
|
+
|
|
12
|
+
## [0.1.1] - 2026-08-20
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
- `npm:pi-editor-footer` now works when installed via `pi install npm:pi-editor-footer` — added `main` (`dist/index.js`), `files`, and `build` (`tsc --project tsconfig.build.json`) so pi can discover the extension from `~/.pi/agent/npm/node_modules`
|
|
17
|
+
|
|
7
18
|
## [0.1.0] - 2026-08-20
|
|
8
19
|
|
|
20
|
+
|
|
9
21
|
### Added
|
|
10
22
|
|
|
11
23
|
- Full TUI theme `pi-editor-footer` rebuilt on `TrackingEditor`: project-aware footer (`cwd` · `git` • `runtime` left, `tokens` next to `context` right), model-info border glow top, live theme respect, detail window preserved
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/** Typed config for pi-skill-desc theme (admission boundary: validate once). */
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
export const DEFAULT_CONFIG = {
|
|
6
|
+
enabled: true,
|
|
7
|
+
workspaceDisplay: "path",
|
|
8
|
+
cursorStyle: "block",
|
|
9
|
+
icons: {
|
|
10
|
+
mode: "auto",
|
|
11
|
+
},
|
|
12
|
+
footerSegments: {
|
|
13
|
+
cwd: true,
|
|
14
|
+
sessionName: false,
|
|
15
|
+
gitBranch: true,
|
|
16
|
+
gitStatus: true,
|
|
17
|
+
gitCommit: false,
|
|
18
|
+
runtime: true,
|
|
19
|
+
context: true,
|
|
20
|
+
tokens: true,
|
|
21
|
+
cost: true,
|
|
22
|
+
extensionStatuses: true,
|
|
23
|
+
},
|
|
24
|
+
telemetry: {
|
|
25
|
+
enabled: true,
|
|
26
|
+
tps: true,
|
|
27
|
+
ttft: true,
|
|
28
|
+
duration: true,
|
|
29
|
+
tokens: true,
|
|
30
|
+
stalls: true,
|
|
31
|
+
cost: true,
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
export function getConfigPath() {
|
|
35
|
+
const home = homedir();
|
|
36
|
+
return join(home, ".pi", "agent", "pi-skill-desc.json");
|
|
37
|
+
}
|
|
38
|
+
function deepMerge(base, override) {
|
|
39
|
+
if (typeof base !== "object" || base === null || Array.isArray(base)) {
|
|
40
|
+
return override ?? base;
|
|
41
|
+
}
|
|
42
|
+
if (typeof override !== "object" ||
|
|
43
|
+
override === null ||
|
|
44
|
+
Array.isArray(override)) {
|
|
45
|
+
return base;
|
|
46
|
+
}
|
|
47
|
+
const result = { ...base };
|
|
48
|
+
const rec = override;
|
|
49
|
+
for (const key of Object.keys(rec)) {
|
|
50
|
+
const bv = base[key];
|
|
51
|
+
const ov = rec[key];
|
|
52
|
+
if (typeof bv === "object" &&
|
|
53
|
+
bv !== null &&
|
|
54
|
+
!Array.isArray(bv) &&
|
|
55
|
+
typeof ov === "object" &&
|
|
56
|
+
ov !== null &&
|
|
57
|
+
!Array.isArray(ov)) {
|
|
58
|
+
result[key] = deepMerge(bv, ov);
|
|
59
|
+
}
|
|
60
|
+
else if (ov !== undefined) {
|
|
61
|
+
result[key] = ov;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return result;
|
|
65
|
+
}
|
|
66
|
+
function validate(config) {
|
|
67
|
+
// workspaceDisplay
|
|
68
|
+
if (config.workspaceDisplay !== "path" &&
|
|
69
|
+
config.workspaceDisplay !== "name") {
|
|
70
|
+
config.workspaceDisplay = DEFAULT_CONFIG.workspaceDisplay;
|
|
71
|
+
}
|
|
72
|
+
// cursorStyle
|
|
73
|
+
if (config.cursorStyle !== "block" &&
|
|
74
|
+
config.cursorStyle !== "bar" &&
|
|
75
|
+
config.cursorStyle !== "underline") {
|
|
76
|
+
config.cursorStyle = DEFAULT_CONFIG.cursorStyle;
|
|
77
|
+
}
|
|
78
|
+
// icons.mode
|
|
79
|
+
if (config.icons.mode !== "auto" &&
|
|
80
|
+
config.icons.mode !== "nerd" &&
|
|
81
|
+
config.icons.mode !== "ascii") {
|
|
82
|
+
config.icons.mode = DEFAULT_CONFIG.icons.mode;
|
|
83
|
+
}
|
|
84
|
+
return config;
|
|
85
|
+
}
|
|
86
|
+
export function ensureConfigExists() {
|
|
87
|
+
const path = getConfigPath();
|
|
88
|
+
if (existsSync(path))
|
|
89
|
+
return;
|
|
90
|
+
try {
|
|
91
|
+
const dir = join(path, "..");
|
|
92
|
+
if (!existsSync(dir))
|
|
93
|
+
mkdirSync(dir, { recursive: true });
|
|
94
|
+
writeFileSync(path, JSON.stringify(DEFAULT_CONFIG, null, 2) + "\n", "utf8");
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
// best-effort
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
export function loadConfig() {
|
|
101
|
+
const path = getConfigPath();
|
|
102
|
+
if (!existsSync(path)) {
|
|
103
|
+
ensureConfigExists();
|
|
104
|
+
return structuredClone(DEFAULT_CONFIG);
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
const raw = readFileSync(path, "utf8");
|
|
108
|
+
const parsed = JSON.parse(raw);
|
|
109
|
+
const merged = deepMerge(structuredClone(DEFAULT_CONFIG), parsed);
|
|
110
|
+
return validate(merged);
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
console.warn(`[pi-skill-desc] config parse error (${path}): ${err instanceof Error ? err.message : String(err)} — using defaults`);
|
|
114
|
+
return structuredClone(DEFAULT_CONFIG);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
export function saveConfig(patch) {
|
|
118
|
+
const current = loadConfig();
|
|
119
|
+
const merged = validate(deepMerge(current, patch));
|
|
120
|
+
const path = getConfigPath();
|
|
121
|
+
try {
|
|
122
|
+
const dir = join(path, "..");
|
|
123
|
+
if (!existsSync(dir))
|
|
124
|
+
mkdirSync(dir, { recursive: true });
|
|
125
|
+
writeFileSync(path, JSON.stringify(merged, null, 2) + "\n", "utf8");
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
// best-effort
|
|
129
|
+
}
|
|
130
|
+
return merged;
|
|
131
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure, TUI-free rendering of the Detail window (ADR-0001).
|
|
3
|
+
*
|
|
4
|
+
* Given a highlighted completion candidate and window state, produces the
|
|
5
|
+
* lines to render in the 5-line detail window above the input box.
|
|
6
|
+
* No pi imports — this is the single testable seam of the extension.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Rendered lines for the detail window: `[header, ...contentLines]`.
|
|
10
|
+
*
|
|
11
|
+
* - Returns `[]` when `item` is null or its description is empty/whitespace.
|
|
12
|
+
* - Header is `<label> · <kind>`, suffixed with a ` offset/total` scroll
|
|
13
|
+
* marker (e.g. ` 3/8`) when the description overflows the window.
|
|
14
|
+
* - The description is wrapped to `width` characters per line (simple
|
|
15
|
+
* character-based wrap; embedded newlines become paragraph breaks).
|
|
16
|
+
* - Shrink-to-fit: exactly `min(maxLines, 1 + contentLines)` lines are
|
|
17
|
+
* returned (1 header + up to `maxLines - 1` content lines).
|
|
18
|
+
* - `scrollOffset` is clamped into `[0, max(0, contentLines - (maxLines - 1))]`.
|
|
19
|
+
*/
|
|
20
|
+
export function renderDetail(item, width, maxLines, scrollOffset) {
|
|
21
|
+
if (!item || item.description.trim() === "") {
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
const wrapWidth = Math.max(1, Math.floor(width));
|
|
25
|
+
const safeMax = Math.max(1, Math.floor(maxLines));
|
|
26
|
+
const contentLines = wrapDescription(item.description, wrapWidth);
|
|
27
|
+
const capacity = Math.max(0, safeMax - 1);
|
|
28
|
+
const maxOffset = capacity === 0 ? 0 : Math.max(0, contentLines.length - capacity);
|
|
29
|
+
const offset = clamp(Math.floor(scrollOffset) || 0, 0, maxOffset);
|
|
30
|
+
const visibleLines = contentLines.slice(offset, offset + capacity);
|
|
31
|
+
// "More content" marker: `...` replaces the last visible content line when
|
|
32
|
+
// there is content remaining BELOW the window (not yet scrolled to the
|
|
33
|
+
// bottom). The header's ` offset/total` marker still carries the totals.
|
|
34
|
+
const hasMoreBelow = offset + visibleLines.length < contentLines.length;
|
|
35
|
+
if (hasMoreBelow && visibleLines.length > 0) {
|
|
36
|
+
visibleLines[visibleLines.length - 1] = "...";
|
|
37
|
+
}
|
|
38
|
+
const overflows = contentLines.length > capacity;
|
|
39
|
+
const namePart = `${item.label} · ${item.kind}`;
|
|
40
|
+
let header;
|
|
41
|
+
if (overflows) {
|
|
42
|
+
// Reserve room for the scroll marker so it always survives truncation.
|
|
43
|
+
const marker = ` ${offset + 1}/${contentLines.length}`;
|
|
44
|
+
const nameWidth = Math.max(0, wrapWidth - marker.length);
|
|
45
|
+
header = truncateToWidth(namePart, nameWidth) + marker;
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
header = truncateToWidth(namePart, wrapWidth);
|
|
49
|
+
}
|
|
50
|
+
return [header, ...visibleLines];
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Next scroll offset after moving by `delta` (-1 = back/up, +1 = forward/down).
|
|
54
|
+
*
|
|
55
|
+
* - Returns 0 when there is nothing to scroll (description fits the window).
|
|
56
|
+
* - Otherwise returns `offset + delta`, clamped to
|
|
57
|
+
* `[0, contentLines - (maxLines - 1)]`.
|
|
58
|
+
*/
|
|
59
|
+
export function scroll(offset, delta, contentLines, maxLines) {
|
|
60
|
+
const capacity = Math.max(0, maxLines - 1);
|
|
61
|
+
if (capacity === 0) {
|
|
62
|
+
return 0;
|
|
63
|
+
}
|
|
64
|
+
const maxOffset = Math.max(0, contentLines - capacity);
|
|
65
|
+
if (maxOffset === 0) {
|
|
66
|
+
return 0;
|
|
67
|
+
}
|
|
68
|
+
return clamp(Math.floor(offset) + delta, 0, maxOffset);
|
|
69
|
+
}
|
|
70
|
+
function wrapDescription(description, width) {
|
|
71
|
+
const lines = [];
|
|
72
|
+
for (const rawLine of description.split("\n")) {
|
|
73
|
+
const trimmed = rawLine.replace(/\s+$/g, "");
|
|
74
|
+
if (trimmed === "") {
|
|
75
|
+
// Preserve explicit paragraph breaks (empty lines in the description).
|
|
76
|
+
lines.push("");
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
for (let i = 0; i < trimmed.length; i += width) {
|
|
80
|
+
const segment = trimmed.slice(i, i + width).replace(/\s+$/g, "");
|
|
81
|
+
if (segment !== "") {
|
|
82
|
+
lines.push(segment);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return lines;
|
|
87
|
+
}
|
|
88
|
+
function truncateToWidth(text, width) {
|
|
89
|
+
return text.length <= width ? text : text.slice(0, width);
|
|
90
|
+
}
|
|
91
|
+
function clamp(value, min, max) {
|
|
92
|
+
return Math.max(min, Math.min(max, value));
|
|
93
|
+
}
|
package/dist/footer.js
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { truncateToWidth, visibleWidth, wrapTextWithAnsi, } from "@earendil-works/pi-tui";
|
|
2
|
+
import { getUsageTotals } from "./state.js";
|
|
3
|
+
import { resolveGlyphs, resolveIconMode, runtimeSymbol } from "./icons.js";
|
|
4
|
+
import { alignRight, basenamePath, cacheHitColor, fitSegmentsByPriority, fmtTokens, formatCwd, formatDuration, formatProviderLabel, sanitizeStatus, stressColor, truncateBranch, truncatePath, } from "./utils.js";
|
|
5
|
+
function renderBar(theme, pct, barWidth, ascii) {
|
|
6
|
+
const filled = Math.max(0, Math.min(barWidth, Math.round((pct / 100) * barWidth)));
|
|
7
|
+
const empty = barWidth - filled;
|
|
8
|
+
const color = stressColor(pct);
|
|
9
|
+
const filledCell = ascii ? "#" : "█";
|
|
10
|
+
const emptyCell = ascii ? "-" : "░";
|
|
11
|
+
return (theme.fg("dim", "[") +
|
|
12
|
+
theme.fg(color, filledCell.repeat(filled)) +
|
|
13
|
+
theme.fg("dim", emptyCell.repeat(empty)) +
|
|
14
|
+
theme.fg("dim", "]"));
|
|
15
|
+
}
|
|
16
|
+
function renderGitSegment(theme, git, glyphs, segments, maxBranchLen = 20) {
|
|
17
|
+
const parts = [];
|
|
18
|
+
if (segments.gitBranch) {
|
|
19
|
+
if (git.branch) {
|
|
20
|
+
parts.push(theme.fg("mdLink", truncateBranch(git.branch, maxBranchLen)));
|
|
21
|
+
}
|
|
22
|
+
else if (git.commit?.detached) {
|
|
23
|
+
parts.push(theme.fg("warning", "HEAD"));
|
|
24
|
+
if (git.commit.oid) {
|
|
25
|
+
const shortHash = git.commit.oid.slice(0, 7);
|
|
26
|
+
const tag = git.commit.tag ? ` ${git.commit.tag}` : "";
|
|
27
|
+
parts.push(theme.fg("dim", `${shortHash}${tag}`));
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (segments.gitStatus) {
|
|
32
|
+
const statusIcons = [];
|
|
33
|
+
const addStatus = (count, glyph, color) => {
|
|
34
|
+
if (count > 0)
|
|
35
|
+
statusIcons.push(theme.fg(color, `${glyph}${count}`));
|
|
36
|
+
};
|
|
37
|
+
addStatus(git.conflicted, glyphs.conflicted, "error");
|
|
38
|
+
addStatus(git.deleted, glyphs.deleted, "error");
|
|
39
|
+
addStatus(git.modified, glyphs.modified, "warning");
|
|
40
|
+
addStatus(git.renamed, glyphs.renamed, "warning");
|
|
41
|
+
addStatus(git.staged, glyphs.staged, "success");
|
|
42
|
+
addStatus(git.untracked, glyphs.untracked, "muted");
|
|
43
|
+
addStatus(git.stashed, glyphs.stashed, "muted");
|
|
44
|
+
if (git.ahead > 0 && git.behind > 0) {
|
|
45
|
+
statusIcons.push(theme.fg("warning", `${glyphs.diverged}${git.ahead}/${git.behind}`));
|
|
46
|
+
}
|
|
47
|
+
else if (git.ahead > 0) {
|
|
48
|
+
statusIcons.push(theme.fg("success", `${glyphs.ahead}${git.ahead}`));
|
|
49
|
+
}
|
|
50
|
+
else if (git.behind > 0) {
|
|
51
|
+
statusIcons.push(theme.fg("warning", `${glyphs.behind}${git.behind}`));
|
|
52
|
+
}
|
|
53
|
+
const statusBlock = statusIcons.join(" ");
|
|
54
|
+
if (statusBlock) {
|
|
55
|
+
parts.push(`${theme.fg("dim", "[")}${statusBlock}${theme.fg("dim", "]")}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return parts.join(" ");
|
|
59
|
+
}
|
|
60
|
+
function renderRuntimeSegment(theme, runtime, iconMode) {
|
|
61
|
+
if (!runtime)
|
|
62
|
+
return "";
|
|
63
|
+
const symbol = theme.fg("success", runtimeSymbol(runtime.name, iconMode));
|
|
64
|
+
const version = runtime.version ? theme.fg("muted", runtime.version) : "";
|
|
65
|
+
const label = [symbol, version].filter(Boolean).join(" ");
|
|
66
|
+
return label;
|
|
67
|
+
}
|
|
68
|
+
function renderTimerSegment(theme, state, glyphs) {
|
|
69
|
+
if (state.workingSince !== undefined) {
|
|
70
|
+
return `${theme.fg("accent", glyphs.working)} ${theme.fg("dim", "working")} ${theme.fg("accent", formatDuration(Date.now() - state.workingSince))}`;
|
|
71
|
+
}
|
|
72
|
+
if (state.lastDoneIn !== undefined) {
|
|
73
|
+
return `${theme.fg("success", glyphs.done)} ${theme.fg("success", "done")} ${theme.fg("text", formatDuration(state.lastDoneIn))}`;
|
|
74
|
+
}
|
|
75
|
+
return "";
|
|
76
|
+
}
|
|
77
|
+
export function renderFooter(width, state, config, theme, ctx) {
|
|
78
|
+
if (width <= 0)
|
|
79
|
+
return [""];
|
|
80
|
+
const glyphs = resolveGlyphs(config.icons.mode);
|
|
81
|
+
const segments = config.footerSegments;
|
|
82
|
+
const totals = ctx.totals ?? {
|
|
83
|
+
input: 0,
|
|
84
|
+
output: 0,
|
|
85
|
+
cacheRead: 0,
|
|
86
|
+
cacheWrite: 0,
|
|
87
|
+
cost: 0,
|
|
88
|
+
latestCacheHitRate: undefined,
|
|
89
|
+
};
|
|
90
|
+
const meta = ctx.getModelMeta
|
|
91
|
+
? ctx.getModelMeta()
|
|
92
|
+
: {
|
|
93
|
+
provider: formatProviderLabel(ctx.model?.provider),
|
|
94
|
+
model: ctx.model?.name ?? ctx.model?.id ?? "no-model",
|
|
95
|
+
effort: undefined,
|
|
96
|
+
};
|
|
97
|
+
const leftParts = [];
|
|
98
|
+
if (segments.cwd) {
|
|
99
|
+
const maxCwd = Math.min(30, Math.max(10, Math.floor(width * 0.4)));
|
|
100
|
+
const rawCwd = formatCwd(ctx.cwd);
|
|
101
|
+
const displayCwd = config.workspaceDisplay === "name" ? basenamePath(rawCwd) : rawCwd;
|
|
102
|
+
const cwdPrefix = `${theme.fg("mdLink", glyphs.cwd)} `;
|
|
103
|
+
const accent = (text) => theme.fg("accent", text);
|
|
104
|
+
leftParts.push({
|
|
105
|
+
text: `${cwdPrefix}${accent(truncatePath(displayCwd, maxCwd))}`,
|
|
106
|
+
compactText: `${cwdPrefix}${accent(truncatePath(basenamePath(displayCwd), maxCwd))}`,
|
|
107
|
+
priority: 5,
|
|
108
|
+
truncate: (_text, maxWidth, ellipsis) => {
|
|
109
|
+
const pathWidth = maxWidth - visibleWidth(cwdPrefix);
|
|
110
|
+
if (pathWidth <= visibleWidth(ellipsis)) {
|
|
111
|
+
return truncateToWidth(`${cwdPrefix}${accent(basenamePath(displayCwd))}`, maxWidth, ellipsis);
|
|
112
|
+
}
|
|
113
|
+
return `${cwdPrefix}${accent(truncatePath(basenamePath(displayCwd), pathWidth))}`;
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
if (segments.sessionName) {
|
|
118
|
+
const sessionName = ctx.sessionName;
|
|
119
|
+
if (sessionName) {
|
|
120
|
+
const sep = leftParts.length > 0 ? `${theme.fg("dim", " • ")}` : "";
|
|
121
|
+
leftParts.push({
|
|
122
|
+
text: `${sep}${theme.fg("dim", glyphs.session)} ${theme.fg("text", truncateToWidth(sessionName, 24, theme.fg("dim", "...")))}`,
|
|
123
|
+
priority: 2,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const gitSeg = renderGitSegment(theme, state.git, glyphs, segments);
|
|
128
|
+
if (gitSeg) {
|
|
129
|
+
const sep = leftParts.length > 0 ? `${theme.fg("dim", " · ")}` : "";
|
|
130
|
+
leftParts.push({ text: `${sep}${gitSeg}`, priority: 4 });
|
|
131
|
+
}
|
|
132
|
+
if (segments.runtime) {
|
|
133
|
+
const runtimeSeg = renderRuntimeSegment(theme, state.runtime, config.icons.mode);
|
|
134
|
+
if (runtimeSeg) {
|
|
135
|
+
const sep = leftParts.length > 0 ? `${theme.fg("dim", " • ")}` : "";
|
|
136
|
+
leftParts.push({ text: `${sep}${runtimeSeg}`, priority: 4 });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const timerSeg = renderTimerSegment(theme, state, glyphs);
|
|
140
|
+
if (timerSeg) {
|
|
141
|
+
const sep = leftParts.length > 0 ? `${theme.fg("dim", " • ")}` : "";
|
|
142
|
+
leftParts.push({ text: `${sep}${timerSeg}`, priority: 1 });
|
|
143
|
+
}
|
|
144
|
+
const stats = [];
|
|
145
|
+
if (segments.tokens) {
|
|
146
|
+
stats.push(theme.fg("accent", `${glyphs.input} ${fmtTokens(totals.input)}`));
|
|
147
|
+
stats.push(theme.fg("success", `${glyphs.output} ${fmtTokens(totals.output)}`));
|
|
148
|
+
const hasCacheTokens = totals.cacheRead > 0 || totals.cacheWrite > 0;
|
|
149
|
+
if (hasCacheTokens && totals.latestCacheHitRate !== undefined) {
|
|
150
|
+
stats.push(theme.fg(cacheHitColor(totals.latestCacheHitRate), `${glyphs.cacheHit} ${totals.latestCacheHitRate.toFixed(1)}%`));
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (segments.cost) {
|
|
154
|
+
stats.push(theme.fg("warning", `${glyphs.cost} $${totals.cost.toFixed(3)}`));
|
|
155
|
+
}
|
|
156
|
+
const statsBlock = stats.join(` ${theme.fg("dim", "|")} `);
|
|
157
|
+
// Context
|
|
158
|
+
let contextText = "";
|
|
159
|
+
let contextCompact;
|
|
160
|
+
if (segments.context) {
|
|
161
|
+
const contextUsage = ctx.contextUsage;
|
|
162
|
+
const contextWindow = contextUsage?.contextWindow ?? 0;
|
|
163
|
+
if (contextWindow > 0) {
|
|
164
|
+
const contextPct = contextUsage?.percent ?? 0;
|
|
165
|
+
const pctText = theme.fg(stressColor(contextPct), `${contextPct.toFixed(1)}%`);
|
|
166
|
+
const contextTokens = contextUsage?.tokens ?? 0;
|
|
167
|
+
const ctxText = `${theme.fg("text", fmtTokens(contextTokens))}${theme.fg("dim", "/")}${theme.fg("text", fmtTokens(contextWindow))}`;
|
|
168
|
+
const contextIcon = theme.fg(stressColor(contextPct), glyphs.context);
|
|
169
|
+
const reserved = visibleWidth(contextIcon) +
|
|
170
|
+
visibleWidth(pctText) +
|
|
171
|
+
visibleWidth(ctxText) +
|
|
172
|
+
7;
|
|
173
|
+
const barWidth = Math.max(4, Math.min(12, width - reserved));
|
|
174
|
+
contextText = `${contextIcon} ${renderBar(theme, contextPct, barWidth, resolveIconMode(config.icons.mode) === "ascii")} ${pctText} ${theme.fg("dim", "·")} ${ctxText}`;
|
|
175
|
+
const compact = `${theme.fg(stressColor(contextPct), glyphs.context)} ${theme.fg(stressColor(contextPct), `${contextPct.toFixed(1)}%`)}`;
|
|
176
|
+
if (visibleWidth(compact) < visibleWidth(contextText))
|
|
177
|
+
contextCompact = compact;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
// Tokens right next to context bar (user request): combine them as single right block
|
|
181
|
+
const rightBlock = [statsBlock, contextText].filter(Boolean).join(" ");
|
|
182
|
+
const rightCompact = statsBlock && contextCompact ? `${statsBlock} ${contextCompact}` : statsBlock || contextCompact;
|
|
183
|
+
const allParts = [...leftParts];
|
|
184
|
+
if (rightBlock) {
|
|
185
|
+
allParts.push({
|
|
186
|
+
text: rightBlock,
|
|
187
|
+
compactText: rightCompact,
|
|
188
|
+
priority: 4,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
const fitted = fitSegmentsByPriority(allParts, width, theme.fg("dim", "..."));
|
|
192
|
+
const fittedContext = rightBlock ? (fitted.pop() ?? "") : "";
|
|
193
|
+
const line1 = alignRight(fitted.join(" "), fittedContext, width, theme);
|
|
194
|
+
const mainLines = [line1].map((line) => truncateToWidth(line, width, theme.fg("dim", "...")));
|
|
195
|
+
if (segments.extensionStatuses && ctx.extensionStatuses) {
|
|
196
|
+
const statuses = Array.from(ctx.extensionStatuses.entries())
|
|
197
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
198
|
+
.map(([, text]) => sanitizeStatus(text))
|
|
199
|
+
.filter((text) => text.length > 0);
|
|
200
|
+
if (statuses.length > 0) {
|
|
201
|
+
const separator = ` ${theme.fg("dim", "|")} `;
|
|
202
|
+
const statusText = statuses
|
|
203
|
+
.map((status) => theme.fg("muted", status))
|
|
204
|
+
.join(separator);
|
|
205
|
+
const line = `${theme.fg("mdLink", glyphs.extensions)} ${statusText}`;
|
|
206
|
+
return [...mainLines, ...wrapTextWithAnsi(line, width)];
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return mainLines;
|
|
210
|
+
}
|
|
211
|
+
// Simplified installFooter for typecheck — real pi integration will wire via ExtensionContext
|
|
212
|
+
export function installFooter(ctx, getState, getConfig, getModelMeta, hooks) {
|
|
213
|
+
// Try setFooter if available (pi-coding-agent), else fallback to setWidget belowEditor
|
|
214
|
+
const themeStub = { fg: (_s, t) => t };
|
|
215
|
+
const render = (width) => {
|
|
216
|
+
const state = getState();
|
|
217
|
+
const config = getConfig();
|
|
218
|
+
const cwd = ctx.sessionManager?.getCwd() ?? process.cwd();
|
|
219
|
+
const totals = getUsageTotals(ctx);
|
|
220
|
+
return renderFooter(width, state, config, themeStub, {
|
|
221
|
+
cwd,
|
|
222
|
+
sessionName: ctx.sessionManager?.getSessionName?.(),
|
|
223
|
+
contextUsage: ctx.getContextUsage?.(),
|
|
224
|
+
model: ctx.model,
|
|
225
|
+
totals,
|
|
226
|
+
getModelMeta,
|
|
227
|
+
});
|
|
228
|
+
};
|
|
229
|
+
// Prefer native footer if available
|
|
230
|
+
if (typeof ctx.ui.setFooter ===
|
|
231
|
+
"function") {
|
|
232
|
+
const ui = ctx.ui;
|
|
233
|
+
ui.setFooter((tui, _theme, footerData) => {
|
|
234
|
+
hooks.setRequestRender(() => tui.requestRender());
|
|
235
|
+
const unsub = footerData.onBranchChange(() => {
|
|
236
|
+
hooks.scheduleGitRefresh();
|
|
237
|
+
tui.requestRender();
|
|
238
|
+
});
|
|
239
|
+
return {
|
|
240
|
+
dispose() {
|
|
241
|
+
unsub();
|
|
242
|
+
hooks.setRequestRender(undefined);
|
|
243
|
+
},
|
|
244
|
+
invalidate() { },
|
|
245
|
+
render(width) {
|
|
246
|
+
// Use real theme when rendering
|
|
247
|
+
const theme = _theme;
|
|
248
|
+
const state = getState();
|
|
249
|
+
const config = getConfig();
|
|
250
|
+
const cwd = ctx.sessionManager?.getCwd() ?? process.cwd();
|
|
251
|
+
const totals = getUsageTotals(ctx);
|
|
252
|
+
return renderFooter(width, state, config, theme, {
|
|
253
|
+
cwd,
|
|
254
|
+
sessionName: ctx.sessionManager?.getSessionName?.(),
|
|
255
|
+
contextUsage: ctx.getContextUsage?.(),
|
|
256
|
+
model: ctx.model,
|
|
257
|
+
totals,
|
|
258
|
+
extensionStatuses: footerData.getExtensionStatuses(),
|
|
259
|
+
getModelMeta,
|
|
260
|
+
});
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
});
|
|
264
|
+
return () => {
|
|
265
|
+
ctx.ui.setFooter(undefined);
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
// Fallback: widget belowEditor
|
|
269
|
+
if (ctx.ui.setWidget) {
|
|
270
|
+
ctx.ui.setWidget("theme-footer", () => ({
|
|
271
|
+
invalidate() { },
|
|
272
|
+
render,
|
|
273
|
+
}), { placement: "belowEditor" });
|
|
274
|
+
hooks.setRequestRender(() => { });
|
|
275
|
+
return () => {
|
|
276
|
+
ctx.ui.setWidget?.("theme-footer", undefined);
|
|
277
|
+
hooks.setRequestRender(undefined);
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
hooks.setRequestRender(undefined);
|
|
281
|
+
return () => { };
|
|
282
|
+
}
|
package/dist/git.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
const execFileAsync = promisify(execFile);
|
|
6
|
+
const GIT_TIMEOUT_MS = 2000;
|
|
7
|
+
export function emptyGitStatus() {
|
|
8
|
+
return {
|
|
9
|
+
branch: undefined,
|
|
10
|
+
ahead: 0,
|
|
11
|
+
behind: 0,
|
|
12
|
+
modified: 0,
|
|
13
|
+
untracked: 0,
|
|
14
|
+
staged: 0,
|
|
15
|
+
stashed: 0,
|
|
16
|
+
conflicted: 0,
|
|
17
|
+
renamed: 0,
|
|
18
|
+
deleted: 0,
|
|
19
|
+
commit: null,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
async function gitExec(args, cwd) {
|
|
23
|
+
try {
|
|
24
|
+
const { stdout } = await execFileAsync("git", args, {
|
|
25
|
+
cwd,
|
|
26
|
+
timeout: GIT_TIMEOUT_MS,
|
|
27
|
+
maxBuffer: 1024 * 1024,
|
|
28
|
+
});
|
|
29
|
+
return stdout;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export async function readGitStatus(cwd, options = {}) {
|
|
36
|
+
if (!existsSync(join(cwd, ".git"))) {
|
|
37
|
+
return emptyGitStatus();
|
|
38
|
+
}
|
|
39
|
+
const stdout = await gitExec(["status", "--porcelain=v1", "--branch", "--show-stash"], cwd);
|
|
40
|
+
if (stdout === null) {
|
|
41
|
+
return emptyGitStatus();
|
|
42
|
+
}
|
|
43
|
+
const status = emptyGitStatus();
|
|
44
|
+
const lines = stdout.split("\n");
|
|
45
|
+
for (const line of lines) {
|
|
46
|
+
if (line.startsWith("## ")) {
|
|
47
|
+
const branchPart = line.slice(3);
|
|
48
|
+
const detached = branchPart.startsWith("HEAD (no branch)");
|
|
49
|
+
if (detached) {
|
|
50
|
+
status.branch = undefined;
|
|
51
|
+
status.commit = { oid: null, detached: true, tag: null };
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
const branchMatch = branchPart.match(/^(\S+?)(?:\.\.\.(\S+))?(?:\s+\[(ahead|behind) (\d+)\])?$/);
|
|
55
|
+
if (branchMatch) {
|
|
56
|
+
status.branch = branchMatch[1];
|
|
57
|
+
if (branchMatch[3] === "ahead")
|
|
58
|
+
status.ahead = parseInt(branchMatch[4], 10);
|
|
59
|
+
if (branchMatch[3] === "behind")
|
|
60
|
+
status.behind = parseInt(branchMatch[4], 10);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (line.startsWith("# stash ")) {
|
|
66
|
+
const stashCount = parseInt(line.slice(8).trim(), 10);
|
|
67
|
+
if (!Number.isNaN(stashCount)) {
|
|
68
|
+
status.stashed = stashCount;
|
|
69
|
+
}
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (options.readCounts === false)
|
|
73
|
+
continue;
|
|
74
|
+
if (line.length < 3)
|
|
75
|
+
continue;
|
|
76
|
+
const x = line[0];
|
|
77
|
+
const y = line[1];
|
|
78
|
+
if (x === "U" || y === "U" || (x === "C" && y === "C"))
|
|
79
|
+
status.conflicted++;
|
|
80
|
+
else if (x === "?" && y === "?")
|
|
81
|
+
status.untracked++;
|
|
82
|
+
else if (x === "R")
|
|
83
|
+
status.renamed++;
|
|
84
|
+
else if (x === "D" || y === "D")
|
|
85
|
+
status.deleted++;
|
|
86
|
+
else {
|
|
87
|
+
if (x !== " " && x !== "?")
|
|
88
|
+
status.staged++;
|
|
89
|
+
if (y === "M" || y === "D")
|
|
90
|
+
status.modified++;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (options.readCounts !== false &&
|
|
94
|
+
status.stashed === 0 &&
|
|
95
|
+
!stdout.includes("# stash")) {
|
|
96
|
+
const stashOut = await gitExec(["stash", "list"], cwd);
|
|
97
|
+
if (stashOut !== null) {
|
|
98
|
+
const count = stashOut
|
|
99
|
+
.split("\n")
|
|
100
|
+
.filter((l) => l.trim().length > 0).length;
|
|
101
|
+
if (!Number.isNaN(count))
|
|
102
|
+
status.stashed = count;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (options.readCommit && status.commit?.detached) {
|
|
106
|
+
const oid = await gitExec(["rev-parse", "HEAD"], cwd);
|
|
107
|
+
if (oid) {
|
|
108
|
+
status.commit.oid = oid.trim();
|
|
109
|
+
}
|
|
110
|
+
if (options.readTag) {
|
|
111
|
+
const tag = await gitExec(["describe", "--tags", "--exact-match", "HEAD"], cwd);
|
|
112
|
+
if (tag) {
|
|
113
|
+
status.commit.tag = tag.trim();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return status;
|
|
118
|
+
}
|
|
119
|
+
export function hasGitChanges(s) {
|
|
120
|
+
return (s.modified > 0 ||
|
|
121
|
+
s.untracked > 0 ||
|
|
122
|
+
s.staged > 0 ||
|
|
123
|
+
s.conflicted > 0 ||
|
|
124
|
+
s.renamed > 0 ||
|
|
125
|
+
s.deleted > 0 ||
|
|
126
|
+
s.ahead > 0 ||
|
|
127
|
+
s.behind > 0);
|
|
128
|
+
}
|