@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.
Files changed (2) hide show
  1. package/index.ts +192 -22
  2. 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
- setStatus(ctx);
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
- setStatus(ctx);
409
+ updateFooter(ctx);
262
410
  clearPlanWidget(ctx);
263
411
  persistState();
264
- ctx.ui.notify(`Plan mode enabled. Thinking=${planThinking}. Plans will be written to ${PLAN_DIR}/`, "info");
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
- setStatus(ctx);
420
+ updateFooter(ctx);
273
421
  clearPlanWidget(ctx);
274
422
  persistState();
275
- ctx.ui.notify(`Plan mode disabled. Thinking=${pi.getThinkingLevel()}.`, "info");
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
- setStatus(ctx);
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
- setStatus(ctx);
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
- setStatus(ctx);
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
- setStatus(ctx);
762
+ updateFooter(ctx);
593
763
  clearPlanWidget(ctx);
594
764
  persistState();
595
765
  persistPreferences();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bacnh85/pi-plan",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Pi extension that adds a plan mode with workspace markdown plans and thinking-level presets.",
5
5
  "license": "MIT",
6
6
  "publishConfig": {