bermudis-pi-goodies 0.27.2 → 0.27.3

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
@@ -8,7 +8,6 @@ extensions. One entry point, thirteen independent features.
8
8
  | `copy-with-model` | `/copy-with-model` | Copy last assistant message to the clipboard in a code fence tagged with the model name. |
9
9
  | `copy-trajectory` | `/copy-trajectory [thinking]` | Copy the whole conversation (user + assistant text, tool calls stripped) to the clipboard; `thinking` also includes assistant thinking blocks. |
10
10
  | `name-with-ai` | `/name-with-ai [name]` | Generate a short session name from the first user message (or set one manually). |
11
- | `zed` | `/z` | Open Zed editor on the current working directory. |
12
11
  | `prefer-tools` | hook (no command) | Blocks legacy tools: `rm` → `trash`, bare `python`/`pip`/`pytest`/`mypy` → `uv`. On `glm-5.*` models it also blocks bash `rg` (they invent ripgrep flags) — the built-in grep tool still works. |
13
12
  | `keep-model-on-new` | hook (no command) | Keep the active model when `/new` starts a fresh session instead of reverting to pi's saved default model. |
14
13
  | `model-thinking` | `/model-thinking` | Per-model default thinking levels: save the current level as this model's default and get it back on every switch to that model, instead of pi's global default. |
@@ -26,7 +25,7 @@ extensions. One entry point, thirteen independent features.
26
25
  First install, pinned to a published version:
27
26
 
28
27
  ```bash
29
- pi install npm:bermudis-pi-goodies@0.27.2
28
+ pi install npm:bermudis-pi-goodies@0.27.3
30
29
  ```
31
30
 
32
31
  Picking up a new release on an existing install (`pi install` is not the
package/goodies.ts CHANGED
@@ -34,7 +34,6 @@ type FeatureName =
34
34
  | "copy-with-model"
35
35
  | "copy-trajectory"
36
36
  | "name-with-ai"
37
- | "zed"
38
37
  | "prefer-tools"
39
38
  | "keep-model"
40
39
  | "model-thinking"
@@ -50,7 +49,6 @@ const FEATURES: FeatureName[] = [
50
49
  "copy-with-model",
51
50
  "copy-trajectory",
52
51
  "name-with-ai",
53
- "zed",
54
52
  "prefer-tools",
55
53
  "keep-model",
56
54
  "model-thinking",
package/index.ts CHANGED
@@ -6,7 +6,6 @@
6
6
  * - copy-with-model /copy-with-model copy last reply tagged with the model
7
7
  * - copy-trajectory /copy-trajectory copy the whole conversation (text only) to the clipboard
8
8
  * - name-with-ai /name-with-ai generate a session name via the model
9
- * - zed /z open Zed on cwd
10
9
  * - prefer-tools hook block legacy tools (trash/uv; blocks rg on glm-5.*)
11
10
  * - keep-model hook preserve the active model across /new
12
11
  * - model-thinking /model-thinking per-model default thinking levels, applied on switch
@@ -38,7 +37,6 @@ import tps from "./tps.ts";
38
37
  import vision from "./vision.ts";
39
38
  import side from "./side.ts";
40
39
  import nameWithAi from "./name-with-ai.ts";
41
- import zed from "./zed.ts";
42
40
  import preferTools from "./prefer-tools.ts";
43
41
  import goodies, { isEnabled } from "./goodies.ts";
44
42
  import { setCleanTuiActive } from "./clean-tui.ts";
@@ -51,7 +49,6 @@ export default function bermudisPiGoodies(pi: ExtensionAPI): void {
51
49
  if (isEnabled("copy-with-model")) copyWithModel(pi);
52
50
  if (isEnabled("copy-trajectory")) copyTrajectory(pi);
53
51
  if (isEnabled("name-with-ai")) nameWithAi(pi);
54
- if (isEnabled("zed")) zed(pi);
55
52
  if (isEnabled("prefer-tools")) preferTools(pi);
56
53
  if (isEnabled("keep-model")) keepModelOnNew(pi);
57
54
  if (isEnabled("model-thinking")) modelThinking(pi);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bermudis-pi-goodies",
3
- "version": "0.27.2",
3
+ "version": "0.27.3",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/bermudi/agent-extensions.git",
package/review.ts CHANGED
@@ -2125,9 +2125,9 @@ Instructions:
2125
2125
  endReviewInProgress = true;
2126
2126
  try {
2127
2127
  const choice = await ctx.ui.select("Finish review:", [
2128
- "Return only",
2129
2128
  "Return and fix findings",
2130
2129
  "Return and summarize",
2130
+ "Return only",
2131
2131
  ]);
2132
2132
 
2133
2133
  if (choice === undefined) {
package/side.ts CHANGED
@@ -15,7 +15,8 @@
15
15
  * - /side-exit navigates back to the recorded tip, restores the main model,
16
16
  * and hands the main agent the side conversation — also as an attributed
17
17
  * quote — with the mode chosen at exit: trajectory (full transcript),
18
- * summary (the side model summarizes), or nothing.
18
+ * summary (the side model summarizes, shown as a cancellable progress
19
+ * widget above the editor), or nothing.
19
20
  *
20
21
  * Durability: handoffs are appended as custom-message entries via
21
22
  * sendMessage({ triggerTurn: false }) while idle, which writes the session
@@ -636,10 +637,24 @@ function refreshSideLens(ctx: ExtensionContext): void {
636
637
  setSideLensTokens(tokens);
637
638
  }
638
639
 
640
+ /**
641
+ * The summarizer's completion resolved with stopReason "aborted" (partial
642
+ * text after an Esc) instead of rejecting — the caller's abort-signal check
643
+ * never fires on that path, so this sentinel carries the cancel out to the
644
+ * SummaryOutcome boundary below.
645
+ */
646
+ class SummaryAbortedError extends Error {
647
+ constructor() {
648
+ super("side model summary was aborted");
649
+ this.name = "SummaryAbortedError";
650
+ }
651
+ }
652
+
639
653
  async function summarizeDelta(
640
654
  ctx: ExtensionCommandContext,
641
655
  sideModel: ModelRef,
642
656
  transcript: string,
657
+ signal?: AbortSignal,
643
658
  ): Promise<string> {
644
659
  const model = ctx.modelRegistry.find(sideModel.provider, sideModel.id);
645
660
  if (!model) {
@@ -647,22 +662,116 @@ async function summarizeDelta(
647
662
  `${modelRef(sideModel)} is no longer available to summarize — use trajectory or nothing`,
648
663
  );
649
664
  }
650
- const response = await ctx.modelRegistry.complete(model, {
651
- messages: [
652
- {
653
- role: "user",
654
- timestamp: Date.now(),
655
- content: [
656
- { type: "text", text: `${SUMMARY_INSTRUCTIONS}\n\n${transcript}` },
657
- ],
658
- },
659
- ],
660
- });
665
+ const response = await ctx.modelRegistry.complete(
666
+ model,
667
+ {
668
+ messages: [
669
+ {
670
+ role: "user",
671
+ timestamp: Date.now(),
672
+ content: [
673
+ { type: "text", text: `${SUMMARY_INSTRUCTIONS}\n\n${transcript}` },
674
+ ],
675
+ },
676
+ ],
677
+ },
678
+ { signal },
679
+ );
661
680
  const text = extractTextParts(response.content).join("\n").trim();
681
+ // complete() RESOLVES failed and aborted calls (providers mark stopReason
682
+ // "error" or "aborted" and keep the partial text) rather than rejecting.
683
+ // Left alone, a truncated reply would reach the main agent as a finished
684
+ // summary. "aborted" becomes the cancel sentinel; "error" becomes a real
685
+ // failure so it rides the fail-closed exit path with the provider's message.
686
+ if (response.stopReason === "aborted") throw new SummaryAbortedError();
687
+ if (response.stopReason === "error") {
688
+ throw new Error(
689
+ `side model summary failed: ${response.errorMessage ?? "unknown error"}`,
690
+ );
691
+ }
662
692
  if (!text) throw new Error("side model returned an empty summary");
663
693
  return text;
664
694
  }
665
695
 
696
+ type SummaryOutcome =
697
+ | { ok: true; text: string }
698
+ | { ok: false; cancelled: true }
699
+ | { ok: false; cancelled: false; error: unknown };
700
+
701
+ const SUMMARIZE_WIDGET_KEY = "bermudis-pi-goodies.side-summarize";
702
+
703
+ /** Fallback budget when there is no TTY to measure (tests, piped output). */
704
+ const WIDGET_LINE_FALLBACK_WIDTH = 80;
705
+
706
+ /**
707
+ * Visible budget for the summarize progress line. Pi renders string-array
708
+ * widgets as Text(line, 1, 0) — one column of side padding — so the line
709
+ * wraps past (terminal width − 2). Without a TTY fall back to the
710
+ * historical 80 and never grow past it, so known-width terminals only ever
711
+ * truncate harder (same pattern as clean-tui's bashLineCap).
712
+ */
713
+ function widgetLineCap(): number {
714
+ const cols = process.stdout.columns;
715
+ if (typeof cols !== "number" || cols <= 0)
716
+ return WIDGET_LINE_FALLBACK_WIDTH;
717
+ return Math.max(20, Math.min(WIDGET_LINE_FALLBACK_WIDTH, cols - 2));
718
+ }
719
+
720
+ /** Widget lines render above the editor; stay single-line on narrow terminals. */
721
+ function truncateWidgetLine(text: string, max = widgetLineCap()): string {
722
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text;
723
+ }
724
+
725
+ /**
726
+ * Exit summarization with visible, cancellable UI: a progress widget above
727
+ * the editor for the whole call, Esc to abort. The picker closes the moment
728
+ * the mode is chosen; without these the UI then sits silent until the model
729
+ * answers, which reads as a freeze on long consultations.
730
+ *
731
+ * setWidget/onTerminalInput are optional-called because harness stubs and
732
+ * limited (non-interactive) contexts lack them; headless keeps the old
733
+ * no-feedback behavior, which is all it ever had.
734
+ */
735
+ export async function summarizeWithProgress(
736
+ ctx: ExtensionCommandContext,
737
+ sideModel: ModelRef,
738
+ transcript: string,
739
+ turnCount: number,
740
+ ): Promise<SummaryOutcome> {
741
+ const abort = new AbortController();
742
+ const turns = `${turnCount} ${turnCount === 1 ? "turn" : "turns"}`;
743
+ const line = truncateWidgetLine(
744
+ `⟳ summarizing side consultation (${turns}) with ${modelRef(sideModel)} — esc cancels`,
745
+ );
746
+ const unsubscribe = ctx.ui?.onTerminalInput?.((data) => {
747
+ // A bare Esc arrives as exactly "\x1b" (pi's stdin buffer flushes a lone
748
+ // ESC after its escape timeout); sequences like arrow keys arrive as
749
+ // longer chunks and pass through untouched.
750
+ if (data === "\x1b") {
751
+ abort.abort();
752
+ return { consume: true };
753
+ }
754
+ return undefined;
755
+ });
756
+ ctx.ui?.setWidget?.(SUMMARIZE_WIDGET_KEY, [line]);
757
+ try {
758
+ const text = await summarizeDelta(ctx, sideModel, transcript, abort.signal);
759
+ return { ok: true, text };
760
+ } catch (error) {
761
+ // Cancellation reaches here two ways: our own Esc (signal aborted, which
762
+ // may surface as a rejection or a resolved "aborted" message that
763
+ // summarizeDelta turns into the sentinel). Either way the user asked to
764
+ // stop, so it rides the stay-in-side-session path. Every other error,
765
+ // including a resolved "error" message, rides the fail-closed exit path.
766
+ if (abort.signal.aborted || error instanceof SummaryAbortedError)
767
+ return { ok: false, cancelled: true };
768
+ return { ok: false, cancelled: false, error };
769
+ } finally {
770
+ unsubscribe?.();
771
+ ctx.ui?.setWidget?.(SUMMARIZE_WIDGET_KEY, undefined);
772
+ }
773
+ }
774
+
666
775
  // ---------------------------------------------------------------------------
667
776
  // Extension entry point
668
777
  // ---------------------------------------------------------------------------
@@ -1024,20 +1133,28 @@ export default function side(pi: ExtensionAPI): void {
1024
1133
  // mode (fail-closed) rather than exiting empty-handed.
1025
1134
  let summaryText: string | undefined;
1026
1135
  if (mode === "summary" && deltaTurns.length > 0) {
1027
- try {
1028
- summaryText = await summarizeDelta(
1029
- ctx,
1030
- effectiveSide,
1031
- renderTrajectory(deltaTurns),
1032
- );
1033
- } catch (error) {
1034
- logGoodiesEvent({
1035
- type: "side_summary_failed",
1036
- error: describeError(error),
1037
- });
1038
- ctx.ui.notify(`side-exit: ${describeError(error)}`, "error");
1136
+ const result = await summarizeWithProgress(
1137
+ ctx,
1138
+ effectiveSide,
1139
+ renderTrajectory(deltaTurns),
1140
+ deltaTurns.length,
1141
+ );
1142
+ if (!result.ok) {
1143
+ if (result.cancelled) {
1144
+ ctx.ui.notify(
1145
+ "side-exit: summarization cancelled — still in the side session",
1146
+ "warning",
1147
+ );
1148
+ } else {
1149
+ logGoodiesEvent({
1150
+ type: "side_summary_failed",
1151
+ error: describeError(result.error),
1152
+ });
1153
+ ctx.ui.notify(`side-exit: ${describeError(result.error)}`, "error");
1154
+ }
1039
1155
  return;
1040
1156
  }
1157
+ summaryText = result.text;
1041
1158
  }
1042
1159
 
1043
1160
  const result = await ctx.navigateTree(marker.data.mainTipId, {
package/zed.ts DELETED
@@ -1,38 +0,0 @@
1
- /**
2
- * /z — Open Zed editor on cwd.
3
- */
4
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
- import { spawn } from "node:child_process";
6
-
7
- export default function (pi: ExtensionAPI) {
8
- // Arch packages the CLI as "zeditor"
9
- const zedBin = process.platform === "linux" ? "zeditor" : "zed";
10
-
11
- pi.registerCommand("z", {
12
- description: "Open Zed editor on cwd (new window)",
13
- handler: async (_args, ctx) => {
14
- const child = spawn(zedBin, ["--new", ctx.cwd], {
15
- detached: true,
16
- stdio: "ignore",
17
- });
18
- child.on("error", (err) => {
19
- const code = (err as NodeJS.ErrnoException).code;
20
- if (code === "ENOENT") {
21
- ctx.ui.notify(
22
- `Zed not found: \`${zedBin}\` is not installed or not on PATH`,
23
- "error",
24
- );
25
- } else {
26
- ctx.ui.notify(`Failed to open Zed: ${err.message}`, "error");
27
- }
28
- });
29
- // Only announce once the process has actually started — spawn()'s
30
- // 'error' event fires asynchronously, so notifying synchronously would
31
- // show "Opening Zed" before a missing-binary error lands.
32
- child.on("spawn", () => {
33
- ctx.ui.notify(`Opening Zed: ${ctx.cwd}`, "info");
34
- });
35
- child.unref();
36
- },
37
- });
38
- }