pi-spark 0.23.1 → 0.25.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/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.25.0](https://github.com/zlliang/pi-spark/compare/v0.24.0...v0.25.0) (2026-09-14)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * refine recap lifecycle and feature event handling ([4bb7474](https://github.com/zlliang/pi-spark/commit/4bb7474b3d04a6d92179b1873afdd6155e5e0368))
9
+
10
+ ## [0.24.0](https://github.com/zlliang/pi-spark/compare/v0.23.1...v0.24.0) (2026-09-14)
11
+
12
+
13
+ ### ⚠ BREAKING CHANGES
14
+
15
+ * adopt Pi 0.85 extension APIs
16
+
17
+ ### Features
18
+
19
+ * adopt Pi 0.85 extension APIs ([2cd3208](https://github.com/zlliang/pi-spark/commit/2cd3208fe5bf8aade7828720ae0397da6836fe99))
20
+
21
+
22
+ ### Bug Fixes
23
+
24
+ * capitalize DeepSeek pricing labels ([fffcc40](https://github.com/zlliang/pi-spark/commit/fffcc40c8664056fb54d3c5fea5853f3805a2e4f))
25
+
3
26
  ## [0.23.1](https://github.com/zlliang/pi-spark/compare/v0.23.0...v0.23.1) (2026-09-12)
4
27
 
5
28
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-spark",
3
- "version": "0.23.1",
3
+ "version": "0.25.0",
4
4
  "description": "Pi package that polishes your daily experience",
5
5
  "keywords": [
6
6
  "pi-coding-agent",
@@ -44,17 +44,17 @@
44
44
  "zod": "^4.4.3"
45
45
  },
46
46
  "peerDependencies": {
47
- "@earendil-works/pi-agent-core": ">=0.84.0",
48
- "@earendil-works/pi-ai": ">=0.84.0",
49
- "@earendil-works/pi-coding-agent": ">=0.84.0",
50
- "@earendil-works/pi-tui": ">=0.84.0",
47
+ "@earendil-works/pi-agent-core": ">=0.85.0",
48
+ "@earendil-works/pi-ai": ">=0.85.0",
49
+ "@earendil-works/pi-coding-agent": ">=0.85.0",
50
+ "@earendil-works/pi-tui": ">=0.85.0",
51
51
  "typebox": "*"
52
52
  },
53
53
  "devDependencies": {
54
- "@earendil-works/pi-agent-core": ">=0.84.0",
55
- "@earendil-works/pi-ai": ">=0.84.0",
56
- "@earendil-works/pi-coding-agent": ">=0.84.0",
57
- "@earendil-works/pi-tui": ">=0.84.0",
54
+ "@earendil-works/pi-agent-core": ">=0.85.0",
55
+ "@earendil-works/pi-ai": ">=0.85.0",
56
+ "@earendil-works/pi-coding-agent": ">=0.85.0",
57
+ "@earendil-works/pi-tui": ">=0.85.0",
58
58
  "@types/node": "*",
59
59
  "typebox": "*",
60
60
  "typescript": "^7.0.2"
@@ -50,6 +50,10 @@ export function registerCredits(pi: ExtensionAPI): void {
50
50
  creditsManager?.refresh(ctx);
51
51
  });
52
52
 
53
+ pi.on("session_compact_failed", (_event, ctx) => {
54
+ creditsManager?.refresh(ctx);
55
+ });
56
+
53
57
  pi.on("session_before_tree", (event, ctx) => {
54
58
  if (!event.preparation.userWantsSummary) return;
55
59
 
@@ -25,34 +25,34 @@ export const deepseekProvider: CreditsProvider = {
25
25
  pricingRules: [
26
26
  {
27
27
  model: "deepseek-flash",
28
- defaultCost: { input: 0.15, output: 0.6, cacheRead: 0.003, cacheWrite: 0, label: "off-peak" },
28
+ defaultCost: { input: 0.15, output: 0.6, cacheRead: 0.003, cacheWrite: 0, label: "Off-peak" },
29
29
  windows: [
30
- { start: "01:00", end: "04:00", days: [1, 2, 3, 4, 5], cost: { input: 0.3, output: 1.2, cacheRead: 0.006, cacheWrite: 0, label: "peak" } },
31
- { start: "06:00", end: "10:00", days: [1, 2, 3, 4, 5], cost: { input: 0.3, output: 1.2, cacheRead: 0.006, cacheWrite: 0, label: "peak" } },
30
+ { start: "01:00", end: "04:00", days: [1, 2, 3, 4, 5], cost: { input: 0.3, output: 1.2, cacheRead: 0.006, cacheWrite: 0, label: "Peak" } },
31
+ { start: "06:00", end: "10:00", days: [1, 2, 3, 4, 5], cost: { input: 0.3, output: 1.2, cacheRead: 0.006, cacheWrite: 0, label: "Peak" } },
32
32
  ],
33
33
  },
34
34
  {
35
35
  model: "deepseek-v4-flash",
36
- defaultCost: { input: 0.15, output: 0.6, cacheRead: 0.003, cacheWrite: 0, label: "off-peak" },
36
+ defaultCost: { input: 0.15, output: 0.6, cacheRead: 0.003, cacheWrite: 0, label: "Off-peak" },
37
37
  windows: [
38
- { start: "01:00", end: "04:00", days: [1, 2, 3, 4, 5], cost: { input: 0.3, output: 1.2, cacheRead: 0.006, cacheWrite: 0, label: "peak" } },
39
- { start: "06:00", end: "10:00", days: [1, 2, 3, 4, 5], cost: { input: 0.3, output: 1.2, cacheRead: 0.006, cacheWrite: 0, label: "peak" } },
38
+ { start: "01:00", end: "04:00", days: [1, 2, 3, 4, 5], cost: { input: 0.3, output: 1.2, cacheRead: 0.006, cacheWrite: 0, label: "Peak" } },
39
+ { start: "06:00", end: "10:00", days: [1, 2, 3, 4, 5], cost: { input: 0.3, output: 1.2, cacheRead: 0.006, cacheWrite: 0, label: "Peak" } },
40
40
  ],
41
41
  },
42
42
  {
43
43
  model: "deepseek-v4-flash-vision-exp",
44
- defaultCost: { input: 0.15, output: 0.6, cacheRead: 0.003, cacheWrite: 0, label: "off-peak" },
44
+ defaultCost: { input: 0.15, output: 0.6, cacheRead: 0.003, cacheWrite: 0, label: "Off-peak" },
45
45
  windows: [
46
- { start: "01:00", end: "04:00", days: [1, 2, 3, 4, 5], cost: { input: 0.3, output: 1.2, cacheRead: 0.006, cacheWrite: 0, label: "peak" } },
47
- { start: "06:00", end: "10:00", days: [1, 2, 3, 4, 5], cost: { input: 0.3, output: 1.2, cacheRead: 0.006, cacheWrite: 0, label: "peak" } },
46
+ { start: "01:00", end: "04:00", days: [1, 2, 3, 4, 5], cost: { input: 0.3, output: 1.2, cacheRead: 0.006, cacheWrite: 0, label: "Peak" } },
47
+ { start: "06:00", end: "10:00", days: [1, 2, 3, 4, 5], cost: { input: 0.3, output: 1.2, cacheRead: 0.006, cacheWrite: 0, label: "Peak" } },
48
48
  ],
49
49
  },
50
50
  {
51
51
  model: "deepseek-v4-pro",
52
- defaultCost: { input: 0.66, output: 1.98, cacheRead: 0.022, cacheWrite: 0, label: "off-peak" },
52
+ defaultCost: { input: 0.66, output: 1.98, cacheRead: 0.022, cacheWrite: 0, label: "Off-peak" },
53
53
  windows: [
54
- { start: "01:00", end: "04:00", days: [1, 2, 3, 4, 5], cost: { input: 1.32, output: 3.96, cacheRead: 0.044, cacheWrite: 0, label: "peak" } },
55
- { start: "06:00", end: "10:00", days: [1, 2, 3, 4, 5], cost: { input: 1.32, output: 3.96, cacheRead: 0.044, cacheWrite: 0, label: "peak" } },
54
+ { start: "01:00", end: "04:00", days: [1, 2, 3, 4, 5], cost: { input: 1.32, output: 3.96, cacheRead: 0.044, cacheWrite: 0, label: "Peak" } },
55
+ { start: "06:00", end: "10:00", days: [1, 2, 3, 4, 5], cost: { input: 1.32, output: 3.96, cacheRead: 0.044, cacheWrite: 0, label: "Peak" } },
56
56
  ],
57
57
  },
58
58
  ],
@@ -5,7 +5,7 @@ import { Loader } from "../../../components/loader";
5
5
  import { renderCredits } from "../status";
6
6
 
7
7
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
8
- import type { SelectItem } from "@earendil-works/pi-tui";
8
+ import type { SelectItem, TuiMouseEvent } from "@earendil-works/pi-tui";
9
9
  import type { BankedRateLimitReset, RateLimitResetCreditsResponse } from "./openai-codex";
10
10
  import type { Credits } from "../types";
11
11
 
@@ -113,6 +113,7 @@ export async function showCodexResetsSelector(ctx: ExtensionContext, load: (sign
113
113
  finish(null);
114
114
  }
115
115
  },
116
+ handleMouse: (event: TuiMouseEvent) => container.handleMouse(event),
116
117
  };
117
118
  });
118
119
 
@@ -170,6 +171,7 @@ export async function confirmCodexReset(ctx: ExtensionContext, credit: BankedRat
170
171
  selectList.handleInput(data);
171
172
  tui.requestRender();
172
173
  },
174
+ handleMouse: (event: TuiMouseEvent) => container.handleMouse(event),
173
175
  };
174
176
  });
175
177
 
@@ -1,6 +1,7 @@
1
- import { hyperlink } from "@earendil-works/pi-tui";
2
1
  import prettyMilliseconds from "pretty-ms";
3
2
 
3
+ import { formatLink } from "../../utils/format";
4
+
4
5
  import type { Theme } from "@earendil-works/pi-coding-agent";
5
6
  import type { Credits, CreditsLane } from "./types";
6
7
 
@@ -15,7 +16,7 @@ export function renderCredits(theme: Theme, label: string, credits: Credits, lin
15
16
  const suffix = credits.suffix ? ` ${theme.fg("dim", credits.suffix)}` : "";
16
17
 
17
18
  const text = `${styledLabel}${value}${suffix}`;
18
- return link ? hyperlink(text, link) : text;
19
+ return link ? formatLink(text, link) : text;
19
20
  }
20
21
 
21
22
  export function renderError(theme: Theme, label: string, message: string): string {
@@ -43,27 +43,28 @@ class Editor extends CustomEditor {
43
43
  this.tui.requestRender();
44
44
  }
45
45
 
46
- render(width: number): string[] {
46
+ override render(width: number): string[] {
47
47
  // Pi reapplies the thinking-level border color when the level changes. Use the dim color here
48
- // while preserving Bash mode's dedicated border color.
48
+ // while preserving Bash mode's dedicated border color. Set it before rendering, since the
49
+ // border helpers below run inside `super.render()`.
49
50
  if (this.thinkingLevelIndicator === "model" && !this.getText().trimStart().startsWith("!")) {
50
51
  this.borderColor = (text) => this.ctx.ui.theme.fg("dim", text);
51
52
  }
52
53
 
53
- const lines = super.render(width);
54
- if (lines.length === 0) return lines;
54
+ return super.render(width);
55
+ }
55
56
 
56
- lines[0] = this.getTopBorder(width);
57
+ protected override renderTopBorder(width: number, hiddenLineCount: number): string {
58
+ return this.renderBorder(width, this.getTopLeft(hiddenLineCount), this.getTopRight());
59
+ }
57
60
 
58
- return lines;
61
+ protected override renderBottomBorder(width: number, hiddenLineCount: number): string {
62
+ return this.renderBorder(width, this.getScrollHint("↓", hiddenLineCount), "");
59
63
  }
60
64
 
61
- private getTopBorder(width: number): string {
65
+ private renderBorder(width: number, left: string, right: string): string {
62
66
  const theme = this.ctx.ui.theme;
63
67
 
64
- const left = this.getLeft();
65
- const right = this.getRight();
66
-
67
68
  return new SplitLine(left, right, {
68
69
  padding: 1,
69
70
  innerPadding: 1,
@@ -72,16 +73,17 @@ class Editor extends CustomEditor {
72
73
  }).render(width)[0];
73
74
  }
74
75
 
75
- private getLeft(): string {
76
+ private getTopLeft(hiddenLineCount: number): string {
76
77
  const theme = this.ctx.ui.theme;
77
78
 
78
79
  const spinner = this.spinner.getFrame();
79
80
  const workingMessage = this.workingMessage;
81
+ const workingText = [spinner ? theme.fg("accent", spinner) : undefined, workingMessage ? theme.fg("dim", workingMessage) : undefined].filter(Boolean).join(" ");
80
82
 
81
- return [theme.fg("accent", spinner), workingMessage ? theme.fg("dim", workingMessage) : undefined].filter(Boolean).join(" ");
83
+ return [this.getScrollHint("↑", hiddenLineCount), workingText].filter(Boolean).join(theme.fg("dim", " · "));
82
84
  }
83
85
 
84
- private getRight(): string {
86
+ private getTopRight(): string {
85
87
  const theme = this.ctx.ui.theme;
86
88
 
87
89
  const modelBeforeText = this.slots.modelBefore;
@@ -91,6 +93,13 @@ class Editor extends CustomEditor {
91
93
  return [modelBeforeText ? theme.fg("dim", modelBeforeText) : undefined, coloredModelText].filter(Boolean).join(theme.fg("dim", " · "));
92
94
  }
93
95
 
96
+ /** Renders Pi's hidden-line hint, which this editor keeps on the left side of the border. */
97
+ private getScrollHint(direction: "↑" | "↓", hiddenLineCount: number): string {
98
+ if (hiddenLineCount <= 0) return "";
99
+
100
+ return this.ctx.ui.theme.fg("dim", `${direction} ${hiddenLineCount} more`);
101
+ }
102
+
94
103
  private withThinkingLevelColor(text: string): string {
95
104
  const theme = this.ctx.ui.theme;
96
105
  const thinkingLevel = this.pi.getThinkingLevel();
@@ -159,11 +168,6 @@ export function registerEditor(pi: ExtensionAPI, events: EventCollector): void {
159
168
  editor?.setWorkingMessage("Running tools");
160
169
  });
161
170
 
162
- pi.on("tool_call", (event) => {
163
- runningToolCallIds.add(event.toolCallId);
164
- editor?.setWorkingMessage("Running tools");
165
- });
166
-
167
171
  pi.on("tool_execution_end", (event) => {
168
172
  runningToolCallIds.delete(event.toolCallId);
169
173
  editor?.setWorkingMessage(runningToolCallIds.size > 0 ? "Running tools" : undefined);
@@ -1,11 +1,11 @@
1
1
  import { homedir } from "node:os";
2
2
  import { resolve } from "node:path";
3
3
  import { pathToFileURL } from "node:url";
4
- import { hyperlink, Text } from "@earendil-works/pi-tui";
4
+ import { Text } from "@earendil-works/pi-tui";
5
5
 
6
6
  import { SplitLine } from "../../components/split-line";
7
7
  import { loadConfig } from "../../config";
8
- import { formatContextUsage, formatCwd, sanitizeText } from "../../utils/format";
8
+ import { formatContextUsage, formatCwd, formatLink, sanitizeText } from "../../utils/format";
9
9
  import { getEntryUsage } from "../../utils/usage";
10
10
 
11
11
  import type { ExtensionContext, ExtensionAPI, ReadonlyFooterDataProvider, Theme } from "@earendil-works/pi-coding-agent";
@@ -48,7 +48,7 @@ class FooterComponent implements Component {
48
48
  private getLeft(): string {
49
49
  const cwd = this.ctx.sessionManager.getCwd();
50
50
  const url = pathToFileURL(resolve(cwd));
51
- const cwdText = hyperlink(formatCwd(cwd, homedir()), url.href);
51
+ const cwdText = formatLink(formatCwd(cwd, homedir()), url.href);
52
52
  const branch = this.footerData.getGitBranch();
53
53
  const sessionName = this.ctx.sessionManager.getSessionName();
54
54
 
@@ -2,6 +2,7 @@ import { keyHint, rawKeyHint, DynamicBorder } from "@earendil-works/pi-coding-ag
2
2
  import { Box, Container, SelectList, Spacer, Text } from "@earendil-works/pi-tui";
3
3
 
4
4
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
5
+ import type { TuiMouseEvent } from "@earendil-works/pi-tui";
5
6
  import type { PresetManager } from "./manager";
6
7
 
7
8
  export async function showPresetSelector(ctx: ExtensionContext, presetManager: PresetManager): Promise<string | undefined> {
@@ -51,6 +52,7 @@ export async function showPresetSelector(ctx: ExtensionContext, presetManager: P
51
52
  selectList.handleInput(data);
52
53
  tui.requestRender();
53
54
  },
55
+ handleMouse: (event: TuiMouseEvent) => container.handleMouse(event),
54
56
  };
55
57
  });
56
58
 
@@ -1,5 +1,8 @@
1
1
  import parseDuration from "parse-duration";
2
2
  import * as z from "zod";
3
+ import { convertToLlm, sessionEntryToContextMessages } from "@earendil-works/pi-coding-agent";
4
+
5
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
6
 
4
7
  const MIN_IDLE_MS = 5_000;
5
8
 
@@ -17,105 +20,79 @@ export const idleTimeoutSchema = z
17
20
  })
18
21
  .pipe(z.number().min(MIN_IDLE_MS));
19
22
 
20
- type IdleTimeout = z.infer<typeof idleTimeoutSchema>;
21
- type IdleHash = string | number | boolean;
22
-
23
23
  const DEFAULT_IDLE_MS = 5 * 60 * 1000;
24
24
  const POLL_MS = 1_000;
25
25
 
26
- export class IdleListener<T> {
27
- private state: "active" | "watching" | "idle" = "active";
28
- private computeStateHash: (ctx: T) => IdleHash;
29
- private lastStateHash: IdleHash | undefined;
30
-
26
+ export class IdleListener {
27
+ private entered = false;
28
+ private lastEditorText: string | undefined;
29
+ private stableSince = 0;
31
30
  private idleMs: number;
32
- private pollMs: number;
33
- private idleTimer: ReturnType<typeof setTimeout> | undefined;
34
31
  private pollTimer: ReturnType<typeof setInterval> | undefined;
35
32
 
36
- private enterCallbacks: Set<(ctx: T) => void> = new Set();
37
- private wakeCallbacks: Set<(ctx: T) => void> = new Set();
33
+ private enterCallbacks: Set<(ctx: ExtensionContext) => void> = new Set();
34
+ private resetCallbacks: Set<(ctx: ExtensionContext) => void> = new Set();
38
35
 
39
- constructor(computeStateHash: (ctx: T) => IdleHash, idleMs: IdleTimeout = DEFAULT_IDLE_MS, pollMs: number = POLL_MS) {
40
- this.computeStateHash = computeStateHash;
36
+ constructor(idleMs: number = DEFAULT_IDLE_MS) {
41
37
  this.idleMs = idleMs;
42
- this.pollMs = pollMs;
43
38
  }
44
39
 
45
- on(event: "enter" | "wake", callback: (ctx: T) => void): () => void {
46
- const callbackSet = event === "enter" ? this.enterCallbacks : this.wakeCallbacks;
47
- callbackSet.add(callback);
40
+ on(event: "enter" | "reset", callback: (ctx: ExtensionContext) => void): () => void {
41
+ const callbacks = event === "enter" ? this.enterCallbacks : this.resetCallbacks;
42
+ callbacks.add(callback);
48
43
 
49
- return () => callbackSet.delete(callback);
44
+ return () => callbacks.delete(callback);
50
45
  }
51
46
 
52
- watch(ctx: T): void {
53
- if (this.state === "idle") return;
54
-
55
- this.stop();
56
- this.state = "watching";
57
-
58
- this.lastStateHash = this.computeStateHash(ctx);
59
- this.reset(ctx);
60
-
61
- this.pollTimer = setInterval(() => {
62
- const current = this.computeStateHash(ctx);
63
- if (current === this.lastStateHash) return;
47
+ /** Clears any recap and starts a fresh idle period, keeping observation active while Pi is busy. */
48
+ reset(ctx: ExtensionContext): void {
49
+ this.entered = false;
50
+ this.lastEditorText = undefined;
51
+ this.resetCallbacks.forEach((callback) => callback(ctx));
64
52
 
65
- this.lastStateHash = current;
66
- this.reset(ctx);
67
- }, this.pollMs);
68
- }
69
-
70
- enter(ctx: T): void {
71
- if (this.state === "idle") return;
72
-
73
- this.stop();
74
- this.state = "idle";
75
-
76
- this.enterCallbacks.forEach((callback) => callback(ctx));
77
- }
78
-
79
- /** Emits on every wake signal, even when the listener is already active. */
80
- wake(ctx: T): void {
81
- this.stop();
82
- this.state = "active";
83
-
84
- this.wakeCallbacks.forEach((callback) => callback(ctx));
53
+ this.check(ctx);
54
+ if (!this.pollTimer) {
55
+ this.pollTimer = setInterval(() => this.check(ctx), POLL_MS);
56
+ }
85
57
  }
86
58
 
87
59
  dispose(): void {
88
- this.stop();
89
- this.state = "active";
60
+ if (this.pollTimer) {
61
+ clearInterval(this.pollTimer);
62
+ this.pollTimer = undefined;
63
+ }
90
64
 
65
+ this.entered = false;
66
+ this.lastEditorText = undefined;
91
67
  this.enterCallbacks.clear();
92
- this.wakeCallbacks.clear();
93
- }
94
-
95
- private reset(ctx: T): void {
96
- this.clearIdleTimer();
97
- this.idleTimer = setTimeout(() => {
98
- this.idleTimer = undefined;
99
- this.enter(ctx);
100
- }, this.idleMs);
101
- }
102
-
103
- private stop(): void {
104
- this.clearIdleTimer();
105
- this.clearPollTimer();
68
+ this.resetCallbacks.clear();
106
69
  }
107
70
 
108
- private clearIdleTimer(): void {
109
- if (!this.idleTimer) return;
110
-
111
- clearTimeout(this.idleTimer);
112
- this.idleTimer = undefined;
113
- }
114
-
115
- private clearPollTimer(): void {
116
- if (!this.pollTimer) return;
117
-
118
- clearInterval(this.pollTimer);
119
- this.pollTimer = undefined;
71
+ private check(ctx: ExtensionContext): void {
72
+ const canEnter = ctx.isIdle() && ctx.sessionManager.buildContextEntries().some((entry) => convertToLlm(sessionEntryToContextMessages(entry)).length > 0);
73
+ if (!canEnter) {
74
+ this.lastEditorText = undefined;
75
+ if (this.entered) {
76
+ this.entered = false;
77
+ this.resetCallbacks.forEach((callback) => callback(ctx));
78
+ }
79
+ return;
80
+ }
81
+
82
+ // Keep the recap while the user edits; only a reset or Pi becoming busy clears it.
83
+ if (this.entered) return;
84
+
85
+ const editorText = ctx.ui.getEditorText();
86
+ const now = Date.now();
87
+ if (editorText !== this.lastEditorText) {
88
+ this.lastEditorText = editorText;
89
+ this.stableSince = now;
90
+ return;
91
+ }
92
+
93
+ if (now - this.stableSince >= this.idleMs) {
94
+ this.entered = true;
95
+ this.enterCallbacks.forEach((callback) => callback(ctx));
96
+ }
120
97
  }
121
98
  }
@@ -2,13 +2,13 @@ import { IdleListener } from "./idle";
2
2
  import { RecapManager } from "./manager";
3
3
  import { loadConfig } from "../../config";
4
4
 
5
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
5
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
6
 
7
7
  export function registerRecap(pi: ExtensionAPI): void {
8
- let idleListener: IdleListener<ExtensionContext> | undefined = undefined;
8
+ let idleListener: IdleListener | undefined = undefined;
9
9
  let recapManager: RecapManager | undefined = undefined;
10
10
 
11
- pi.on("session_start", (event, ctx) => {
11
+ pi.on("session_start", (_event, ctx) => {
12
12
  const config = loadConfig(ctx).recap;
13
13
  if (ctx.mode !== "tui" || !config) return;
14
14
 
@@ -16,48 +16,34 @@ export function registerRecap(pi: ExtensionAPI): void {
16
16
 
17
17
  pi.registerCommand("recap", {
18
18
  description: "Generate a short recap of the current session",
19
- handler: async () => await recapManager?.run(ctx, { force: true }),
19
+ handler: async (_args, ctx) => await recapManager?.run(ctx, { force: true }),
20
20
  });
21
21
 
22
- idleListener = new IdleListener((c) => `idle:${c.isIdle()};editor:${c.ui.getEditorText()}`, config.idle);
23
- idleListener.on("enter", (c) => recapManager?.run(c));
24
- idleListener.on("wake", (c) => recapManager?.clear(c));
22
+ idleListener = new IdleListener(config.idle);
23
+ idleListener.on("enter", (ctx) => recapManager?.run(ctx));
24
+ idleListener.on("reset", (ctx) => recapManager?.clear(ctx));
25
25
 
26
- if (event.reason === "resume" || event.reason === "fork") {
27
- idleListener.watch(ctx);
28
- }
26
+ idleListener.reset(ctx);
29
27
  });
30
28
 
31
29
  pi.on("input", (_event, ctx) => {
32
- idleListener?.wake(ctx);
30
+ idleListener?.reset(ctx);
33
31
  });
34
32
 
35
33
  pi.on("user_bash", (_event, ctx) => {
36
- idleListener?.wake(ctx);
34
+ idleListener?.reset(ctx);
37
35
  });
38
36
 
39
37
  pi.on("agent_start", (_event, ctx) => {
40
- idleListener?.wake(ctx);
38
+ idleListener?.reset(ctx);
41
39
  });
42
40
 
43
41
  pi.on("session_before_compact", (_event, ctx) => {
44
- idleListener?.wake(ctx);
42
+ idleListener?.reset(ctx);
45
43
  });
46
44
 
47
45
  pi.on("session_before_tree", (_event, ctx) => {
48
- idleListener?.wake(ctx);
49
- });
50
-
51
- pi.on("agent_settled", (_event, ctx) => {
52
- idleListener?.watch(ctx);
53
- });
54
-
55
- pi.on("session_compact", (_event, ctx) => {
56
- idleListener?.watch(ctx);
57
- });
58
-
59
- pi.on("session_tree", (_event, ctx) => {
60
- idleListener?.watch(ctx);
46
+ idleListener?.reset(ctx);
61
47
  });
62
48
 
63
49
  pi.on("session_shutdown", (_event, ctx) => {
@@ -1,4 +1,4 @@
1
- import { convertToLlm, serializeConversation } from "@earendil-works/pi-coding-agent";
1
+ import { convertToLlm, serializeConversation, sessionEntryToContextMessages } from "@earendil-works/pi-coding-agent";
2
2
 
3
3
  import { clearRecapWidget, setRecapLoadingWidget, setRecapTextWidget } from "./widget";
4
4
  import { sanitizeText } from "../../utils/format";
@@ -32,7 +32,7 @@ export class RecapManager {
32
32
  }
33
33
 
34
34
  async run(ctx: ExtensionContext, options: { force?: boolean } = {}): Promise<void> {
35
- if (this.active && !options.force) return;
35
+ if (!options.force && (this.active || this.inflight || !ctx.isIdle())) return;
36
36
 
37
37
  this.cancelInflight();
38
38
  const controller = new AbortController();
@@ -41,6 +41,7 @@ export class RecapManager {
41
41
  try {
42
42
  const modelSettings = await resolveModelSettings(ctx, this.config, "recap");
43
43
  if (controller.signal.aborted || this.inflight !== controller || !modelSettings) return;
44
+ if (!options.force && !ctx.isIdle()) return;
44
45
 
45
46
  const { model, thinkingLevel, warning } = modelSettings;
46
47
 
@@ -113,7 +114,7 @@ export class RecapManager {
113
114
  }
114
115
 
115
116
  private buildPrompt(ctx: ExtensionContext): string {
116
- const messages = ctx.sessionManager.getBranch().filter((entry) => entry.type === "message").map((entry) => entry.message);
117
+ const messages = ctx.sessionManager.buildContextEntries().flatMap(sessionEntryToContextMessages);
117
118
  const text = serializeConversation(convertToLlm(messages));
118
119
  const conversation = text.length > MAX_CONVERSATION_CHARS ? text.slice(-MAX_CONVERSATION_CHARS) : text;
119
120
 
@@ -16,12 +16,12 @@ export function registerTitle(pi: ExtensionAPI): void {
16
16
  // Generate the title after the first completed turn, without waiting for the full agent run.
17
17
  // The manager skips sessions that already have a name and prevents concurrent attempts.
18
18
  pi.on("turn_end", (_event, ctx) => {
19
- void titleManager?.run(ctx);
19
+ titleManager?.run(ctx);
20
20
  });
21
21
 
22
22
  // Fall back to settlement if the run ends without a completed turn or an early attempt fails.
23
23
  pi.on("agent_settled", (_event, ctx) => {
24
- void titleManager?.run(ctx);
24
+ titleManager?.run(ctx);
25
25
  });
26
26
 
27
27
  pi.on("session_shutdown", () => {
@@ -139,29 +139,18 @@ function formatWriteCall(path: string | null, content: string | null, highlighte
139
139
 
140
140
  export function registerWrite(pi: ExtensionAPI): void {
141
141
  let enabled = true;
142
- const toolCache = new Map<string, ReturnType<typeof createWriteToolDefinition>>();
143
142
 
144
- function getWriteTool(cwd: string): ReturnType<typeof createWriteToolDefinition> {
145
- let tool = toolCache.get(cwd);
146
- if (!tool) {
147
- tool = createWriteToolDefinition(cwd);
148
- toolCache.set(cwd, tool);
149
- }
150
-
151
- return tool;
152
- };
143
+ // Pi resolves paths against `ctx.cwd`, falling back to this cwd only when neither is provided.
144
+ const writeTool = createWriteToolDefinition(process.cwd());
153
145
 
154
146
  pi.on("session_start", (_event, ctx) => {
155
147
  enabled = loadConfig(ctx).write !== false;
156
148
  });
157
149
 
158
150
  pi.registerTool({
159
- ...getWriteTool(process.cwd()),
160
- execute(toolCallId, params, signal, onUpdate, ctx) {
161
- return getWriteTool(ctx.cwd).execute(toolCallId, params, signal, onUpdate, ctx);
162
- },
151
+ ...writeTool,
163
152
  renderCall(args, theme, context) {
164
- if (!enabled) return getWriteTool(context.cwd).renderCall!(args, theme, context);
153
+ if (!enabled) return writeTool.renderCall!(args, theme, context);
165
154
 
166
155
  const renderArgs = args as { path?: string; file_path?: string; content?: string } | undefined;
167
156
  const path = parseArg(renderArgs?.file_path ?? renderArgs?.path);
@@ -1,7 +1,8 @@
1
1
  import { homedir } from "node:os";
2
2
  import { isAbsolute, join, resolve } from "node:path";
3
3
  import { fileURLToPath, pathToFileURL } from "node:url";
4
- import { getCapabilities, hyperlink } from "@earendil-works/pi-tui";
4
+
5
+ import { formatLink } from "../../utils/format";
5
6
 
6
7
  import type { Theme } from "@earendil-works/pi-coding-agent";
7
8
 
@@ -12,9 +13,8 @@ export function renderPath(path: string | null, cwd: string, theme: Theme): stri
12
13
  const home = homedir();
13
14
  const displayPath = path.startsWith(home) ? `~${path.slice(home.length)}` : path;
14
15
  const styledPath = theme.fg("accent", displayPath);
15
- if (!getCapabilities().hyperlinks) return styledPath;
16
16
 
17
- return hyperlink(styledPath, pathToFileURL(resolvePath(path, cwd)).href);
17
+ return formatLink(styledPath, pathToFileURL(resolvePath(path, cwd)).href);
18
18
  }
19
19
 
20
20
  function resolvePath(path: string, cwd: string): string {
@@ -1,10 +1,16 @@
1
1
  import { isAbsolute, relative, resolve, sep } from "node:path";
2
+ import { getCapabilities, hyperlink } from "@earendil-works/pi-tui";
2
3
 
3
4
  import { http } from "./http";
4
5
 
5
6
  import type { ProviderId } from "@earendil-works/pi-ai";
6
7
  import type { ContextUsage } from "@earendil-works/pi-coding-agent";
7
8
 
9
+ /** Links text with OSC 8, honoring the terminal's detected and user-overridden hyperlink support. */
10
+ export function formatLink(text: string, url: string): string {
11
+ return getCapabilities().hyperlinks ? hyperlink(text, url) : text;
12
+ }
13
+
8
14
  export function formatModel(provider?: ProviderId, model?: string, thinkingLevel?: string): string {
9
15
  return provider && model ? `${provider}/${model}${thinkingLevel ? `:${thinkingLevel}` : ""}` : "no-model";
10
16
  }