pi-minimal-footer 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.1.3] - 2026-10-05
11
+
12
+ ### Added
13
+
14
+ - Strict TypeScript type checking for the Pi extension
15
+
16
+ ### Changed
17
+
18
+ - Refresh README and package-gallery screenshots with Pi 1.0.3 and the current minimal footer
19
+ - Refresh context after persisted turns, final agent settlement, compaction, and session-tree navigation without reinstalling the footer on each event
20
+ - Read `minFooter` from Pi's configured agent directory at session start or explicit toggle; retain unrelated settings
21
+ - On narrow terminals, shrink/drop the path, then extension statuses, then git branch before truncating model/context
22
+
23
+ ### Fixed
24
+
25
+ - Preserve right alignment on narrow terminals after dropping extension statuses or git branch, including truncated model/context
26
+ - Avoid stale branch subscriptions and render references after footer teardown
27
+ - Abbreviate home only for the exact home path or its children
28
+ - Show the custom footer only in terminal UI mode
29
+ - Clarify that `showSkills` displays extension statuses, not installed skills
30
+
10
31
  ## [0.1.2] - 2026-09-01
11
32
 
12
33
  ### Changed
@@ -65,7 +86,8 @@ First npm-ready release.
65
86
 
66
87
  - Project scaffold — `extensions/index.ts` with basic footer structure, `package.json` with pi extension manifest, `README.md`, `LICENSE` (MIT)
67
88
 
68
- [Unreleased]: https://github.com/Ryu-CZ/pi-minimal-footer/compare/v0.1.2...HEAD
89
+ [Unreleased]: https://github.com/Ryu-CZ/pi-minimal-footer/compare/v0.1.3...HEAD
90
+ [0.1.3]: https://github.com/Ryu-CZ/pi-minimal-footer/compare/v0.1.2...v0.1.3
69
91
  [0.1.2]: https://github.com/Ryu-CZ/pi-minimal-footer/compare/v0.1.1...v0.1.2
70
92
  [0.1.1]: https://github.com/Ryu-CZ/pi-minimal-footer/compare/v0.1.0...v0.1.1
71
93
  [0.1.0]: https://github.com/Ryu-CZ/pi-minimal-footer/compare/v0.0.0...v0.1.0
package/README.md CHANGED
@@ -18,7 +18,7 @@
18
18
  A clean, compact one-line footer for [Pi](https://github.com/earendil-works/pi).
19
19
 
20
20
  ```
21
- ~/path/to/dir (skill1 | skill2)  main sonnet 12/128k
21
+ ~/path/to/dir (status1 | status2)  main sonnet 12/128k
22
22
  ```
23
23
 
24
24
  ![pi-minimal-footer screenshot](media/github-preview.png)
@@ -41,13 +41,21 @@ cp -r extensions/* ~/.pi/agent/extensions/
41
41
  ln -s "$PWD/extensions" ~/.pi/agent/extensions/minimal-footer
42
42
  ```
43
43
 
44
+ ## Development
45
+
46
+ Requires Node.js >=22.19.0. Run `npm ci`, `npm run check`, and `npm test`. Pi loads TypeScript directly; no build is needed. For an isolated local preview, run `pi --no-extensions -e ./extensions/index.ts`. After source edits, use `/reload` in Pi. Tests use Pi 1.0.3 and make no provider requests.
47
+
44
48
  ## Features
45
49
 
46
- - **Working directory** — relative path from home (`~/...`)
47
- - **Active skills** — extensions reporting their current status
50
+ - **Working directory** — home abbreviated as `~`; paths outside home remain absolute
51
+ - **Extension statuses** — text reported by extensions through Pi's status API (`showSkills` retains its existing setting name; it does not discover installed skills)
48
52
  - **Git branch** — current branch name
49
53
  - **Model** — active model ID
50
- - **Context usage** — tokens used / context window (e.g., `12/128k`)
54
+ - **Context usage** — tokens used / context window (e.g., `12/128k`). After compaction, Pi reports usage as unknown (`?`) until a subsequent model response.
55
+
56
+ On narrow terminals, the footer shrinks or drops the path, then drops extension statuses and the git branch before truncating model/context. The remaining segments stay right-aligned.
57
+
58
+ The footer appears only in Pi's interactive terminal UI, not in print, JSON, or RPC modes.
51
59
 
52
60
  ## Commands
53
61
 
@@ -57,11 +65,11 @@ ln -s "$PWD/extensions" ~/.pi/agent/extensions/minimal-footer
57
65
  | `/minfooter on` | Enable |
58
66
  | `/minfooter off` | Disable |
59
67
 
60
- > The `/minfooter` command only toggles the `enabled` flag. To show or hide individual segments, edit `~/.pi/agent/settings.json` directly.
68
+ > The `/minfooter` command only toggles the `enabled` flag. To show or hide individual segments, edit the agent settings file directly.
61
69
 
62
70
  ## Configuration
63
71
 
64
- Settings live in `~/.pi/agent/settings.json` under the `minFooter` key:
72
+ Settings live in Pi's configured agent directory, in `settings.json` under the `minFooter` key (normally `~/.pi/agent/settings.json`). The footer reads settings on session start and when `/minfooter` is run; external edits do not take effect until one of those actions (there is no file watcher). Unrelated settings are preserved when toggling.
65
73
 
66
74
  ```json
67
75
  {
@@ -1,19 +1,21 @@
1
1
  /**
2
2
  * Minimal footer — replaces pi's default footer with a clean status line:
3
3
  *
4
- * ~/path/to/dir (skill1 | skill2)  main sonnet 12k / 128k
4
+ * ~/path/to/dir (skill1 | skill2)  main sonnet 12/128k
5
5
  *
6
- * Settings are persisted in ~/.pi/agent/settings.json under "minFooter".
6
+ * Settings are persisted in the agent directory (usually ~/.pi/agent)
7
+ * settings.json under "minFooter".
7
8
  *
8
9
  * Commands:
9
10
  * /minfooter — toggle on/off
10
11
  * /minfooter on|off — force state
11
12
  */
12
13
 
13
- import type { ExtensionAPI, ExtensionContext, ReadonlyFooterDataProvider } from "@earendil-works/pi-coding-agent";
14
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
15
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
14
16
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
15
17
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
16
- import { join, dirname } from "node:path";
18
+ import { join, dirname, sep } from "node:path";
17
19
  import { homedir } from "node:os";
18
20
 
19
21
  // ── Settings ──────────────────────────────────────────────────────────
@@ -41,7 +43,7 @@ const DEFAULT_SETTINGS: FooterSettings = {
41
43
  };
42
44
 
43
45
  function settingsPath(): string {
44
- return join(homedir(), ".pi", "agent", "settings.json");
46
+ return join(getAgentDir(), "settings.json");
45
47
  }
46
48
 
47
49
  function readSettings(): Settings {
@@ -64,11 +66,6 @@ function writeSettings(patch: Partial<Settings>): void {
64
66
  }
65
67
  }
66
68
 
67
- function readEnabled(): boolean {
68
- const s = readSettings();
69
- return s.minFooter?.enabled !== false; // default to true
70
- }
71
-
72
69
  function readConfig(): FooterSettings {
73
70
  const s = readSettings();
74
71
  return { ...DEFAULT_SETTINGS, ...s.minFooter };
@@ -89,19 +86,22 @@ function formatSize(n: number | null | undefined): string {
89
86
  return String(n);
90
87
  }
91
88
 
89
+ /** Abbreviate only an exact home match or a boundary-prefixed child path. */
90
+ function abbreviateHome(p: string, home: string): string {
91
+ if (p === home) return "~";
92
+ if (p.startsWith(home + sep)) return "~" + p.slice(home.length);
93
+ return p;
94
+ }
95
+
92
96
  // ── State ─────────────────────────────────────────────────────────────
93
97
 
94
- let enabled = readEnabled();
95
- let config = readConfig();
96
- let state = {
97
- cwd: process.cwd(),
98
- model: "no-model",
99
- context: "?",
100
- branch: "",
101
- footerData: null as ReadonlyFooterDataProvider | null,
102
- };
98
+ interface FooterState {
99
+ cwd: string;
100
+ model: string;
101
+ context: string;
102
+ }
103
103
 
104
- function updateState(ctx: ExtensionContext): void {
104
+ function updateState(ctx: ExtensionContext, state: FooterState): void {
105
105
  state.cwd = ctx.cwd ?? process.cwd();
106
106
  state.model = ctx.model?.id ?? "no-model";
107
107
  const usage = ctx.getContextUsage();
@@ -121,101 +121,126 @@ function updateState(ctx: ExtensionContext): void {
121
121
  }
122
122
  }
123
123
 
124
- // ── Footer builders ───────────────────────────────────────────────────
125
-
126
- function buildLeft(): string {
127
- if (!config.showPath) return "";
128
- const home = homedir();
129
- if (state.cwd.startsWith(home)) {
130
- return "~" + state.cwd.slice(home.length);
131
- }
132
- return state.cwd;
133
- }
134
-
135
- function buildRight(footerData: ReadonlyFooterDataProvider, bold: (s: string) => string): string {
136
- const segments: string[] = [];
137
-
138
- if (config.showSkills) {
139
- const statuses = footerData.getExtensionStatuses();
140
- const skillNames = [...statuses.values()].filter(s => s.trim());
141
- if (skillNames.length > 0) {
142
- segments.push("(" + skillNames.join(" | ") + ")");
143
- }
144
- }
145
-
146
- if (config.showGitBranch) {
147
- const branch = footerData.getGitBranch();
148
- if (branch) segments.push(` ${branch}`);
124
+ // ── Layout ────────────────────────────────────────────────────────────
125
+ //
126
+ // Priority when space runs out:
127
+ // 1. drop/truncate the path first (truncate, then drop entirely)
128
+ // 2. drop extension statuses
129
+ // 3. drop the git branch
130
+ // 4. retain model/context as long as possible
131
+ // 5. last resort: truncate the remaining model/context to fit
132
+ //
133
+ // Every returned line is guaranteed to fit `width` (visible-width safe).
134
+
135
+ function buildLine(width: number, path: string, statuses: string, branch: string, model: string, context: string): string {
136
+ if (width <= 0) return "";
137
+ const core = [model, context].filter(Boolean).join(" ");
138
+ const right = [statuses, branch, core].filter(Boolean).join(" ");
139
+ const rightWidth = visibleWidth(right);
140
+
141
+ if (rightWidth < width) {
142
+ const left = path ? truncateToWidth(path, width - rightWidth - 1, "...", true) : " ".repeat(width - rightWidth - 1);
143
+ return `${left} ${right}`;
149
144
  }
150
145
 
151
- if (config.showModel) {
152
- segments.push(bold(state.model));
153
- }
154
-
155
- if (config.showContext) {
156
- segments.push(bold(state.context));
157
- }
158
-
159
- return segments.join(" ");
146
+ // Drop the path, then extension statuses, then git; protect model/context until last.
147
+ let remaining = right;
148
+ if (rightWidth > width) remaining = [branch, core].filter(Boolean).join(" ");
149
+ if (visibleWidth(remaining) > width) remaining = core;
150
+ remaining = truncateToWidth(remaining, width, "...");
151
+ return " ".repeat(width - visibleWidth(remaining)) + remaining;
160
152
  }
161
153
 
162
154
  // ── Extension ─────────────────────────────────────────────────────────
163
155
 
164
156
  export default function (pi: ExtensionAPI) {
165
- function setFooter(ctx: ExtensionContext): void {
166
- updateState(ctx);
167
- config = readConfig(); // re-read on each set
168
-
169
- if (!ctx.hasUI) return;
170
-
171
- if (enabled) {
172
- ctx.ui.setFooter((tui, theme, footerData) => {
173
- state.footerData = footerData;
174
-
175
- const unsub = footerData.onBranchChange(() => tui.requestRender());
176
-
177
- return {
178
- render(width: number): string[] {
179
- const left = buildLeft();
180
- const right = buildRight(footerData, (s) => theme.bold(s));
181
- const rightW = visibleWidth(right);
182
-
183
- // If the right side (model + context) alone doesn't fit, drop the path and truncate it.
184
- if (rightW >= width) {
185
- return [theme.fg("dim", truncateToWidth(right, width, ""))];
186
- }
187
-
188
- // Otherwise keep the right side intact and fit the path into the remaining
189
- // space (padded to exactly fill it, keeping the right side right-aligned).
190
- const leftPart = truncateToWidth(left, width - rightW - 1, "...", true);
191
- return [theme.fg("dim", `${leftPart} ${right}`)];
192
- },
193
- invalidate(): void {},
194
- dispose: unsub,
195
- };
196
- });
197
- } else {
198
- ctx.ui.setFooter(undefined);
157
+ let config = readConfig();
158
+ let enabled = config.enabled !== false;
159
+ const state: FooterState = { cwd: process.cwd(), model: "no-model", context: "?" };
160
+ let requestRender: (() => void) | null = null;
161
+ let disposeFooter: (() => void) | null = null;
162
+
163
+ function install(ctx: ExtensionContext): void {
164
+ config = readConfig();
165
+ enabled = config.enabled !== false;
166
+ if (ctx.mode !== "tui") return;
167
+
168
+ if (!enabled) {
169
+ if (disposeFooter) ctx.ui.setFooter(undefined);
170
+ return;
199
171
  }
172
+
173
+ ctx.ui.setFooter((tui, theme, footerData) => {
174
+ const unsub = footerData.onBranchChange(() => tui.requestRender());
175
+ const request = () => tui.requestRender();
176
+ requestRender = request;
177
+ let disposed = false;
178
+ const dispose = () => {
179
+ if (disposed) return;
180
+ disposed = true;
181
+ unsub();
182
+ if (requestRender === request) requestRender = null;
183
+ if (disposeFooter === dispose) disposeFooter = null;
184
+ };
185
+ disposeFooter = dispose;
186
+
187
+ return {
188
+ render(width: number): string[] {
189
+ const skills = config.showSkills
190
+ ? [...footerData.getExtensionStatuses().values()].filter((s) => s.trim())
191
+ : [];
192
+ const branch = config.showGitBranch ? footerData.getGitBranch() : null;
193
+ const line = buildLine(
194
+ width,
195
+ config.showPath ? abbreviateHome(state.cwd, homedir()) : "",
196
+ skills.length ? `(${skills.join(" | ")})` : "",
197
+ branch ? ` ${branch}` : "",
198
+ config.showModel ? theme.bold(state.model) : "",
199
+ config.showContext ? theme.bold(state.context) : "",
200
+ );
201
+ return [theme.fg("dim", line)];
202
+ },
203
+ invalidate(): void {},
204
+ dispose,
205
+ };
206
+ });
207
+ }
208
+
209
+ /** Cheap refresh: update plain state and request one render. */
210
+ function refresh(ctx: ExtensionContext): void {
211
+ updateState(ctx, state);
212
+ requestRender?.();
200
213
  }
201
214
 
202
- // ── Lifecycle events ────────────────────────────────────────────────
215
+ // ── Lifecycle events (passive; no footer re-install) ────────────────
203
216
 
204
217
  pi.on("session_start", async (_event, ctx) => {
205
- enabled = readEnabled();
206
- setFooter(ctx);
218
+ refresh(ctx);
219
+ install(ctx);
207
220
  });
208
221
 
209
222
  pi.on("model_select", async (_event, ctx) => {
210
- setFooter(ctx);
223
+ refresh(ctx);
224
+ });
225
+
226
+ pi.on("turn_end", async (_event, ctx) => {
227
+ refresh(ctx);
211
228
  });
212
229
 
213
- pi.on("message_end", async (_event, ctx) => {
214
- setFooter(ctx);
230
+ pi.on("agent_settled", async (_event, ctx) => {
231
+ refresh(ctx);
215
232
  });
216
233
 
217
234
  pi.on("session_compact", async (_event, ctx) => {
218
- setFooter(ctx);
235
+ refresh(ctx);
236
+ });
237
+
238
+ pi.on("session_tree", async (_event, ctx) => {
239
+ refresh(ctx);
240
+ });
241
+
242
+ pi.on("session_shutdown", async () => {
243
+ disposeFooter?.();
219
244
  });
220
245
 
221
246
  // ── Command ─────────────────────────────────────────────────────────
@@ -227,7 +252,8 @@ export default function (pi: ExtensionAPI) {
227
252
  else if (args === "off") enabled = false;
228
253
  else enabled = !enabled;
229
254
  writeEnabled(enabled);
230
- setFooter(ctx);
255
+ refresh(ctx);
256
+ install(ctx);
231
257
  ctx.ui.notify(`Minimal footer ${enabled ? "enabled" : "disabled"}`, "info");
232
258
  },
233
259
  });
Binary file
package/media/preview.png CHANGED
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-minimal-footer",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "A minimal footer extension for pi — clean status line with path, skills, model, and context usage.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -30,10 +30,20 @@
30
30
  "type": "git",
31
31
  "url": "git+https://github.com/Ryu-CZ/pi-minimal-footer.git"
32
32
  },
33
+ "scripts": {
34
+ "test": "node --test --test-concurrency=1 tests/*.test.mjs",
35
+ "check": "tsc --noEmit"
36
+ },
33
37
  "peerDependencies": {
34
38
  "@earendil-works/pi-coding-agent": "*",
35
39
  "@earendil-works/pi-tui": "*"
36
40
  },
41
+ "devDependencies": {
42
+ "@earendil-works/pi-coding-agent": "1.0.3",
43
+ "@earendil-works/pi-tui": "1.0.3",
44
+ "@types/node": "22.20.5",
45
+ "typescript": "7.0.2"
46
+ },
37
47
  "pi": {
38
48
  "extensions": [
39
49
  "./extensions"