@bacnh85/pi-plan 0.2.0 → 0.3.0
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/index.ts +192 -22
- package/package.json +1 -1
package/index.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
|
1
2
|
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
3
|
import { isToolCallEventType, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
3
5
|
import { Type } from "typebox";
|
|
4
6
|
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
5
7
|
import os from "node:os";
|
|
@@ -181,22 +183,168 @@ export default function piPlanExtension(pi: ExtensionAPI): void {
|
|
|
181
183
|
let lastPlanStatus: PlanStatus | undefined;
|
|
182
184
|
let applyingStoredThinking = false;
|
|
183
185
|
|
|
184
|
-
function setStatus(ctx: ExtensionContext): void {
|
|
185
|
-
if (planModeEnabled) {
|
|
186
|
-
ctx.ui.setStatus(STATUS_KEY, ctx.ui.theme.fg("warning", `plan:${planThinking}`));
|
|
187
|
-
return;
|
|
188
|
-
}
|
|
189
|
-
if (executionMode) {
|
|
190
|
-
ctx.ui.setStatus(STATUS_KEY, ctx.ui.theme.fg("accent", `exec:${normalThinking}`));
|
|
191
|
-
return;
|
|
192
|
-
}
|
|
193
|
-
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
194
|
-
}
|
|
195
|
-
|
|
196
186
|
function clearPlanWidget(ctx: ExtensionContext): void {
|
|
197
187
|
ctx.ui.setWidget(STATUS_KEY, undefined);
|
|
198
188
|
}
|
|
199
|
-
|
|
189
|
+
function updateFooter(ctx: ExtensionContext): void {
|
|
190
|
+
if (planModeEnabled) {
|
|
191
|
+
ctx.ui.setFooter((tui, theme, footerData) => {
|
|
192
|
+
const unsub = footerData.onBranchChange(() => tui.requestRender());
|
|
193
|
+
const sanitizeStatusText = (text: string) =>
|
|
194
|
+
text.replace(/[\r\n\t]/g, " ").replace(/ +/g, " ").trim();
|
|
195
|
+
return {
|
|
196
|
+
dispose: unsub,
|
|
197
|
+
invalidate() {},
|
|
198
|
+
render(width: number): string[] {
|
|
199
|
+
const lines: string[] = [];
|
|
200
|
+
|
|
201
|
+
// Line 1: cwd (branch) • session name
|
|
202
|
+
const home = process.env.HOME || process.env.USERPROFILE;
|
|
203
|
+
const resolvedCwd = path.resolve(ctx.sessionManager.getCwd());
|
|
204
|
+
let pwd = resolvedCwd;
|
|
205
|
+
if (home) {
|
|
206
|
+
const resolvedHome = path.resolve(home);
|
|
207
|
+
const rel = path.relative(resolvedHome, resolvedCwd);
|
|
208
|
+
if (rel === "" || (!rel.startsWith(`..`) && !path.isAbsolute(rel))) {
|
|
209
|
+
pwd = rel === "" ? "~" : `~${path.sep}${rel}`;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
const branch = footerData.getGitBranch();
|
|
213
|
+
if (branch) pwd = `${pwd} (${branch})`;
|
|
214
|
+
const sessionName = ctx.sessionManager.getSessionName();
|
|
215
|
+
if (sessionName) pwd = `${pwd} • ${sessionName}`;
|
|
216
|
+
lines.push(truncateToWidth(theme.fg("dim", pwd), width, theme.fg("dim", "...")));
|
|
217
|
+
|
|
218
|
+
// Calculate cumulative usage from ALL session entries
|
|
219
|
+
const fmtTokens = (n: number) =>
|
|
220
|
+
n < 1000 ? `${n}` :
|
|
221
|
+
n < 10000 ? `${(n / 1000).toFixed(1)}k` :
|
|
222
|
+
n < 1000000 ? `${Math.round(n / 1000)}k` :
|
|
223
|
+
n < 10000000 ? `${(n / 1000000).toFixed(1)}M` :
|
|
224
|
+
`${Math.round(n / 1000000)}M`;
|
|
225
|
+
|
|
226
|
+
let totalInput = 0, totalOutput = 0, totalCacheRead = 0, totalCacheWrite = 0, totalCost = 0;
|
|
227
|
+
let latestCacheHitRate: number | undefined;
|
|
228
|
+
for (const e of ctx.sessionManager.getEntries()) {
|
|
229
|
+
if (e.type === "message" && e.message.role === "assistant") {
|
|
230
|
+
const m = e.message as AssistantMessage;
|
|
231
|
+
totalInput += m.usage.input;
|
|
232
|
+
totalOutput += m.usage.output;
|
|
233
|
+
totalCacheRead += m.usage.cacheRead;
|
|
234
|
+
totalCacheWrite += m.usage.cacheWrite;
|
|
235
|
+
totalCost += m.usage.cost.total;
|
|
236
|
+
const latestPromptTokens = m.usage.input + m.usage.cacheRead + m.usage.cacheWrite;
|
|
237
|
+
latestCacheHitRate = latestPromptTokens > 0
|
|
238
|
+
? (m.usage.cacheRead / latestPromptTokens) * 100
|
|
239
|
+
: undefined;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const contextUsage = ctx.getContextUsage();
|
|
244
|
+
const contextWindow = contextUsage?.contextWindow ?? ctx.model?.contextWindow ?? 0;
|
|
245
|
+
const contextPercentValue = contextUsage?.percent ?? 0;
|
|
246
|
+
const contextPercent = contextUsage?.percent !== null
|
|
247
|
+
? contextPercentValue.toFixed(1)
|
|
248
|
+
: "?";
|
|
249
|
+
|
|
250
|
+
// Build left stats
|
|
251
|
+
const parts: string[] = [];
|
|
252
|
+
if (totalInput) parts.push(`↑${fmtTokens(totalInput)}`);
|
|
253
|
+
if (totalOutput) parts.push(`↓${fmtTokens(totalOutput)}`);
|
|
254
|
+
if (totalCacheRead) parts.push(`R${fmtTokens(totalCacheRead)}`);
|
|
255
|
+
if (totalCacheWrite) parts.push(`W${fmtTokens(totalCacheWrite)}`);
|
|
256
|
+
if ((totalCacheRead > 0 || totalCacheWrite > 0) && latestCacheHitRate !== undefined) {
|
|
257
|
+
parts.push(`CH${latestCacheHitRate.toFixed(1)}%`);
|
|
258
|
+
}
|
|
259
|
+
const usingSubscription = ctx.model ? ctx.modelRegistry.isUsingOAuth(ctx.model) : false;
|
|
260
|
+
if (totalCost || usingSubscription) {
|
|
261
|
+
parts.push(`$${totalCost.toFixed(3)}${usingSubscription ? " (sub)" : ""}`);
|
|
262
|
+
}
|
|
263
|
+
const autoIndicator = " (auto)";
|
|
264
|
+
const contextPercentDisplay = contextPercent === "?"
|
|
265
|
+
? `?/${fmtTokens(contextWindow)}${autoIndicator}`
|
|
266
|
+
: `${contextPercent}%/${fmtTokens(contextWindow)}${autoIndicator}`;
|
|
267
|
+
let contextPercentStr: string;
|
|
268
|
+
if (contextPercentValue > 90) {
|
|
269
|
+
contextPercentStr = theme.fg("error", contextPercentDisplay);
|
|
270
|
+
} else if (contextPercentValue > 70) {
|
|
271
|
+
contextPercentStr = theme.fg("warning", contextPercentDisplay);
|
|
272
|
+
} else {
|
|
273
|
+
contextPercentStr = contextPercentDisplay;
|
|
274
|
+
}
|
|
275
|
+
parts.push(contextPercentStr);
|
|
276
|
+
|
|
277
|
+
const statsLeft = parts.join(" ");
|
|
278
|
+
const statsLeftWidth = visibleWidth(statsLeft);
|
|
279
|
+
|
|
280
|
+
// Right side: model info (dimmed) + highlighted plan mode indicator
|
|
281
|
+
const modelName = ctx.model?.id || "no-model";
|
|
282
|
+
let rightModelInfo = modelName;
|
|
283
|
+
if (ctx.model?.reasoning) {
|
|
284
|
+
const thinkingLevel = pi.getThinkingLevel() || "off";
|
|
285
|
+
rightModelInfo = thinkingLevel === "off"
|
|
286
|
+
? `${modelName} • thinking off`
|
|
287
|
+
: `${modelName} • ${thinkingLevel}`;
|
|
288
|
+
}
|
|
289
|
+
if (footerData.getAvailableProviderCount() > 1 && ctx.model) {
|
|
290
|
+
const withProvider = `(${ctx.model.provider}) ${rightModelInfo}`;
|
|
291
|
+
const minPad = 2;
|
|
292
|
+
if (statsLeftWidth + minPad + visibleWidth(withProvider) + visibleWidth(" | Plan mode") <= width) {
|
|
293
|
+
rightModelInfo = withProvider;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const planText = " | Plan mode";
|
|
298
|
+
const rightModelWidth = visibleWidth(rightModelInfo);
|
|
299
|
+
const planWidth = visibleWidth(planText);
|
|
300
|
+
const minPadding = 2;
|
|
301
|
+
const totalNeeded = statsLeftWidth + minPadding + rightModelWidth + planWidth;
|
|
302
|
+
|
|
303
|
+
let statsLine: string;
|
|
304
|
+
if (totalNeeded <= width) {
|
|
305
|
+
const padding = " ".repeat(width - statsLeftWidth - rightModelWidth - planWidth);
|
|
306
|
+
// Dim statsLeft and model info separately (statsLeft may have color codes from context %)
|
|
307
|
+
statsLine = theme.fg("dim", statsLeft) + theme.fg("dim", padding + rightModelInfo) + theme.fg("warning", planText);
|
|
308
|
+
} else {
|
|
309
|
+
const availableForRight = width - statsLeftWidth - minPadding;
|
|
310
|
+
if (availableForRight > 0) {
|
|
311
|
+
if (availableForRight <= planWidth) {
|
|
312
|
+
// Very tight: show only (part of) plan indicator
|
|
313
|
+
const trimmed = truncateToWidth(theme.fg("warning", planText), availableForRight, "");
|
|
314
|
+
const pad = " ".repeat(Math.max(0, width - statsLeftWidth - visibleWidth(trimmed)));
|
|
315
|
+
statsLine = theme.fg("dim", statsLeft) + pad + trimmed;
|
|
316
|
+
} else {
|
|
317
|
+
// Show model info (truncated if needed) + plan indicator
|
|
318
|
+
const availModel = availableForRight - planWidth;
|
|
319
|
+
const modelDisplay = truncateToWidth(rightModelInfo, availModel, "");
|
|
320
|
+
const pad = " ".repeat(Math.max(0, width - statsLeftWidth - visibleWidth(modelDisplay) - planWidth));
|
|
321
|
+
statsLine = theme.fg("dim", statsLeft) + theme.fg("dim", pad + modelDisplay) + theme.fg("warning", planText);
|
|
322
|
+
}
|
|
323
|
+
} else {
|
|
324
|
+
statsLine = theme.fg("dim", statsLeft);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
lines.push(statsLine);
|
|
329
|
+
|
|
330
|
+
// Line 3: extension statuses (from setStatus calls by other extensions)
|
|
331
|
+
const extensionStatuses = footerData.getExtensionStatuses();
|
|
332
|
+
if (extensionStatuses.size > 0) {
|
|
333
|
+
const sortedStatuses = Array.from(extensionStatuses.entries())
|
|
334
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
335
|
+
.map(([, text]) => sanitizeStatusText(text));
|
|
336
|
+
const statusLine = sortedStatuses.join(" ");
|
|
337
|
+
lines.push(truncateToWidth(statusLine, width, theme.fg("dim", "...")));
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return lines;
|
|
341
|
+
},
|
|
342
|
+
};
|
|
343
|
+
});
|
|
344
|
+
} else {
|
|
345
|
+
ctx.ui.setFooter(undefined);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
200
348
|
function persistState(): void {
|
|
201
349
|
pi.appendEntry("pi-plan", {
|
|
202
350
|
enabled: planModeEnabled,
|
|
@@ -248,7 +396,7 @@ export default function piPlanExtension(pi: ExtensionAPI): void {
|
|
|
248
396
|
if (key && preferences) {
|
|
249
397
|
preferences.perModel[key] = { planThinking, normalThinking };
|
|
250
398
|
}
|
|
251
|
-
|
|
399
|
+
updateFooter(ctx);
|
|
252
400
|
persistState();
|
|
253
401
|
persistPreferences();
|
|
254
402
|
}
|
|
@@ -258,10 +406,10 @@ export default function piPlanExtension(pi: ExtensionAPI): void {
|
|
|
258
406
|
executionMode = false;
|
|
259
407
|
enablePlanTools();
|
|
260
408
|
applyThinking(planThinking);
|
|
261
|
-
|
|
409
|
+
updateFooter(ctx);
|
|
262
410
|
clearPlanWidget(ctx);
|
|
263
411
|
persistState();
|
|
264
|
-
ctx.ui.notify(`Plan mode enabled.
|
|
412
|
+
ctx.ui.notify(`Plan mode enabled. Plans will be written to ${PLAN_DIR}/`, "info");
|
|
265
413
|
}
|
|
266
414
|
|
|
267
415
|
function leavePlanMode(ctx: ExtensionContext, restoreThinking = true): void {
|
|
@@ -269,10 +417,10 @@ export default function piPlanExtension(pi: ExtensionAPI): void {
|
|
|
269
417
|
executionMode = false;
|
|
270
418
|
restoreTools();
|
|
271
419
|
if (restoreThinking) applyThinking(normalThinking);
|
|
272
|
-
|
|
420
|
+
updateFooter(ctx);
|
|
273
421
|
clearPlanWidget(ctx);
|
|
274
422
|
persistState();
|
|
275
|
-
ctx.ui.notify(
|
|
423
|
+
ctx.ui.notify("Plan mode disabled.", "info");
|
|
276
424
|
}
|
|
277
425
|
|
|
278
426
|
async function handlePlanCommand(args: string, ctx: ExtensionContext): Promise<void> {
|
|
@@ -290,7 +438,7 @@ export default function piPlanExtension(pi: ExtensionAPI): void {
|
|
|
290
438
|
lastPlanStatus = "approved";
|
|
291
439
|
restoreTools();
|
|
292
440
|
applyThinking(normalThinking);
|
|
293
|
-
|
|
441
|
+
updateFooter(ctx);
|
|
294
442
|
clearPlanWidget(ctx);
|
|
295
443
|
persistState();
|
|
296
444
|
persistPreferences();
|
|
@@ -500,7 +648,7 @@ export default function piPlanExtension(pi: ExtensionAPI): void {
|
|
|
500
648
|
} else if (executionMode) {
|
|
501
649
|
applyThinking(normalThinking);
|
|
502
650
|
}
|
|
503
|
-
|
|
651
|
+
updateFooter(ctx);
|
|
504
652
|
clearPlanWidget(ctx);
|
|
505
653
|
});
|
|
506
654
|
|
|
@@ -519,7 +667,7 @@ export default function piPlanExtension(pi: ExtensionAPI): void {
|
|
|
519
667
|
applyThinking(normalThinking);
|
|
520
668
|
}
|
|
521
669
|
}
|
|
522
|
-
|
|
670
|
+
updateFooter(ctx);
|
|
523
671
|
persistState();
|
|
524
672
|
});
|
|
525
673
|
|
|
@@ -540,6 +688,28 @@ export default function piPlanExtension(pi: ExtensionAPI): void {
|
|
|
540
688
|
}
|
|
541
689
|
});
|
|
542
690
|
|
|
691
|
+
pi.on("context", async (event) => {
|
|
692
|
+
// When not in plan mode or execution mode, filter out stale context messages
|
|
693
|
+
if (!planModeEnabled && !executionMode) {
|
|
694
|
+
return {
|
|
695
|
+
messages: event.messages.filter((m) => {
|
|
696
|
+
const msg = m as { customType?: string };
|
|
697
|
+
return msg.customType !== "pi-plan-context" && msg.customType !== "pi-plan-execution-context";
|
|
698
|
+
}),
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
// In execution mode, filter out stale plan mode context messages
|
|
702
|
+
if (executionMode && !planModeEnabled) {
|
|
703
|
+
return {
|
|
704
|
+
messages: event.messages.filter((m) => {
|
|
705
|
+
const msg = m as { customType?: string };
|
|
706
|
+
return msg.customType !== "pi-plan-context";
|
|
707
|
+
}),
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
// In plan mode, let all messages through
|
|
711
|
+
});
|
|
712
|
+
|
|
543
713
|
pi.on("before_agent_start", async (_event, ctx) => {
|
|
544
714
|
if (planModeEnabled) {
|
|
545
715
|
const relativePlan = lastPlanPath ? relativeToCwd(ctx.cwd, lastPlanPath) : `${PLAN_DIR}/<timestamp>-<title>.md`;
|
|
@@ -589,7 +759,7 @@ export default function piPlanExtension(pi: ExtensionAPI): void {
|
|
|
589
759
|
lastPlanStatus = "approved";
|
|
590
760
|
restoreTools();
|
|
591
761
|
applyThinking(normalThinking);
|
|
592
|
-
|
|
762
|
+
updateFooter(ctx);
|
|
593
763
|
clearPlanWidget(ctx);
|
|
594
764
|
persistState();
|
|
595
765
|
persistPreferences();
|