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 +23 -0
- package/package.json +9 -9
- package/src/features/credits/index.ts +4 -0
- package/src/features/credits/providers/deepseek.ts +12 -12
- package/src/features/credits/providers/openai-codex-panel.ts +3 -1
- package/src/features/credits/status.ts +3 -2
- package/src/features/editor/index.ts +22 -18
- package/src/features/footer/index.ts +3 -3
- package/src/features/presets/selector.ts +2 -0
- package/src/features/recap/idle.ts +56 -79
- package/src/features/recap/index.ts +13 -27
- package/src/features/recap/manager.ts +4 -3
- package/src/features/title/index.ts +2 -2
- package/src/features/write/index.ts +4 -15
- package/src/features/write/path.ts +3 -3
- package/src/utils/format.ts +6 -0
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.
|
|
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.
|
|
48
|
-
"@earendil-works/pi-ai": ">=0.
|
|
49
|
-
"@earendil-works/pi-coding-agent": ">=0.
|
|
50
|
-
"@earendil-works/pi-tui": ">=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.
|
|
55
|
-
"@earendil-works/pi-ai": ">=0.
|
|
56
|
-
"@earendil-works/pi-coding-agent": ">=0.
|
|
57
|
-
"@earendil-works/pi-tui": ">=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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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 ?
|
|
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
|
-
|
|
54
|
-
|
|
54
|
+
return super.render(width);
|
|
55
|
+
}
|
|
55
56
|
|
|
56
|
-
|
|
57
|
+
protected override renderTopBorder(width: number, hiddenLineCount: number): string {
|
|
58
|
+
return this.renderBorder(width, this.getTopLeft(hiddenLineCount), this.getTopRight());
|
|
59
|
+
}
|
|
57
60
|
|
|
58
|
-
|
|
61
|
+
protected override renderBottomBorder(width: number, hiddenLineCount: number): string {
|
|
62
|
+
return this.renderBorder(width, this.getScrollHint("↓", hiddenLineCount), "");
|
|
59
63
|
}
|
|
60
64
|
|
|
61
|
-
private
|
|
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
|
|
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 [
|
|
83
|
+
return [this.getScrollHint("↑", hiddenLineCount), workingText].filter(Boolean).join(theme.fg("dim", " · "));
|
|
82
84
|
}
|
|
83
85
|
|
|
84
|
-
private
|
|
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 {
|
|
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 =
|
|
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
|
|
27
|
-
private
|
|
28
|
-
private
|
|
29
|
-
private
|
|
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:
|
|
37
|
-
private
|
|
33
|
+
private enterCallbacks: Set<(ctx: ExtensionContext) => void> = new Set();
|
|
34
|
+
private resetCallbacks: Set<(ctx: ExtensionContext) => void> = new Set();
|
|
38
35
|
|
|
39
|
-
constructor(
|
|
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" | "
|
|
46
|
-
const
|
|
47
|
-
|
|
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 () =>
|
|
44
|
+
return () => callbacks.delete(callback);
|
|
50
45
|
}
|
|
51
46
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
this.
|
|
56
|
-
this.
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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.
|
|
89
|
-
|
|
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.
|
|
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
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
5
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
6
6
|
|
|
7
7
|
export function registerRecap(pi: ExtensionAPI): void {
|
|
8
|
-
let idleListener: IdleListener
|
|
8
|
+
let idleListener: IdleListener | undefined = undefined;
|
|
9
9
|
let recapManager: RecapManager | undefined = undefined;
|
|
10
10
|
|
|
11
|
-
pi.on("session_start", (
|
|
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(
|
|
23
|
-
idleListener.on("enter", (
|
|
24
|
-
idleListener.on("
|
|
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
|
-
|
|
27
|
-
idleListener.watch(ctx);
|
|
28
|
-
}
|
|
26
|
+
idleListener.reset(ctx);
|
|
29
27
|
});
|
|
30
28
|
|
|
31
29
|
pi.on("input", (_event, ctx) => {
|
|
32
|
-
idleListener?.
|
|
30
|
+
idleListener?.reset(ctx);
|
|
33
31
|
});
|
|
34
32
|
|
|
35
33
|
pi.on("user_bash", (_event, ctx) => {
|
|
36
|
-
idleListener?.
|
|
34
|
+
idleListener?.reset(ctx);
|
|
37
35
|
});
|
|
38
36
|
|
|
39
37
|
pi.on("agent_start", (_event, ctx) => {
|
|
40
|
-
idleListener?.
|
|
38
|
+
idleListener?.reset(ctx);
|
|
41
39
|
});
|
|
42
40
|
|
|
43
41
|
pi.on("session_before_compact", (_event, ctx) => {
|
|
44
|
-
idleListener?.
|
|
42
|
+
idleListener?.reset(ctx);
|
|
45
43
|
});
|
|
46
44
|
|
|
47
45
|
pi.on("session_before_tree", (_event, ctx) => {
|
|
48
|
-
idleListener?.
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
145
|
-
|
|
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
|
-
...
|
|
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
|
|
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
|
-
|
|
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
|
|
17
|
+
return formatLink(styledPath, pathToFileURL(resolvePath(path, cwd)).href);
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
function resolvePath(path: string, cwd: string): string {
|
package/src/utils/format.ts
CHANGED
|
@@ -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
|
}
|