@sassywares/pi-extensions 0.3.20 → 0.3.21

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, and the footer status line shows `⏸ ask · ro(sandbox-exec)`, `⏸ ask · bash off`, or `▶ work`.
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.
@@ -17,7 +17,6 @@
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";
21
20
 
22
21
  type Mode = "work" | "ask";
23
22
 
@@ -52,8 +51,6 @@ export default function modesExtension(pi: ExtensionAPI): void {
52
51
  let backendReason = "not detected yet";
53
52
  let backendChecked = false;
54
53
  let workTools: string[] | undefined;
55
- let footerTui: { requestRender(): void } | undefined;
56
- let footerOwned = false;
57
54
  const wrappedCalls = new Set<string>();
58
55
 
59
56
  function configuredAskTools(): string[] {
@@ -75,59 +72,12 @@ export default function modesExtension(pi: ExtensionAPI): void {
75
72
  }
76
73
 
77
74
  function updateStatus(ctx: ExtensionContext): void {
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();
75
+ if (mode === "work") {
76
+ ctx.ui.setStatus("modes", ctx.ui.theme.fg("accent", "▶ work"));
86
77
  return;
87
78
  }
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;
79
+ const text = backend ? `⏸ ask · ro(${backend.id})` : "⏸ ask · bash off";
80
+ ctx.ui.setStatus("modes", ctx.ui.theme.fg(backend ? "warning" : "error", text));
131
81
  }
132
82
 
133
83
  function describe(): string {
@@ -163,7 +113,6 @@ export default function modesExtension(pi: ExtensionAPI): void {
163
113
  mode = "ask";
164
114
  workTools = pi.getActiveTools();
165
115
  pi.setActiveTools(askToolset());
166
- installAskFooter(ctx);
167
116
  updateStatus(ctx);
168
117
  persist();
169
118
  if (backend) {
@@ -177,7 +126,6 @@ export default function modesExtension(pi: ExtensionAPI): void {
177
126
  mode = "work";
178
127
  pi.setActiveTools(workTools ?? pi.getActiveTools());
179
128
  workTools = undefined;
180
- removeAskFooter(ctx);
181
129
  updateStatus(ctx);
182
130
  persist();
183
131
  ctx.ui.notify("Work mode: full access restored.", "info");
@@ -291,9 +239,6 @@ export default function modesExtension(pi: ExtensionAPI): void {
291
239
  await ensureBackend(ctx.cwd);
292
240
  workTools = pi.getActiveTools();
293
241
  pi.setActiveTools(askToolset());
294
- installAskFooter(ctx);
295
- } else {
296
- removeAskFooter(ctx);
297
242
  }
298
243
  updateStatus(ctx);
299
244
  });
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.21",
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,7 +36,6 @@
36
36
  },
37
37
  "peerDependencies": {
38
38
  "@earendil-works/pi-coding-agent": "*",
39
- "@earendil-works/pi-tui": "*",
40
39
  "typebox": "*"
41
40
  },
42
41
  "pi": {
@@ -1,164 +0,0 @@
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
- }