@sassywares/pi-extensions 0.3.20 → 0.3.22

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
@@ -151,7 +151,7 @@ Writes do not fail quietly and they are not matched by pattern. `rm`, `sed -i`,
151
151
 
152
152
  A backend is only trusted after it proves both halves of the contract: reads inside the working directory must succeed **and** a probe write there must fail. A backend that is installed but cannot enforce (for example bubblewrap on a distribution that blocks unprivileged user namespaces) is discarded, and ask mode continues without bash rather than falling back to a command allowlist. The working directory always wins over the temp allowance: if the project itself lives under the temp directory, temp writes are denied too and `/modes` reports `temp dir is read-only`.
153
153
 
154
- `/modes` shows the verdict. While ask mode is active, the footer is replaced by a compact one that draws the badge on the working-directory/session line — `⏸ ask · ro(sandbox-exec)` or `⏸ ask · bash off` — instead of taking a row of its own; other extension statuses keep their line. Leaving ask mode restores pi's native footer.
154
+ `/modes` shows the verdict. The mode badge sits on a footer line of its own, aligned to the right edge: `▶ work`, `⏸ ask · ro(sandbox-exec)`, or `⏸ ask · bash off`. To place it there without reimplementing pi's footer, the extension delegates to pi's own `FooterComponent` and appends the badge line, so directory, session name, token stats, model, and context usage stay exactly as pi renders them. If a future pi release changes what that component reads, the extension restores the native footer and falls back to a left-aligned status line.
155
155
 
156
156
  - `--ask` starts a session in ask mode; the mode persists across resume.
157
157
  - `shift+tab` is not used, because pi binds it to the thinking level. To match an editor that uses it for modes, bind `app.thinking.cycle` to something else in `~/.pi/agent/keybindings.json` and add a shortcut there.
@@ -1,90 +1,54 @@
1
1
  /**
2
- * Ask-mode footer.
2
+ * Right-aligned mode badge.
3
3
  *
4
- * The built-in footer puts every `ctx.ui.setStatus()` entry on its own line
5
- * below the token stats, which leaves stale blank space above the model line
6
- * and pushes the transcript up by one row. This footer is installed only while
7
- * ask mode is active, and draws the mode badge on the working-directory/session
8
- * line instead, right-aligned into space that is otherwise empty.
4
+ * `ctx.ui.setStatus()` has no placement: pi appends status texts as a line of
5
+ * their own, left-aligned, and its sanitizer collapses runs of spaces, so a
6
+ * badge cannot be pushed to the right edge from there.
9
7
  *
10
- * It is deliberately a subset of pi's footer: working directory, git branch,
11
- * session name, token totals, cost, context usage, and model. Any other
12
- * extension statuses are kept on their own line.
8
+ * This footer keeps pi's own rendering (it delegates to the exported
9
+ * `FooterComponent`) and appends one extra line with the badge aligned to the
10
+ * right edge. Nothing about the directory, session name, token stats, or model
11
+ * line is reimplemented here, so it stays correct as pi's footer evolves.
12
+ *
13
+ * If a future pi version changes what `FooterComponent` reads from its session
14
+ * argument, the render below throws; `onError` then tells the extension to
15
+ * restore the native footer and fall back to the plain status line.
13
16
  */
14
17
 
15
- import { homedir } from "node:os";
16
- import { relative, resolve, sep } from "node:path";
17
18
  import type { Component } from "@earendil-works/pi-tui";
18
19
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
19
20
  import type { ExtensionContext, ReadonlyFooterDataProvider, Theme } from "@earendil-works/pi-coding-agent";
21
+ import { FooterComponent } from "@earendil-works/pi-coding-agent";
20
22
 
21
23
  export interface ModeBadge {
22
24
  text: string;
23
- tone: "warning" | "error";
24
- }
25
-
26
- interface Usage {
27
- input?: number;
28
- output?: number;
29
- cost?: { total?: number };
30
- }
31
-
32
- interface Totals {
33
- input: number;
34
- output: number;
35
- cost: number;
36
- }
37
-
38
- const EMPTY_TOTALS: Totals = { input: 0, output: 0, cost: 0 };
39
-
40
- export function formatTokens(count: number): string {
41
- if (count < 1000) return count.toString();
42
- if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
43
- if (count < 1000000) return `${Math.round(count / 1000)}k`;
44
- if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`;
45
- return `${Math.round(count / 1000000)}M`;
25
+ tone: "warning" | "error" | "accent";
46
26
  }
47
27
 
48
- export function formatCwd(cwd: string, home = homedir()): string {
49
- const resolvedCwd = resolve(cwd);
50
- const resolvedHome = resolve(home);
51
- const delta = relative(resolvedHome, resolvedCwd);
52
- const insideHome = delta === "" || (delta !== ".." && !delta.startsWith(`..${sep}`) && !isAbsolutePath(delta));
53
- if (!insideHome) return cwd;
54
- return delta === "" ? "~" : `~${sep}${delta}`;
55
- }
56
-
57
- function isAbsolutePath(value: string): boolean {
58
- return value.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(value);
59
- }
60
-
61
- /** Left text on the left edge, right text on the right edge, one space minimum between. */
62
- function justify(left: string, right: string, width: number): string {
63
- const leftWidth = visibleWidth(left);
64
- const rightWidth = visibleWidth(right);
65
- if (leftWidth + rightWidth + 1 <= width) {
66
- return left + " ".repeat(width - leftWidth - rightWidth) + right;
67
- }
68
-
69
- const availableLeft = width - rightWidth - 1;
70
- if (availableLeft >= 12) {
71
- const shortened = truncateToWidth(left, availableLeft, "…");
72
- const shortenedWidth = visibleWidth(shortened);
73
- return shortened + " ".repeat(Math.max(0, width - shortenedWidth - rightWidth)) + right;
74
- }
75
-
76
- if (rightWidth + 1 <= width) {
77
- return " ".repeat(Math.max(0, width - rightWidth)) + right;
78
- }
79
-
80
- return truncateToWidth(right, width, "…");
28
+ export interface ModesFooterOptions {
29
+ /** Session auto-compaction, shown by the footer as the `(auto)` suffix. */
30
+ autoCompact: boolean;
31
+ /** Called once if pi's footer cannot be rendered through this shim. */
32
+ onError: () => void;
81
33
  }
82
34
 
83
- function addUsage(totals: Totals, usage: Usage | undefined): void {
84
- if (!usage) return;
85
- totals.input += usage.input ?? 0;
86
- totals.output += usage.output ?? 0;
87
- totals.cost += usage.cost?.total ?? 0;
35
+ /**
36
+ * The subset of `AgentSession` that `FooterComponent` reads. Everything except
37
+ * a few computed values is already on the extension context.
38
+ */
39
+ function sessionShim(ctx: ExtensionContext) {
40
+ return {
41
+ model: ctx.model,
42
+ state: { model: ctx.model, thinkingLevel: ctx.thinkingLevel },
43
+ routedModel: undefined,
44
+ getContextUsage: () => ctx.getContextUsage(),
45
+ modelRuntime: {
46
+ // Extensions cannot observe subscription backing; the provider name is
47
+ // the only signal available.
48
+ isUsingSubscription: (provider: string) => provider === "kimi-coding",
49
+ },
50
+ sessionManager: ctx.sessionManager,
51
+ } as unknown as ConstructorParameters<typeof FooterComponent>[0];
88
52
  }
89
53
 
90
54
  export function createModesFooter(
@@ -92,73 +56,33 @@ export function createModesFooter(
92
56
  theme: Theme,
93
57
  footerData: ReadonlyFooterDataProvider,
94
58
  badge: () => ModeBadge,
59
+ options: ModesFooterOptions,
95
60
  ): Component & { dispose?(): void } {
96
- let cacheKey = "";
97
- let cache: Totals = EMPTY_TOTALS;
98
-
99
- function totals(): Totals {
100
- const leaf = ctx.sessionManager.getLeafId() ?? "";
101
- const entries = ctx.sessionManager.getEntries();
102
- const key = `${leaf}:${entries.length}`;
103
- if (key === cacheKey) return cache;
104
-
105
- const next: Totals = { input: 0, output: 0, cost: 0 };
106
- for (const entry of entries) {
107
- const record = entry as { type: string; usage?: Usage; message?: { role?: string; usage?: Usage } };
108
- if (record.type === "usage") {
109
- addUsage(next, record.usage);
110
- } else if (record.type === "message") {
111
- addUsage(next, record.message?.usage);
112
- }
113
- }
114
- cacheKey = key;
115
- cache = next;
116
- return next;
117
- }
118
-
119
- function render(width: number): string[] {
120
- const model = ctx.model;
121
- const branch = footerData.getGitBranch();
122
- const sessionName = ctx.sessionManager.getSessionName();
123
-
124
- let place = formatCwd(ctx.sessionManager.getCwd());
125
- if (branch) place += ` (${branch})`;
126
- if (sessionName) place += ` • ${sessionName}`;
61
+ const inner = new FooterComponent(sessionShim(ctx), footerData);
62
+ inner.setAutoCompactEnabled(options.autoCompact);
63
+ let failed = false;
127
64
 
65
+ function badgeLine(width: number): string {
128
66
  const current = badge();
129
- const lines = [justify(theme.fg("dim", place), theme.fg(current.tone, current.text), width)];
130
-
131
- const used = totals();
132
- const stats: string[] = [];
133
- if (used.input) stats.push(`↑${formatTokens(used.input)}`);
134
- if (used.output) stats.push(`↓${formatTokens(used.output)}`);
135
- if (used.cost) stats.push(`$${used.cost.toFixed(3)}`);
136
- const context = ctx.getContextUsage();
137
- if (context) {
138
- const percent = context.percent === null ? "?" : `${context.percent.toFixed(1)}%`;
139
- stats.push(`${percent}/${formatTokens(context.contextWindow ?? 0)}`);
140
- }
141
-
142
- let modelText = model?.id ?? "no-model";
143
- if (model?.reasoning) modelText += ` • ${ctx.thinkingLevel ?? "off"}`;
144
- if (stats.length > 0) {
145
- lines.push(justify(theme.fg("dim", stats.join(" ")), theme.fg("dim", modelText), width));
146
- }
147
-
148
- const statuses = [...footerData.getExtensionStatuses().entries()]
149
- .filter(([key]) => key !== "modes")
150
- .sort(([a], [b]) => a.localeCompare(b))
151
- .map(([, text]) => text.replace(/[\r\n\t]/g, " ").replace(/ +/g, " ").trim())
152
- .filter((text) => text.length > 0);
153
- if (statuses.length > 0) {
154
- lines.push(truncateToWidth(statuses.join(" "), width, "…"));
155
- }
156
-
157
- return lines;
67
+ const text = current.text;
68
+ if (visibleWidth(text) >= width) return theme.fg(current.tone, truncateToWidth(text, width, "…"));
69
+ const padding = Math.max(1, width - visibleWidth(text));
70
+ return " ".repeat(padding) + theme.fg(current.tone, text);
158
71
  }
159
72
 
160
73
  return {
161
- invalidate() {},
162
- render,
74
+ invalidate() {
75
+ inner.invalidate();
76
+ },
77
+ render(width: number): string[] {
78
+ if (failed) return [badgeLine(width)];
79
+ try {
80
+ return [...inner.render(width), badgeLine(width)];
81
+ } catch {
82
+ failed = true;
83
+ options.onError();
84
+ return [badgeLine(width)];
85
+ }
86
+ },
163
87
  };
164
88
  }
@@ -52,8 +52,8 @@ export default function modesExtension(pi: ExtensionAPI): void {
52
52
  let backendReason = "not detected yet";
53
53
  let backendChecked = false;
54
54
  let workTools: string[] | undefined;
55
+ let footerStatus: "none" | "custom" | "fallback" = "none";
55
56
  let footerTui: { requestRender(): void } | undefined;
56
- let footerOwned = false;
57
57
  const wrappedCalls = new Set<string>();
58
58
 
59
59
  function configuredAskTools(): string[] {
@@ -74,60 +74,55 @@ export default function modesExtension(pi: ExtensionAPI): void {
74
74
  pi.appendEntry(STATE_CUSTOM_TYPE, { mode } satisfies PersistedState);
75
75
  }
76
76
 
77
- function updateStatus(ctx: ExtensionContext): void {
78
- ctx.ui.setStatus("modes", undefined);
79
-
80
- if (mode !== "ask") return;
77
+ function badgeSpec(): ModeBadge {
78
+ if (mode === "work") return { text: "▶ work", tone: "accent" };
79
+ if (backend) return { text: `⏸ ask · ro(${backend.id})`, tone: "warning" };
80
+ return { text: "⏸ ask · bash off", tone: "error" };
81
+ }
81
82
 
82
- // In the TUI the badge lives in the footer's first line; other modes have
83
- // no footer, so they keep the plain status line.
84
- if (ctx.mode === "tui") {
83
+ function updateStatus(ctx: ExtensionContext): void {
84
+ // The custom footer draws the badge itself, right-aligned.
85
+ if (footerStatus === "custom") {
86
+ ctx.ui.setStatus("modes", undefined);
85
87
  footerTui?.requestRender();
86
88
  return;
87
89
  }
88
-
89
90
  const badge = badgeSpec();
90
91
  ctx.ui.setStatus("modes", ctx.ui.theme.fg(badge.tone, badge.text));
91
92
  }
92
93
 
93
- function badgeSpec(): ModeBadge {
94
- if (backend) return { text: `⏸ ask · ro(${backend.id})`, tone: "warning" };
95
- return { text: "⏸ ask · bash off", tone: "error" };
96
- }
97
-
98
94
  /**
99
- * Ask mode replaces the built-in footer so the badge can sit on the
100
- * directory/session line instead of taking a row of its own. Work mode
101
- * restores pi's native footer.
95
+ * Install a footer that keeps pi's rendering and appends the badge aligned to
96
+ * the right edge. Falls back to the native footer plus a status line if pi's
97
+ * footer cannot be driven from here.
102
98
  */
103
- function installAskFooter(ctx: ExtensionContext): void {
104
- if (ctx.mode !== "tui" || footerOwned) return;
105
- try {
106
- ctx.ui.setFooter((tui, theme, footerData) => {
107
- footerTui = tui;
108
- const unsubscribe = footerData.onBranchChange(() => tui.requestRender());
109
- return {
110
- ...createModesFooter(ctx, theme, footerData, badgeSpec),
111
- dispose() {
112
- unsubscribe();
99
+ function installFooter(ctx: ExtensionContext): void {
100
+ if (ctx.mode !== "tui" || footerStatus === "custom") return;
101
+ const autoCompact = pi.getSettings().compaction?.enabled !== false;
102
+
103
+ ctx.ui.setFooter((tui, theme, footerData) => {
104
+ footerTui = tui;
105
+ const unsubscribe = footerData.onBranchChange(() => tui.requestRender());
106
+ return {
107
+ ...createModesFooter(ctx, theme, footerData, badgeSpec, {
108
+ autoCompact,
109
+ onError: () => {
110
+ footerStatus = "fallback";
113
111
  footerTui = undefined;
112
+ queueMicrotask(() => {
113
+ ctx.ui.setFooter(undefined);
114
+ updateStatus(ctx);
115
+ });
114
116
  },
115
- };
116
- });
117
- footerOwned = true;
118
- } catch {
119
- // Fall back to the status line rendered by updateStatus().
120
- ctx.ui.setFooter(undefined);
121
- footerOwned = false;
122
- footerTui = undefined;
123
- }
124
- }
125
-
126
- function removeAskFooter(ctx: ExtensionContext): void {
127
- if (ctx.mode !== "tui" || !footerOwned) return;
128
- ctx.ui.setFooter(undefined);
129
- footerOwned = false;
130
- footerTui = undefined;
117
+ }),
118
+ dispose() {
119
+ unsubscribe();
120
+ footerTui = undefined;
121
+ },
122
+ };
123
+ });
124
+ footerStatus = "custom";
125
+ ctx.ui.setStatus("modes", undefined);
131
126
  }
132
127
 
133
128
  function describe(): string {
@@ -163,7 +158,6 @@ export default function modesExtension(pi: ExtensionAPI): void {
163
158
  mode = "ask";
164
159
  workTools = pi.getActiveTools();
165
160
  pi.setActiveTools(askToolset());
166
- installAskFooter(ctx);
167
161
  updateStatus(ctx);
168
162
  persist();
169
163
  if (backend) {
@@ -177,7 +171,6 @@ export default function modesExtension(pi: ExtensionAPI): void {
177
171
  mode = "work";
178
172
  pi.setActiveTools(workTools ?? pi.getActiveTools());
179
173
  workTools = undefined;
180
- removeAskFooter(ctx);
181
174
  updateStatus(ctx);
182
175
  persist();
183
176
  ctx.ui.notify("Work mode: full access restored.", "info");
@@ -291,10 +284,8 @@ export default function modesExtension(pi: ExtensionAPI): void {
291
284
  await ensureBackend(ctx.cwd);
292
285
  workTools = pi.getActiveTools();
293
286
  pi.setActiveTools(askToolset());
294
- installAskFooter(ctx);
295
- } else {
296
- removeAskFooter(ctx);
297
287
  }
288
+ installFooter(ctx);
298
289
  updateStatus(ctx);
299
290
  });
300
291
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sassywares/pi-extensions",
3
- "version": "0.3.20",
3
+ "version": "0.3.22",
4
4
  "description": "A zesty power pack for pi: auto-titled sessions, real web access, clean handoffs, isolated subagents, and ask/work modes.",
5
5
  "type": "module",
6
6
  "license": "MIT",