@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 +1 -1
- package/extensions/modes/footer.ts +58 -134
- package/extensions/modes/index.ts +39 -48
- package/package.json +1 -1
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. 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
|
-
*
|
|
2
|
+
* Right-aligned mode badge.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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
|
|
130
|
-
|
|
131
|
-
const
|
|
132
|
-
|
|
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
|
-
|
|
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
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
83
|
-
//
|
|
84
|
-
if (
|
|
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
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
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
|
|
104
|
-
if (ctx.mode !== "tui" ||
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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.
|
|
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",
|