pi-spark 0.19.3 → 0.21.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/LICENSE.md +9 -0
- package/README.md +58 -6
- package/assets/screencast-write.mp4 +0 -0
- package/index.ts +2 -0
- package/package.json +14 -9
- package/src/config/schema.ts +2 -0
- package/src/features/credits/config.ts +18 -1
- package/src/features/credits/index.ts +7 -4
- package/src/features/credits/manager.ts +7 -4
- package/src/features/credits/providers/deepseek.ts +2 -9
- package/src/features/credits/providers/index.ts +10 -4
- package/src/features/credits/providers/kimi-code.ts +61 -25
- package/src/features/credits/providers/moonshot.ts +3 -10
- package/src/features/credits/providers/openai-codex.ts +24 -45
- package/src/features/credits/providers/openrouter.ts +2 -9
- package/src/features/credits/providers/vercel-ai-gateway.ts +2 -9
- package/src/features/credits/types.ts +2 -2
- package/src/features/editor/config.ts +6 -1
- package/src/features/editor/index.ts +21 -4
- package/src/features/editor/spinner.ts +2 -7
- package/src/features/footer/config.ts +7 -1
- package/src/features/footer/index.ts +19 -8
- package/src/features/fullscreen/filler.ts +67 -23
- package/src/features/fullscreen/index.ts +12 -8
- package/src/features/presets/config.ts +2 -1
- package/src/features/presets/manager.ts +1 -1
- package/src/features/recap/index.ts +1 -1
- package/src/features/title/index.ts +8 -3
- package/src/features/title/manager.ts +2 -3
- package/src/features/write/config.ts +3 -0
- package/src/features/write/index.ts +177 -0
- package/src/features/write/path.ts +33 -0
- package/src/utils/format.ts +3 -4
- package/src/utils/http.ts +16 -0
- package/src/utils/usage.ts +2 -0
- package/themes/github-dark-default.json +91 -0
- package/themes/github-light-default.json +91 -0
- package/LICENSE +0 -21
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { ProviderId } from "@earendil-works/pi-ai";
|
|
2
1
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { CreditsProviderId } from "./config";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Normalized credits/usage for a provider.
|
|
@@ -21,7 +21,7 @@ export type RefreshCredits = (ctx: ExtensionContext) => Promise<void>;
|
|
|
21
21
|
|
|
22
22
|
/** A credits source for a Pi provider, shown in the status line while that provider is active. */
|
|
23
23
|
export interface CreditsProvider {
|
|
24
|
-
readonly id:
|
|
24
|
+
readonly id: CreditsProviderId;
|
|
25
25
|
readonly label: string;
|
|
26
26
|
fetch(apiKey: string, signal: AbortSignal): Promise<Credits>;
|
|
27
27
|
register?(pi: ExtensionAPI, ctx: ExtensionContext, refresh: RefreshCredits): void;
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import * as z from "zod";
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
const spinnerPresetSchema = z.enum(["dots", "lights", "tildes", "pulse"]);
|
|
4
|
+
const thinkingLevelIndicatorSchema = z.enum(["border", "model"]);
|
|
5
|
+
|
|
6
|
+
export type SpinnerPreset = z.infer<typeof spinnerPresetSchema>;
|
|
7
|
+
export type ThinkingLevelIndicator = z.infer<typeof thinkingLevelIndicatorSchema>;
|
|
4
8
|
|
|
5
9
|
export const editorConfigSchema = z.object({
|
|
6
10
|
spinner: spinnerPresetSchema.optional(),
|
|
11
|
+
thinkingLevelIndicator: thinkingLevelIndicatorSchema.optional(),
|
|
7
12
|
});
|
|
@@ -9,16 +9,18 @@ import { formatModel } from "../../utils/format";
|
|
|
9
9
|
import type { ExtensionAPI, ExtensionContext, KeybindingsManager } from "@earendil-works/pi-coding-agent";
|
|
10
10
|
import type { TUI, EditorTheme } from "@earendil-works/pi-tui";
|
|
11
11
|
import type { EventCollector } from "../../events";
|
|
12
|
+
import type { ThinkingLevelIndicator } from "./config";
|
|
12
13
|
|
|
13
14
|
class Editor extends CustomEditor {
|
|
14
15
|
private pi: ExtensionAPI;
|
|
15
16
|
private ctx: ExtensionContext;
|
|
16
17
|
|
|
17
18
|
private spinner: Spinner;
|
|
19
|
+
private thinkingLevelIndicator: ThinkingLevelIndicator;
|
|
18
20
|
private workingMessage: string | undefined;
|
|
19
21
|
private slots: { modelBefore: string | undefined };
|
|
20
22
|
|
|
21
|
-
constructor(pi: ExtensionAPI, ctx: ExtensionContext, tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager, spinner: Spinner = new Spinner()) {
|
|
23
|
+
constructor(pi: ExtensionAPI, ctx: ExtensionContext, tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager, spinner: Spinner = new Spinner(), thinkingLevelIndicator: ThinkingLevelIndicator = "border") {
|
|
22
24
|
super(tui, theme, keybindings);
|
|
23
25
|
|
|
24
26
|
this.pi = pi;
|
|
@@ -26,6 +28,7 @@ class Editor extends CustomEditor {
|
|
|
26
28
|
|
|
27
29
|
this.spinner = spinner;
|
|
28
30
|
this.spinner.setTUI(tui);
|
|
31
|
+
this.thinkingLevelIndicator = thinkingLevelIndicator;
|
|
29
32
|
this.workingMessage = undefined;
|
|
30
33
|
this.slots = { modelBefore: undefined };
|
|
31
34
|
}
|
|
@@ -41,6 +44,12 @@ class Editor extends CustomEditor {
|
|
|
41
44
|
}
|
|
42
45
|
|
|
43
46
|
render(width: number): string[] {
|
|
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.
|
|
49
|
+
if (this.thinkingLevelIndicator === "model" && !this.getText().trimStart().startsWith("!")) {
|
|
50
|
+
this.borderColor = (text) => this.ctx.ui.theme.fg("dim", text);
|
|
51
|
+
}
|
|
52
|
+
|
|
44
53
|
const lines = super.render(width);
|
|
45
54
|
if (lines.length === 0) return lines;
|
|
46
55
|
|
|
@@ -77,8 +86,16 @@ class Editor extends CustomEditor {
|
|
|
77
86
|
|
|
78
87
|
const modelBeforeText = this.slots.modelBefore;
|
|
79
88
|
const modelText = formatModel(this.ctx.model?.provider, this.ctx.model?.id, this.pi.getThinkingLevel());
|
|
89
|
+
const coloredModelText = this.thinkingLevelIndicator === "model" ? this.withThinkingLevelColor(modelText) : theme.fg("dim", modelText);
|
|
90
|
+
|
|
91
|
+
return [modelBeforeText ? theme.fg("dim", modelBeforeText) : undefined, coloredModelText].filter(Boolean).join(theme.fg("dim", " · "));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private withThinkingLevelColor(text: string): string {
|
|
95
|
+
const theme = this.ctx.ui.theme;
|
|
96
|
+
const thinkingLevel = this.pi.getThinkingLevel();
|
|
80
97
|
|
|
81
|
-
return theme.
|
|
98
|
+
return theme.getThinkingBorderColor(thinkingLevel)(text);
|
|
82
99
|
}
|
|
83
100
|
}
|
|
84
101
|
|
|
@@ -89,13 +106,13 @@ export function registerEditor(pi: ExtensionAPI, events: EventCollector): void {
|
|
|
89
106
|
|
|
90
107
|
pi.on("session_start", (_event, ctx) => {
|
|
91
108
|
const config = loadConfig(ctx).editor;
|
|
92
|
-
if (
|
|
109
|
+
if (ctx.mode !== "tui" || !config) return;
|
|
93
110
|
|
|
94
111
|
spinner = new Spinner(config.spinner);
|
|
95
112
|
|
|
96
113
|
ctx.ui.setWorkingVisible(false);
|
|
97
114
|
ctx.ui.setEditorComponent((tui, theme, keybindings) => {
|
|
98
|
-
editor = new Editor(pi, ctx, tui, theme, keybindings, spinner);
|
|
115
|
+
editor = new Editor(pi, ctx, tui, theme, keybindings, spinner, config.thinkingLevelIndicator);
|
|
99
116
|
|
|
100
117
|
events.on(PRESET_CHANGE, (data) => {
|
|
101
118
|
const payload = parsePresetChange(data);
|
|
@@ -1,10 +1,5 @@
|
|
|
1
|
-
import * as z from "zod";
|
|
2
|
-
|
|
3
1
|
import type { TUI } from "@earendil-works/pi-tui";
|
|
4
|
-
|
|
5
|
-
export const spinnerPresetSchema = z.enum(["dots", "lights", "tildes", "pulse"]);
|
|
6
|
-
|
|
7
|
-
type SpinnerPreset = z.infer<typeof spinnerPresetSchema>;
|
|
2
|
+
import type { SpinnerPreset } from "./config";
|
|
8
3
|
|
|
9
4
|
interface SpinnerParams {
|
|
10
5
|
frames: string[];
|
|
@@ -35,7 +30,7 @@ const SPINNER_PRESETS: Record<SpinnerPreset, SpinnerParams> = {
|
|
|
35
30
|
},
|
|
36
31
|
};
|
|
37
32
|
|
|
38
|
-
const DEFAULT_SPINNER_PRESET = "tildes";
|
|
33
|
+
const DEFAULT_SPINNER_PRESET: SpinnerPreset = "tildes";
|
|
39
34
|
|
|
40
35
|
export class Spinner {
|
|
41
36
|
private tui: TUI | undefined;
|
|
@@ -1,3 +1,9 @@
|
|
|
1
1
|
import * as z from "zod";
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
const statusPositionSchema = z.enum(["inline", "below"]);
|
|
4
|
+
|
|
5
|
+
export type StatusPosition = z.infer<typeof statusPositionSchema>;
|
|
6
|
+
|
|
7
|
+
export const footerConfigSchema = z.object({
|
|
8
|
+
statusPosition: statusPositionSchema.optional(),
|
|
9
|
+
});
|
|
@@ -1,26 +1,30 @@
|
|
|
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
5
|
|
|
5
6
|
import { SplitLine } from "../../components/split-line";
|
|
6
7
|
import { loadConfig } from "../../config";
|
|
7
8
|
import { formatContextUsage, formatCost, formatCwd, sanitizeText } from "../../utils/format";
|
|
8
9
|
import { getEntryUsage } from "../../utils/usage";
|
|
9
10
|
|
|
10
|
-
import { hyperlink } from "@earendil-works/pi-tui";
|
|
11
|
-
|
|
12
11
|
import type { ExtensionContext, ExtensionAPI, ReadonlyFooterDataProvider, Theme } from "@earendil-works/pi-coding-agent";
|
|
13
12
|
import type { Component } from "@earendil-works/pi-tui";
|
|
13
|
+
import type { StatusPosition } from "./config";
|
|
14
|
+
|
|
15
|
+
const DEFAULT_STATUS_POSITION: StatusPosition = "inline";
|
|
14
16
|
|
|
15
17
|
class FooterComponent implements Component {
|
|
16
18
|
private ctx: ExtensionContext;
|
|
17
19
|
private theme: Theme;
|
|
18
20
|
private footerData: ReadonlyFooterDataProvider;
|
|
21
|
+
private statusPosition: StatusPosition;
|
|
19
22
|
|
|
20
|
-
constructor(ctx: ExtensionContext, theme: Theme, footerData: ReadonlyFooterDataProvider) {
|
|
23
|
+
constructor(ctx: ExtensionContext, theme: Theme, footerData: ReadonlyFooterDataProvider, statusPosition: StatusPosition = DEFAULT_STATUS_POSITION) {
|
|
21
24
|
this.ctx = ctx;
|
|
22
25
|
this.theme = theme;
|
|
23
26
|
this.footerData = footerData;
|
|
27
|
+
this.statusPosition = statusPosition;
|
|
24
28
|
}
|
|
25
29
|
|
|
26
30
|
invalidate(): void {
|
|
@@ -31,7 +35,14 @@ class FooterComponent implements Component {
|
|
|
31
35
|
const left = this.getLeft();
|
|
32
36
|
const right = this.getRight();
|
|
33
37
|
|
|
34
|
-
|
|
38
|
+
const lines = new SplitLine(left, right, { primarySide: "right", ellipsis: this.theme.fg("dim", "…") }).render(width);
|
|
39
|
+
|
|
40
|
+
if (this.statusPosition === "below") {
|
|
41
|
+
const statusesText = this.getStatusesText();
|
|
42
|
+
if (statusesText) lines.push(...new Text(statusesText, 0, 0).render(width));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return lines;
|
|
35
46
|
}
|
|
36
47
|
|
|
37
48
|
private getLeft(): string {
|
|
@@ -45,7 +56,7 @@ class FooterComponent implements Component {
|
|
|
45
56
|
}
|
|
46
57
|
|
|
47
58
|
private getRight(): string {
|
|
48
|
-
const statusesText = this.getStatusesText();
|
|
59
|
+
const statusesText = this.statusPosition === "inline" ? this.getStatusesText() : "";
|
|
49
60
|
const styledCostText = this.getStyledCostText();
|
|
50
61
|
const styledContextUsageText = this.getStyledContextUsageText();
|
|
51
62
|
|
|
@@ -64,7 +75,7 @@ class FooterComponent implements Component {
|
|
|
64
75
|
}
|
|
65
76
|
|
|
66
77
|
private getStyledCostText(): string {
|
|
67
|
-
const totalCost = this.ctx.sessionManager.
|
|
78
|
+
const totalCost = this.ctx.sessionManager.getEntries().reduce((acc, entry) => acc + (getEntryUsage(entry)?.cost.total ?? 0), 0);
|
|
68
79
|
|
|
69
80
|
// Hide cost below half a cent, since it would render as $0.00.
|
|
70
81
|
if (totalCost < 0.005) return "";
|
|
@@ -89,8 +100,8 @@ class FooterComponent implements Component {
|
|
|
89
100
|
export function registerFooter(pi: ExtensionAPI): void {
|
|
90
101
|
pi.on("session_start", (_event, ctx) => {
|
|
91
102
|
const config = loadConfig(ctx).footer;
|
|
92
|
-
if (
|
|
103
|
+
if (ctx.mode !== "tui" || !config) return;
|
|
93
104
|
|
|
94
|
-
ctx.ui.setFooter((_tui, theme, footerData) => new FooterComponent(ctx, theme, footerData));
|
|
105
|
+
ctx.ui.setFooter((_tui, theme, footerData) => new FooterComponent(ctx, theme, footerData, config.statusPosition));
|
|
95
106
|
});
|
|
96
107
|
}
|
|
@@ -1,38 +1,82 @@
|
|
|
1
1
|
import type { Component, TUI } from "@earendil-works/pi-tui";
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
*/
|
|
3
|
+
const FILLER_MARKER = "\0pi-spark:fullscreen\0";
|
|
4
|
+
|
|
5
|
+
/** Pins the editor and footer to the bottom when the rest of the UI is shorter than the terminal. */
|
|
7
6
|
export class BottomFiller implements Component {
|
|
8
7
|
private tui: TUI;
|
|
9
|
-
private
|
|
8
|
+
private active = false;
|
|
9
|
+
private restore?: () => void;
|
|
10
10
|
|
|
11
11
|
constructor(tui: TUI) {
|
|
12
12
|
this.tui = tui;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Install the layout after Pi has added this component to its widget container.
|
|
17
|
+
*
|
|
18
|
+
* Pi has no flexible spacer or layout-measurement API. The filler therefore emits a temporary
|
|
19
|
+
* marker during Pi's normal render pass. A root render wrapper replaces that marker afterward,
|
|
20
|
+
* when the total height is known. Every component, including the transcript, renders only once.
|
|
21
|
+
*/
|
|
22
|
+
mount(): void {
|
|
23
|
+
if (this.active) return;
|
|
24
|
+
|
|
25
|
+
const widgetIndex = this.tui.children.findIndex((child) => "children" in child && Array.isArray(child.children) && child.children.includes(this));
|
|
26
|
+
if (widgetIndex <= 0) return;
|
|
27
|
+
|
|
28
|
+
const statusContainer = this.tui.children[widgetIndex - 1];
|
|
29
|
+
if (!statusContainer) return;
|
|
30
|
+
|
|
31
|
+
const originalStatusRender = statusContainer.render;
|
|
32
|
+
const patchedStatusRender = (width: number): string[] => {
|
|
33
|
+
const lines = originalStatusRender.call(statusContainer, width);
|
|
34
|
+
if (!this.active) return lines;
|
|
35
|
+
|
|
36
|
+
// Fullscreen already keeps the layout stable, so Pi's two idle placeholder rows only add an
|
|
37
|
+
// unwanted gap above the editor. Real status indicators are left untouched.
|
|
38
|
+
const blankLine = " ".repeat(width);
|
|
39
|
+
return lines.length === 2 && lines.every((line) => line === blankLine) ? [] : lines;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const originalTuiRender = this.tui.render;
|
|
43
|
+
const patchedTuiRender = (width: number): string[] => {
|
|
44
|
+
const lines = originalTuiRender.call(this.tui, width);
|
|
45
|
+
if (!this.active) return lines;
|
|
18
46
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
if (this.measuring) return [];
|
|
22
|
-
this.measuring = true;
|
|
47
|
+
const markerIndex = lines.indexOf(FILLER_MARKER);
|
|
48
|
+
if (markerIndex === -1) return lines;
|
|
23
49
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
50
|
+
lines.splice(markerIndex, 1);
|
|
51
|
+
const fillerHeight = Math.max(0, this.tui.terminal.rows - lines.length);
|
|
52
|
+
if (fillerHeight > 0) lines.splice(markerIndex, 0, ...new Array<string>(fillerHeight).fill(""));
|
|
27
53
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
54
|
+
// Pi reads this after render() and may reset it to the configured value on startup/reload.
|
|
55
|
+
this.tui.setClearOnShrink(true);
|
|
56
|
+
return lines;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
this.active = true;
|
|
60
|
+
statusContainer.render = patchedStatusRender;
|
|
61
|
+
this.tui.render = patchedTuiRender;
|
|
62
|
+
|
|
63
|
+
this.restore = () => {
|
|
64
|
+
if (this.tui.render === patchedTuiRender) this.tui.render = originalTuiRender;
|
|
65
|
+
if (statusContainer.render === patchedStatusRender) statusContainer.render = originalStatusRender;
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
dispose(): void {
|
|
70
|
+
this.active = false;
|
|
71
|
+
this.restore?.();
|
|
72
|
+
this.restore = undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
invalidate(): void {
|
|
76
|
+
// No cached render state
|
|
77
|
+
}
|
|
34
78
|
|
|
35
|
-
|
|
36
|
-
return
|
|
79
|
+
render(_width: number): string[] {
|
|
80
|
+
return this.active ? [FILLER_MARKER] : [];
|
|
37
81
|
}
|
|
38
82
|
}
|
|
@@ -39,7 +39,7 @@ export function registerFullscreen(pi: ExtensionAPI): void {
|
|
|
39
39
|
|
|
40
40
|
pi.on("session_start", (_event, ctx) => {
|
|
41
41
|
const config = loadConfig(ctx).fullscreen;
|
|
42
|
-
if (
|
|
42
|
+
if (ctx.mode !== "tui" || !config) return;
|
|
43
43
|
|
|
44
44
|
enabled = true;
|
|
45
45
|
pendingClear = true;
|
|
@@ -50,14 +50,17 @@ export function registerFullscreen(pi: ExtensionAPI): void {
|
|
|
50
50
|
ctx.ui.setWidget(WIDGET_KEY, (capturedTui) => {
|
|
51
51
|
tui = capturedTui;
|
|
52
52
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
pendingClear = false;
|
|
57
|
-
queueMicrotask(() => capturedTui.requestRender(true));
|
|
58
|
-
}
|
|
53
|
+
const filler = new BottomFiller(capturedTui);
|
|
54
|
+
const shouldClear = pendingClear;
|
|
55
|
+
pendingClear = false;
|
|
59
56
|
|
|
60
|
-
|
|
57
|
+
// Defer setup until `setWidget` has mounted the filler in the render tree.
|
|
58
|
+
queueMicrotask(() => {
|
|
59
|
+
filler.mount();
|
|
60
|
+
if (shouldClear) capturedTui.requestRender(true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
return filler;
|
|
61
64
|
});
|
|
62
65
|
});
|
|
63
66
|
|
|
@@ -76,6 +79,7 @@ export function registerFullscreen(pi: ExtensionAPI): void {
|
|
|
76
79
|
tui.terminal.write(`${line}\r\n`);
|
|
77
80
|
}
|
|
78
81
|
|
|
82
|
+
ctx.ui.setWidget(WIDGET_KEY, undefined);
|
|
79
83
|
tui = undefined;
|
|
80
84
|
enabled = false;
|
|
81
85
|
pendingClear = false;
|
|
@@ -2,7 +2,8 @@ import * as z from "zod";
|
|
|
2
2
|
|
|
3
3
|
import { modelSchema } from "../../config/model";
|
|
4
4
|
|
|
5
|
+
export type PresetConfig = z.infer<typeof modelSchema>;
|
|
6
|
+
|
|
5
7
|
export const presetsConfigSchema = z.record(z.string().min(1), modelSchema);
|
|
6
8
|
|
|
7
9
|
export type PresetsConfig = z.infer<typeof presetsConfigSchema>;
|
|
8
|
-
export type PresetConfig = PresetsConfig[string];
|
|
@@ -2,7 +2,7 @@ import { PRESET_CHANGE } from "../../events";
|
|
|
2
2
|
import { formatModel } from "../../utils/format";
|
|
3
3
|
|
|
4
4
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
|
-
import type {
|
|
5
|
+
import type { PresetConfig, PresetsConfig } from "./config";
|
|
6
6
|
|
|
7
7
|
export class PresetManager {
|
|
8
8
|
private pi: ExtensionAPI;
|
|
@@ -10,7 +10,7 @@ export function registerRecap(pi: ExtensionAPI): void {
|
|
|
10
10
|
|
|
11
11
|
pi.on("session_start", (event, ctx) => {
|
|
12
12
|
const config = loadConfig(ctx).recap;
|
|
13
|
-
if (
|
|
13
|
+
if (ctx.mode !== "tui" || !config) return;
|
|
14
14
|
|
|
15
15
|
recapManager = new RecapManager(pi, config);
|
|
16
16
|
|
|
@@ -8,13 +8,18 @@ export function registerTitle(pi: ExtensionAPI): void {
|
|
|
8
8
|
|
|
9
9
|
pi.on("session_start", (_event, ctx) => {
|
|
10
10
|
const config = loadConfig(ctx).title;
|
|
11
|
-
if (!
|
|
11
|
+
if (!config) return;
|
|
12
12
|
|
|
13
13
|
titleManager = new TitleManager(pi, config);
|
|
14
14
|
});
|
|
15
15
|
|
|
16
|
-
// Generate the title after the first
|
|
17
|
-
// The manager
|
|
16
|
+
// Generate the title after the first completed turn, without waiting for the full agent run.
|
|
17
|
+
// The manager skips sessions that already have a name and prevents concurrent attempts.
|
|
18
|
+
pi.on("turn_end", (_event, ctx) => {
|
|
19
|
+
void titleManager?.run(ctx);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// Fall back to settlement if the run ends without a completed turn or an early attempt fails.
|
|
18
23
|
pi.on("agent_settled", (_event, ctx) => {
|
|
19
24
|
void titleManager?.run(ctx);
|
|
20
25
|
});
|
|
@@ -32,9 +32,8 @@ export class TitleManager {
|
|
|
32
32
|
|
|
33
33
|
/** Generate and set the session title once, silently, from the current context. */
|
|
34
34
|
async run(ctx: ExtensionContext): Promise<void> {
|
|
35
|
-
if (this.pi.getSessionName()) return;
|
|
35
|
+
if (this.pi.getSessionName() || this.inflight) return;
|
|
36
36
|
|
|
37
|
-
this.cancelInflight();
|
|
38
37
|
const controller = new AbortController();
|
|
39
38
|
this.inflight = controller;
|
|
40
39
|
|
|
@@ -44,7 +43,7 @@ export class TitleManager {
|
|
|
44
43
|
|
|
45
44
|
const { model, thinkingLevel } = modelSettings;
|
|
46
45
|
const result = await this.generate(ctx, model, thinkingLevel, controller.signal);
|
|
47
|
-
if (controller.signal.aborted || this.inflight !== controller || !result.content) return;
|
|
46
|
+
if (controller.signal.aborted || this.inflight !== controller || !result.content || this.pi.getSessionName()) return;
|
|
48
47
|
|
|
49
48
|
this.pi.setSessionName(result.content);
|
|
50
49
|
this.pi.appendEntry("title", {
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { createWriteToolDefinition, getLanguageFromPath, highlightCode, keyHint } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
3
|
+
|
|
4
|
+
import { loadConfig } from "../../config";
|
|
5
|
+
import { renderPath } from "./path";
|
|
6
|
+
|
|
7
|
+
import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
|
|
9
|
+
const PREVIEW_LINES = 10;
|
|
10
|
+
const HIGHLIGHT_LOOKBACK_LINES = 50;
|
|
11
|
+
|
|
12
|
+
function normalizeContent(content: string): string {
|
|
13
|
+
return content.replace(/\r/g, "").replace(/\t/g, " ");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
class HighlightCache {
|
|
17
|
+
private rawPath: string | null = null;
|
|
18
|
+
private rawContent = "";
|
|
19
|
+
private argsComplete = false;
|
|
20
|
+
private language = "";
|
|
21
|
+
private normalizedLines: string[] = [];
|
|
22
|
+
private highlightedLines: string[] = [];
|
|
23
|
+
|
|
24
|
+
update(rawPath: string | null, rawContent: string | null, argsComplete: boolean): string[] | undefined {
|
|
25
|
+
if (rawContent === null) {
|
|
26
|
+
this.clear();
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const language = rawPath ? getLanguageFromPath(rawPath) : undefined;
|
|
31
|
+
if (!language) {
|
|
32
|
+
this.clear();
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
if (this.matches(rawPath, rawContent, argsComplete, language)) return this.highlightedLines;
|
|
36
|
+
|
|
37
|
+
if (argsComplete || !this.canExtend(rawPath, rawContent, language)) {
|
|
38
|
+
this.rebuild(rawPath, rawContent, argsComplete, language);
|
|
39
|
+
} else {
|
|
40
|
+
this.extend(rawContent);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return this.highlightedLines;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
clear(): void {
|
|
47
|
+
this.rawPath = null;
|
|
48
|
+
this.rawContent = "";
|
|
49
|
+
this.argsComplete = false;
|
|
50
|
+
this.language = "";
|
|
51
|
+
this.normalizedLines = [];
|
|
52
|
+
this.highlightedLines = [];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private rebuild(rawPath: string | null, rawContent: string, argsComplete: boolean, language: string): void {
|
|
56
|
+
const normalizedContent = normalizeContent(rawContent);
|
|
57
|
+
this.rawPath = rawPath;
|
|
58
|
+
this.rawContent = rawContent;
|
|
59
|
+
this.argsComplete = argsComplete;
|
|
60
|
+
this.language = language;
|
|
61
|
+
this.normalizedLines = normalizedContent.split("\n");
|
|
62
|
+
this.highlightedLines = highlightCode(normalizedContent, language);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private matches(rawPath: string | null, rawContent: string, argsComplete: boolean, language: string): boolean {
|
|
66
|
+
return this.rawPath === rawPath && this.rawContent === rawContent && this.argsComplete === argsComplete && this.language === language;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private canExtend(rawPath: string | null, rawContent: string, language: string): boolean {
|
|
70
|
+
return this.rawPath === rawPath && this.language === language && rawContent.startsWith(this.rawContent);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private extend(rawContent: string): void {
|
|
74
|
+
const segments = normalizeContent(rawContent.slice(this.rawContent.length)).split("\n");
|
|
75
|
+
const lastIndex = this.normalizedLines.length - 1;
|
|
76
|
+
const start = Math.max(0, lastIndex - HIGHLIGHT_LOOKBACK_LINES);
|
|
77
|
+
|
|
78
|
+
this.rawContent = rawContent;
|
|
79
|
+
this.argsComplete = false;
|
|
80
|
+
this.normalizedLines[lastIndex] += segments[0] ?? "";
|
|
81
|
+
this.normalizedLines.push(...segments.slice(1));
|
|
82
|
+
|
|
83
|
+
const highlightedLines = highlightCode(this.normalizedLines.slice(start).join("\n"), this.language);
|
|
84
|
+
this.highlightedLines.splice(start, this.highlightedLines.length - start, ...highlightedLines);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
class WriteCallComponent extends Text {
|
|
89
|
+
private highlightCache = new HighlightCache();
|
|
90
|
+
|
|
91
|
+
constructor() {
|
|
92
|
+
super("", 0, 0);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
updateHighlight(rawPath: string | null, rawContent: string | null, argsComplete: boolean): string[] | undefined {
|
|
96
|
+
return this.highlightCache.update(rawPath, rawContent, argsComplete);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
override invalidate(): void {
|
|
100
|
+
this.highlightCache.clear();
|
|
101
|
+
super.invalidate();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function parseArg(value: unknown): string | null {
|
|
106
|
+
if (typeof value === "string") return value;
|
|
107
|
+
return value == null ? "" : null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function trimTrailingEmptyLines(lines: string[]): string[] {
|
|
111
|
+
let end = lines.length;
|
|
112
|
+
while (end > 0 && lines[end - 1] === "") end--;
|
|
113
|
+
return lines.slice(0, end);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function renderOmission(hiddenLines: number, totalLines: number, theme: Theme): string {
|
|
117
|
+
const summary = theme.fg("muted", `... (${hiddenLines} earlier lines, ${totalLines} total,`);
|
|
118
|
+
return `${summary} ${keyHint("app.tools.expand", "to expand")}${theme.fg("muted", ")")}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function formatWriteCall(path: string | null, content: string | null, highlightedLines: string[] | undefined, expanded: boolean, cwd: string, theme: Theme): string {
|
|
122
|
+
const header = `${theme.fg("toolTitle", theme.bold("write"))} ${renderPath(path, cwd, theme)}`;
|
|
123
|
+
if (content === null) return `${header}\n\n${theme.fg("error", "[invalid content arg - expected string]")}`;
|
|
124
|
+
if (!content) return header;
|
|
125
|
+
|
|
126
|
+
const language = path ? getLanguageFromPath(path) : undefined;
|
|
127
|
+
const normalizedContent = normalizeContent(content);
|
|
128
|
+
const contentLines = language ? (highlightedLines ?? highlightCode(normalizedContent, language)) : normalizedContent.split("\n");
|
|
129
|
+
const lines = trimTrailingEmptyLines(contentLines);
|
|
130
|
+
|
|
131
|
+
const visibleLines = expanded ? lines : lines.slice(-PREVIEW_LINES);
|
|
132
|
+
const hiddenLines = lines.length - visibleLines.length;
|
|
133
|
+
const displayLines = language ? visibleLines : visibleLines.map((line) => theme.fg("toolOutput", line));
|
|
134
|
+
|
|
135
|
+
const body = hiddenLines > 0 ? [renderOmission(hiddenLines, lines.length, theme), ...displayLines] : displayLines;
|
|
136
|
+
|
|
137
|
+
return [header, "", ...body].join("\n");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function registerWrite(pi: ExtensionAPI): void {
|
|
141
|
+
let enabled = true;
|
|
142
|
+
const toolCache = new Map<string, ReturnType<typeof createWriteToolDefinition>>();
|
|
143
|
+
|
|
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
|
+
};
|
|
153
|
+
|
|
154
|
+
pi.on("session_start", (_event, ctx) => {
|
|
155
|
+
enabled = loadConfig(ctx).write !== false;
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
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
|
+
},
|
|
163
|
+
renderCall(args, theme, context) {
|
|
164
|
+
if (!enabled) return getWriteTool(context.cwd).renderCall!(args, theme, context);
|
|
165
|
+
|
|
166
|
+
const renderArgs = args as { path?: string; file_path?: string; content?: string } | undefined;
|
|
167
|
+
const path = parseArg(renderArgs?.file_path ?? renderArgs?.path);
|
|
168
|
+
const content = parseArg(renderArgs?.content);
|
|
169
|
+
|
|
170
|
+
const component = context.lastComponent instanceof WriteCallComponent ? context.lastComponent : new WriteCallComponent();
|
|
171
|
+
const highlightedLines = component.updateHighlight(path, content, context.argsComplete);
|
|
172
|
+
component.setText(formatWriteCall(path, content, highlightedLines, context.expanded, context.cwd, theme));
|
|
173
|
+
|
|
174
|
+
return component;
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
4
|
+
import { getCapabilities, hyperlink } from "@earendil-works/pi-tui";
|
|
5
|
+
|
|
6
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
|
|
8
|
+
export function renderPath(path: string | null, cwd: string, theme: Theme): string {
|
|
9
|
+
if (path === null) return theme.fg("error", "[invalid arg]");
|
|
10
|
+
if (!path) return theme.fg("toolOutput", "...");
|
|
11
|
+
|
|
12
|
+
const home = homedir();
|
|
13
|
+
const displayPath = path.startsWith(home) ? `~${path.slice(home.length)}` : path;
|
|
14
|
+
const styledPath = theme.fg("accent", displayPath);
|
|
15
|
+
if (!getCapabilities().hyperlinks) return styledPath;
|
|
16
|
+
|
|
17
|
+
return hyperlink(styledPath, pathToFileURL(resolvePath(path, cwd)).href);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function resolvePath(path: string, cwd: string): string {
|
|
21
|
+
const normalizedPath = normalizePath(path);
|
|
22
|
+
const normalizedCwd = normalizePath(cwd);
|
|
23
|
+
return isAbsolute(normalizedPath) ? resolve(normalizedPath) : resolve(normalizedCwd, normalizedPath);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function normalizePath(path: string): string {
|
|
27
|
+
const home = homedir();
|
|
28
|
+
if (path === "~") return home;
|
|
29
|
+
if (path.startsWith("~/") || (process.platform === "win32" && path.startsWith("~\\"))) {
|
|
30
|
+
return join(home, path.slice(2));
|
|
31
|
+
}
|
|
32
|
+
return path.startsWith("file://") ? fileURLToPath(path) : path;
|
|
33
|
+
}
|