@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 +1 -1
- package/extensions/modes/index.ts +4 -59
- package/package.json +1 -2
- package/extensions/modes/footer.ts +0 -164
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
}
|