@sassywares/pi-extensions 0.3.19 → 0.3.20

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
@@ -149,7 +149,9 @@ Ask mode enforces read-only access on two levels:
149
149
 
150
150
  Writes do not fail quietly and they are not matched by pattern. `rm`, `sed -i`, `tee`, `>` redirection, `git checkout`, and a nested `bash -c` all fail at the OS level with `Operation not permitted`, while `git log`, `git diff`, `rg`, and network calls keep working. `/dev` and the temp directory stay writable so ordinary tools behave.
151
151
 
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`. `/modes` shows the verdict, and the footer shows `⏸ ask · ro(sandbox-exec)`, `⏸ ask · bash off`, or `▶ work`.
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
+
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.
153
155
 
154
156
  - `--ask` starts a session in ask mode; the mode persists across resume.
155
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.
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Ask-mode footer.
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.
9
+ *
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.
13
+ */
14
+
15
+ import { homedir } from "node:os";
16
+ import { relative, resolve, sep } from "node:path";
17
+ import type { Component } from "@earendil-works/pi-tui";
18
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
19
+ import type { ExtensionContext, ReadonlyFooterDataProvider, Theme } from "@earendil-works/pi-coding-agent";
20
+
21
+ export interface ModeBadge {
22
+ 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`;
46
+ }
47
+
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, "…");
81
+ }
82
+
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;
88
+ }
89
+
90
+ export function createModesFooter(
91
+ ctx: ExtensionContext,
92
+ theme: Theme,
93
+ footerData: ReadonlyFooterDataProvider,
94
+ badge: () => ModeBadge,
95
+ ): 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}`;
127
+
128
+ 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;
158
+ }
159
+
160
+ return {
161
+ invalidate() {},
162
+ render,
163
+ };
164
+ }
@@ -17,6 +17,7 @@
17
17
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
18
18
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
19
19
  import { createWrappedOperations, detectBackend, type SandboxBackend } from "./backends.ts";
20
+ import { createModesFooter, type ModeBadge } from "./footer.ts";
20
21
 
21
22
  type Mode = "work" | "ask";
22
23
 
@@ -51,6 +52,8 @@ export default function modesExtension(pi: ExtensionAPI): void {
51
52
  let backendReason = "not detected yet";
52
53
  let backendChecked = false;
53
54
  let workTools: string[] | undefined;
55
+ let footerTui: { requestRender(): void } | undefined;
56
+ let footerOwned = false;
54
57
  const wrappedCalls = new Set<string>();
55
58
 
56
59
  function configuredAskTools(): string[] {
@@ -72,12 +75,59 @@ export default function modesExtension(pi: ExtensionAPI): void {
72
75
  }
73
76
 
74
77
  function updateStatus(ctx: ExtensionContext): void {
75
- if (mode === "work") {
76
- ctx.ui.setStatus("modes", ctx.ui.theme.fg("accent", "▶ work"));
78
+ ctx.ui.setStatus("modes", undefined);
79
+
80
+ if (mode !== "ask") return;
81
+
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") {
85
+ footerTui?.requestRender();
77
86
  return;
78
87
  }
79
- const text = backend ? `⏸ ask · ro(${backend.id})` : "⏸ ask · bash off";
80
- ctx.ui.setStatus("modes", ctx.ui.theme.fg(backend ? "warning" : "error", text));
88
+
89
+ const badge = badgeSpec();
90
+ ctx.ui.setStatus("modes", ctx.ui.theme.fg(badge.tone, badge.text));
91
+ }
92
+
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
+ /**
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.
102
+ */
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();
113
+ footerTui = undefined;
114
+ },
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;
81
131
  }
82
132
 
83
133
  function describe(): string {
@@ -113,6 +163,7 @@ export default function modesExtension(pi: ExtensionAPI): void {
113
163
  mode = "ask";
114
164
  workTools = pi.getActiveTools();
115
165
  pi.setActiveTools(askToolset());
166
+ installAskFooter(ctx);
116
167
  updateStatus(ctx);
117
168
  persist();
118
169
  if (backend) {
@@ -126,6 +177,7 @@ export default function modesExtension(pi: ExtensionAPI): void {
126
177
  mode = "work";
127
178
  pi.setActiveTools(workTools ?? pi.getActiveTools());
128
179
  workTools = undefined;
180
+ removeAskFooter(ctx);
129
181
  updateStatus(ctx);
130
182
  persist();
131
183
  ctx.ui.notify("Work mode: full access restored.", "info");
@@ -239,6 +291,9 @@ export default function modesExtension(pi: ExtensionAPI): void {
239
291
  await ensureBackend(ctx.cwd);
240
292
  workTools = pi.getActiveTools();
241
293
  pi.setActiveTools(askToolset());
294
+ installAskFooter(ctx);
295
+ } else {
296
+ removeAskFooter(ctx);
242
297
  }
243
298
  updateStatus(ctx);
244
299
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sassywares/pi-extensions",
3
- "version": "0.3.19",
3
+ "version": "0.3.20",
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",
@@ -36,6 +36,7 @@
36
36
  },
37
37
  "peerDependencies": {
38
38
  "@earendil-works/pi-coding-agent": "*",
39
+ "@earendil-works/pi-tui": "*",
39
40
  "typebox": "*"
40
41
  },
41
42
  "pi": {