@hheei/pi-status 0.1.2 → 0.2.1

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/README.md CHANGED
@@ -1,3 +1,35 @@
1
1
  # @hheei/pi-status
2
2
 
3
- Pi response telemetry extension. Requires Pi `>=0.87.0`.
3
+ High-density response telemetry and compact two-line footer extension for Pi. Compatible with Pi `>=0.99.0` and `1.0.0`.
4
+
5
+ ```bash
6
+ pi install npm:@hheei/pi-status
7
+ ```
8
+
9
+ The package mounts an ext-core response status tracker and replaces Pi's default TUI footer with an informative, compact two-line layout.
10
+
11
+ ## Features
12
+
13
+ - **Compact Two-Line Footer**: Maximizes vertical editor space while presenting critical session context at a glance.
14
+ - **Top Row (Session & Worktree)**:
15
+ - Current working directory with `~` home abbreviation and path fitting.
16
+ - Active Git branch name and dirty status indicators (`*`).
17
+ - Active session stats and timing.
18
+ - **Bottom Row (Model & Telemetry)**:
19
+ - Full model identifier with provider prefix (e.g. `anthropic/claude-3.5-sonnet`, `cx/gpt-6.1-sol`).
20
+ - Thinking level indicator when active (e.g. `(high)`, `(low)`).
21
+ - Routing indicator when a virtual model resolves to a distinct physical model (`virtual -> physical`).
22
+ - Context window visualization gauge with percentage and token counts (e.g. `[████░░░░░░] 42% (84k/200k)`), colored dynamically as limits approach.
23
+ - Session token usage and cumulative cost tracking.
24
+ - Right-aligned running child agent ids followed by the TODO status, separated by ` ‧ ` (e.g. `🤖 󰪠#21 #22 #23 ‧ 󰪠 #1 Task`); the separator is dropped when only one side is present, and an unrelated extension status is shown only when both are absent.
25
+ - **Lifecycle Cleanliness**: Gracefully unmounts and restores original UI footers on session disposal or reload.
26
+
27
+ ## Installation
28
+
29
+ Add to your Pi configuration or install directly via npm:
30
+
31
+ ```bash
32
+ pi install npm:@hheei/pi-status
33
+ ```
34
+
35
+ The extension entrypoint is declared under `dist/extension.js` and automatically activates in TUI mode.
@@ -1,3 +1,3 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- /** Mounts ext-core response telemetry for this independently installable feature. */
2
+ /** Mounts ext-core response telemetry and compact two-line footer. */
3
3
  export default function piStatusExtension(pi: ExtensionAPI): void;
package/dist/extension.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { createResponseStatusFeature, registerExtensionLifecycle } from "@hheei/pi-ext-core";
2
- /** Mounts ext-core response telemetry for this independently installable feature. */
2
+ import { CompactFooterComponent } from "./footer.js";
3
+ /** Mounts ext-core response telemetry and compact two-line footer. */
3
4
  export default function piStatusExtension(pi) {
4
5
  const status = createResponseStatusFeature(pi);
5
6
  registerExtensionLifecycle(pi, {
@@ -8,6 +9,12 @@ export default function piStatusExtension(pi) {
8
9
  status.start(extension);
9
10
  const sessionId = extension.sessionManager.getSessionId();
10
11
  resources.add("response-status", () => status.dispose(sessionId));
12
+ if (extension.mode === "tui") {
13
+ extension.ui.setFooter((tui, theme, footerData) => new CompactFooterComponent(extension, tui, theme, footerData));
14
+ resources.add("footer", () => {
15
+ extension.ui.setFooter(undefined);
16
+ });
17
+ }
11
18
  },
12
19
  });
13
20
  }
@@ -0,0 +1,63 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { type Component } from "@earendil-works/pi-tui";
3
+ import { type ThemeLike } from "@hheei/pi-ext-core";
4
+ type FooterFactory = NonNullable<Parameters<ExtensionContext["ui"]["setFooter"]>[0]>;
5
+ export type ReadonlyFooterDataProvider = Parameters<FooterFactory>[2];
6
+ export type FooterTheme = Parameters<FooterFactory>[1];
7
+ /** Formats cwd replacing HOME with ~ */
8
+ export declare function formatCwdForFooter(cwd: string, home: string | undefined): string;
9
+ export interface RoutedModelInfo {
10
+ readonly model: {
11
+ readonly id: string;
12
+ readonly provider?: string;
13
+ };
14
+ readonly thinkingLevel?: string;
15
+ }
16
+ /**
17
+ * Formats model identifier with provider prefix and thinking level:
18
+ * - When theme is provided:
19
+ * [provider/ (dim)][modelId (text)][(thinking) ((dim) + (accent) + (dim))]
20
+ * - When theme is omitted:
21
+ * gm/gemini-3.8-flash(high) or gm/gemini-3.8-flash
22
+ * - When routedModel is provided and resolves to a distinct model:
23
+ * [virtual-model] -> [physical-model]
24
+ */
25
+ export declare function formatFooterModel(model: {
26
+ readonly id: string;
27
+ readonly provider?: string;
28
+ } | undefined, thinkingLevel: string | undefined, theme?: ThemeLike, routedModel?: RoutedModelInfo | undefined): string;
29
+ /**
30
+ * Formats context usage ratio: N%/100K with warning/error colors:
31
+ * - <= 70%: [N% (muted)][/100K (dim)]
32
+ * - 70% ~ 90%: warning
33
+ * - > 90%: error
34
+ */
35
+ export declare function formatFooterContext(contextUsage: {
36
+ readonly percent?: number | null;
37
+ readonly contextWindow?: number;
38
+ } | undefined, defaultWindow: number | undefined, theme: ThemeLike): string;
39
+ /**
40
+ * Formats cwd and optional git branch for footer display:
41
+ * [path (muted)] [( (dim)][branch (accent)][) (dim)]
42
+ */
43
+ export declare function formatFooterPath(cwd: string, branch: string | null | undefined, theme: ThemeLike, home?: string): string;
44
+ /**
45
+ * Arranges a two-column row with left content aligned left and right content aligned right.
46
+ * When constrained, shrinks/truncates or drops the right column to prioritize the left column.
47
+ */
48
+ export declare function layoutTwoColumnRow(left: string, right: string | undefined, width: number, minGap?: number): string;
49
+ /**
50
+ * Custom 2-line compact footer:
51
+ * Line 1: path (git) title
52
+ * Line 2: provider/model(level) · N%/100K agent ids ‧ todo status
53
+ */
54
+ export declare class CompactFooterComponent implements Component {
55
+ #private;
56
+ constructor(extension: ExtensionContext, tui: {
57
+ requestRender(): void;
58
+ }, theme: FooterTheme, footerData: ReadonlyFooterDataProvider);
59
+ invalidate(): void;
60
+ dispose(): void;
61
+ render(width: number): string[];
62
+ }
63
+ export {};
package/dist/footer.js ADDED
@@ -0,0 +1,201 @@
1
+ import { homedir } from "node:os";
2
+ import { isAbsolute, relative, resolve, sep } from "node:path";
3
+ import { visibleWidth } from "@earendil-works/pi-tui";
4
+ import { fitRow, formatCompactNumber } from "@hheei/pi-ext-core";
5
+ /** Pi native status keys owned by other extensions. */
6
+ const AUTO_TITLE_STATUS_KEY = "auto-title";
7
+ const AGENT_STATUS_KEY = "pi-subagents";
8
+ const TODO_STATUS_KEY = "pi-ext-tools:todo";
9
+ /** Formats cwd replacing HOME with ~ */
10
+ export function formatCwdForFooter(cwd, home) {
11
+ if (!home)
12
+ return cwd;
13
+ const resolvedCwd = resolve(cwd);
14
+ const resolvedHome = resolve(home);
15
+ const relativeToHome = relative(resolvedHome, resolvedCwd);
16
+ const isInsideHome = relativeToHome === "" ||
17
+ (relativeToHome !== ".." &&
18
+ !relativeToHome.startsWith(`..${sep}`) &&
19
+ !isAbsolute(relativeToHome));
20
+ if (!isInsideHome)
21
+ return cwd;
22
+ return relativeToHome === "" ? "~" : `~${sep}${relativeToHome}`;
23
+ }
24
+ /**
25
+ * Formats model identifier with provider prefix and thinking level:
26
+ * - When theme is provided:
27
+ * [provider/ (dim)][modelId (text)][(thinking) ((dim) + (accent) + (dim))]
28
+ * - When theme is omitted:
29
+ * gm/gemini-3.8-flash(high) or gm/gemini-3.8-flash
30
+ * - When routedModel is provided and resolves to a distinct model:
31
+ * [virtual-model] -> [physical-model]
32
+ */
33
+ export function formatFooterModel(model, thinkingLevel, theme, routedModel) {
34
+ if (model === undefined)
35
+ return theme !== undefined ? theme.fg("dim", "no-model") : "no-model";
36
+ const hasProvider = model.provider !== undefined && model.provider.trim() !== "";
37
+ const providerText = hasProvider ? `${model.provider}/` : "";
38
+ const hasThinking = thinkingLevel !== undefined && thinkingLevel !== "off" && thinkingLevel.trim() !== "";
39
+ const baseText = (() => {
40
+ if (theme === undefined) {
41
+ const levelSuffix = hasThinking ? `(${thinkingLevel})` : "";
42
+ return `${providerText}${model.id}${levelSuffix}`;
43
+ }
44
+ const providerPart = hasProvider ? theme.fg("dim", providerText) : "";
45
+ const modelPart = theme.fg("text", model.id);
46
+ const thinkingPart = hasThinking
47
+ ? `${theme.fg("dim", "(")}${theme.fg("accent", thinkingLevel)}${theme.fg("dim", ")")}`
48
+ : "";
49
+ return `${providerPart}${modelPart}${thinkingPart}`;
50
+ })();
51
+ if (!routedModel ||
52
+ (routedModel.model.id === model.id && routedModel.model.provider === model.provider)) {
53
+ return baseText;
54
+ }
55
+ const routedHasProvider = routedModel.model.provider !== undefined &&
56
+ routedModel.model.provider.trim() !== "" &&
57
+ routedModel.model.provider !== model.provider;
58
+ const routedProviderText = routedHasProvider ? `${routedModel.model.provider}/` : "";
59
+ const routedThinkingLevel = routedModel.thinkingLevel;
60
+ const routedHasThinking = routedThinkingLevel !== undefined &&
61
+ routedThinkingLevel !== "off" &&
62
+ routedThinkingLevel.trim() !== "";
63
+ if (theme === undefined) {
64
+ const routedLevelSuffix = routedHasThinking ? `(${routedThinkingLevel})` : "";
65
+ return `${baseText} → ${routedProviderText}${routedModel.model.id}${routedLevelSuffix}`;
66
+ }
67
+ const arrowPart = theme.fg("dim", "→");
68
+ const routedProviderPart = routedHasProvider ? theme.fg("dim", routedProviderText) : "";
69
+ const routedModelPart = theme.fg("text", routedModel.model.id);
70
+ const routedThinkingPart = routedHasThinking
71
+ ? `${theme.fg("dim", "(")}${theme.fg("accent", routedThinkingLevel)}${theme.fg("dim", ")")}`
72
+ : "";
73
+ return `${baseText} ${arrowPart} ${routedProviderPart}${routedModelPart}${routedThinkingPart}`;
74
+ }
75
+ /**
76
+ * Formats context usage ratio: N%/100K with warning/error colors:
77
+ * - <= 70%: [N% (muted)][/100K (dim)]
78
+ * - 70% ~ 90%: warning
79
+ * - > 90%: error
80
+ */
81
+ export function formatFooterContext(contextUsage, defaultWindow, theme) {
82
+ const window = contextUsage?.contextWindow ?? defaultWindow ?? 0;
83
+ const windowStr = window > 0 ? formatCompactNumber(window) : "0";
84
+ const percentValue = contextUsage?.percent;
85
+ const percentStr = percentValue !== null && percentValue !== undefined ? `${Math.round(percentValue)}%` : "?%";
86
+ if (percentValue !== null && percentValue !== undefined) {
87
+ if (percentValue > 90)
88
+ return theme.fg("error", `${percentStr}/${windowStr}`);
89
+ if (percentValue > 70)
90
+ return theme.fg("warning", `${percentStr}/${windowStr}`);
91
+ }
92
+ return `${theme.fg("muted", percentStr)}${theme.fg("dim", `/${windowStr}`)}`;
93
+ }
94
+ /**
95
+ * Formats cwd and optional git branch for footer display:
96
+ * [path (muted)] [( (dim)][branch (accent)][) (dim)]
97
+ */
98
+ export function formatFooterPath(cwd, branch, theme, home) {
99
+ const formattedCwd = formatCwdForFooter(cwd, home);
100
+ const cwdPart = theme.fg("muted", formattedCwd);
101
+ if (branch === null || branch === undefined || branch.trim() === "") {
102
+ return cwdPart;
103
+ }
104
+ const branchPart = `${theme.fg("dim", "(")}${theme.fg("accent", branch)}${theme.fg("dim", ")")}`;
105
+ return `${cwdPart} ${branchPart}`;
106
+ }
107
+ /**
108
+ * Arranges a two-column row with left content aligned left and right content aligned right.
109
+ * When constrained, shrinks/truncates or drops the right column to prioritize the left column.
110
+ */
111
+ export function layoutTwoColumnRow(left, right, width, minGap = 2) {
112
+ const leftWidth = visibleWidth(left);
113
+ if (leftWidth >= width) {
114
+ return fitRow(left, width, "");
115
+ }
116
+ if (right === undefined || right.trim() === "") {
117
+ return left;
118
+ }
119
+ const rightWidth = visibleWidth(right);
120
+ const totalNeeded = leftWidth + minGap + rightWidth;
121
+ if (totalNeeded <= width) {
122
+ const spaces = " ".repeat(width - leftWidth - rightWidth);
123
+ return `${left}${spaces}${right}`;
124
+ }
125
+ // Insufficient space: truncate right side with ellipsis if at least 4 cols available
126
+ const availableForRight = width - leftWidth - minGap;
127
+ if (availableForRight >= 4) {
128
+ const truncatedRight = fitRow(right, availableForRight, "…");
129
+ const truncatedWidth = visibleWidth(truncatedRight);
130
+ const spaces = " ".repeat(Math.max(minGap, width - leftWidth - truncatedWidth));
131
+ return `${left}${spaces}${truncatedRight}`;
132
+ }
133
+ // Extremely narrow: drop right side entirely to preserve full left side
134
+ return left;
135
+ }
136
+ /**
137
+ * Custom 2-line compact footer:
138
+ * Line 1: path (git) title
139
+ * Line 2: provider/model(level) · N%/100K agent ids ‧ todo status
140
+ */
141
+ export class CompactFooterComponent {
142
+ #extension;
143
+ #footerData;
144
+ #theme;
145
+ #unsubscribeBranch;
146
+ constructor(extension, tui, theme, footerData) {
147
+ this.#extension = extension;
148
+ this.#footerData = footerData;
149
+ this.#theme = theme;
150
+ this.#unsubscribeBranch = footerData.onBranchChange(() => {
151
+ tui.requestRender();
152
+ });
153
+ }
154
+ invalidate() { }
155
+ dispose() {
156
+ this.#unsubscribeBranch?.();
157
+ }
158
+ render(width) {
159
+ const cwd = this.#extension.sessionManager.getCwd();
160
+ const branch = this.#footerData.getGitBranch();
161
+ const line1Left = formatFooterPath(cwd, branch, this.#theme, homedir());
162
+ const extensionStatuses = this.#footerData.getExtensionStatuses();
163
+ const autoTitleStatus = extensionStatuses.get(AUTO_TITLE_STATUS_KEY);
164
+ const sessionName = this.#extension.sessionManager.getSessionName();
165
+ const line1Right = autoTitleStatus !== undefined && autoTitleStatus.trim() !== ""
166
+ ? autoTitleStatus
167
+ : sessionName !== undefined && sessionName.trim() !== ""
168
+ ? this.#theme.fg("muted", sessionName)
169
+ : undefined;
170
+ const line1 = layoutTwoColumnRow(line1Left, line1Right, width);
171
+ // Line 2 left: model + thinking level · context%
172
+ const routed = this.#extension.routedModel;
173
+ const modelPart = formatFooterModel(this.#extension.model, this.#extension.thinkingLevel, this.#theme, routed);
174
+ const contextPart = formatFooterContext(this.#extension.getContextUsage(), this.#extension.model?.contextWindow, this.#theme);
175
+ const line2Left = `${modelPart} ${this.#theme.fg("dim", "·")} ${contextPart}`;
176
+ // Line 2 right: running child agents then TODO, joined by a dim separator. A missing
177
+ // segment drops the separator with it. Any other extension status is only a fallback,
178
+ // so auto-title (line 1) and these two keys never leak in twice.
179
+ const agentStatus = extensionStatuses.get(AGENT_STATUS_KEY);
180
+ const todoStatus = extensionStatuses.get(TODO_STATUS_KEY);
181
+ const statusParts = [];
182
+ if (agentStatus !== undefined && agentStatus.trim() !== "")
183
+ statusParts.push(agentStatus);
184
+ if (todoStatus !== undefined && todoStatus.trim() !== "")
185
+ statusParts.push(todoStatus);
186
+ let line2Right = statusParts.length > 0 ? statusParts.join(this.#theme.fg("dim", " ‧ ")) : undefined;
187
+ if (line2Right === undefined) {
188
+ for (const [key, value] of extensionStatuses) {
189
+ if (key !== AUTO_TITLE_STATUS_KEY &&
190
+ key !== AGENT_STATUS_KEY &&
191
+ key !== TODO_STATUS_KEY &&
192
+ value.trim() !== "") {
193
+ line2Right = value;
194
+ break;
195
+ }
196
+ }
197
+ }
198
+ const line2 = layoutTwoColumnRow(line2Left, line2Right, width);
199
+ return [line1, line2];
200
+ }
201
+ }
package/dist/index.d.ts CHANGED
@@ -1 +1,2 @@
1
1
  export { default } from "./extension.js";
2
+ export { CompactFooterComponent, type FooterTheme, formatCwdForFooter, formatFooterContext, formatFooterModel, formatFooterPath, layoutTwoColumnRow, type ReadonlyFooterDataProvider, type RoutedModelInfo, } from "./footer.js";
package/dist/index.js CHANGED
@@ -1 +1,2 @@
1
1
  export { default } from "./extension.js";
2
+ export { CompactFooterComponent, formatCwdForFooter, formatFooterContext, formatFooterModel, formatFooterPath, layoutTwoColumnRow, } from "./footer.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hheei/pi-status",
3
- "version": "0.1.2",
3
+ "version": "0.2.1",
4
4
  "description": "Response telemetry for Pi sessions.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -35,13 +35,15 @@
35
35
  "access": "public"
36
36
  },
37
37
  "dependencies": {
38
- "@hheei/pi-ext-core": "^0.1.2"
38
+ "@hheei/pi-ext-core": "^0.3.0"
39
39
  },
40
40
  "peerDependencies": {
41
- "@earendil-works/pi-coding-agent": ">=0.87.0"
41
+ "@earendil-works/pi-coding-agent": ">=1.2.0",
42
+ "@earendil-works/pi-tui": ">=1.2.0"
42
43
  },
43
44
  "devDependencies": {
44
- "@earendil-works/pi-coding-agent": "0.87.0"
45
+ "@earendil-works/pi-coding-agent": "1.2.0",
46
+ "@earendil-works/pi-tui": "1.2.0"
45
47
  },
46
48
  "scripts": {
47
49
  "build": "node -e \"import { rmSync } from 'node:fs'; rmSync('dist', { recursive: true, force: true })\" && tsc -p tsconfig.build.json"