pum-agent 0.1.0-beta.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/LICENSE +21 -0
- package/README.md +196 -0
- package/package.json +69 -0
- package/src/agent-selector.tsx +217 -0
- package/src/agent-usage.ts +93 -0
- package/src/animation.tsx +476 -0
- package/src/app.tsx +1953 -0
- package/src/apply-patch.ts +583 -0
- package/src/cancel-confirmation.ts +14 -0
- package/src/check-mode.ts +630 -0
- package/src/commands.ts +45 -0
- package/src/config.ts +24 -0
- package/src/explanation-strength.ts +47 -0
- package/src/git-branch.ts +54 -0
- package/src/help-popup.tsx +279 -0
- package/src/history.ts +57 -0
- package/src/image-paste.ts +204 -0
- package/src/index.tsx +133 -0
- package/src/login-controller.ts +267 -0
- package/src/login-flow.ts +170 -0
- package/src/login-popup.tsx +154 -0
- package/src/platform.ts +94 -0
- package/src/prompt-stash.ts +130 -0
- package/src/replay.ts +188 -0
- package/src/session-history-popup.tsx +68 -0
- package/src/settings-popup.tsx +283 -0
- package/src/settings.ts +81 -0
- package/src/shutdown.ts +23 -0
- package/src/stash-batch.ts +28 -0
- package/src/status-bar.tsx +143 -0
- package/src/status-metadata.ts +110 -0
- package/src/subagents/manager.ts +1196 -0
- package/src/subagents/types.ts +86 -0
- package/src/syntax.ts +60 -0
- package/src/theme.ts +346 -0
- package/src/tool-line.ts +72 -0
- package/src/transcript.tsx +393 -0
- package/src/web-search.ts +157 -0
- package/src/worktree-command.ts +39 -0
- package/src/worktree.ts +219 -0
- package/src/writing-style.ts +54 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import type { AuthEvent, AuthPrompt } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { Theme } from "./theme";
|
|
3
|
+
import type { LoginMethod } from "./login-flow";
|
|
4
|
+
|
|
5
|
+
export type LoginPage =
|
|
6
|
+
| { kind: "providers"; methods: readonly LoginMethod[]; cursor: number }
|
|
7
|
+
| { kind: "prompt"; providerName: string; prompt: AuthPrompt; cursor: number; value: string; secretLength: number }
|
|
8
|
+
| { kind: "working"; providerName: string; event?: AuthEvent }
|
|
9
|
+
| { kind: "custom-endpoint"; endpoint: string }
|
|
10
|
+
| { kind: "custom-key"; endpoint: string; secretLength: number }
|
|
11
|
+
| { kind: "custom-working"; endpoint: string; message: string }
|
|
12
|
+
| { kind: "error"; title: string; message: string }
|
|
13
|
+
| { kind: "success"; message: string };
|
|
14
|
+
|
|
15
|
+
function popupGeometry(width: number, height: number) {
|
|
16
|
+
const narrow = width < 64;
|
|
17
|
+
const margin = narrow ? 1 : Math.max(2, Math.floor(width * 0.1));
|
|
18
|
+
return {
|
|
19
|
+
left: margin,
|
|
20
|
+
width: Math.max(24, width - margin * 2),
|
|
21
|
+
height: Math.max(10, Math.min(height - 2, 22)),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function InputRow({ theme, label, value, secret = false }: { theme: Theme; label: string; value: string; secret?: boolean }) {
|
|
26
|
+
return (
|
|
27
|
+
<box style={{ flexDirection: "row", height: 1, flexShrink: 0 }}>
|
|
28
|
+
<box style={{ width: 12, flexShrink: 0 }}><text content={label} fg={theme.dim} bg={theme.popupBg} /></box>
|
|
29
|
+
<text content={secret ? "•".repeat(value.length) : value} fg={theme.fg} bg={theme.popupBg} wrapMode="none" />
|
|
30
|
+
<text content="▌" fg={theme.accent} bg={theme.popupBg} />
|
|
31
|
+
</box>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function EventDetails({ theme, event }: { theme: Theme; event?: AuthEvent }) {
|
|
36
|
+
if (!event) return <text content="Starting authentication…" fg={theme.dim} bg={theme.popupBg} />;
|
|
37
|
+
if (event.type === "auth_url") {
|
|
38
|
+
return <>
|
|
39
|
+
<text content={event.instructions ?? "Open this URL in a browser:"} fg={theme.fg} bg={theme.popupBg} />
|
|
40
|
+
<text content={event.url} fg={theme.accent} bg={theme.popupBg} selectable wrapMode="word" />
|
|
41
|
+
</>;
|
|
42
|
+
}
|
|
43
|
+
if (event.type === "device_code") {
|
|
44
|
+
return <>
|
|
45
|
+
<text content="Open this URL and enter the code:" fg={theme.fg} bg={theme.popupBg} />
|
|
46
|
+
<text content={event.verificationUri} fg={theme.accent} bg={theme.popupBg} selectable wrapMode="word" />
|
|
47
|
+
<text content={event.userCode} fg={theme.accent} bg={theme.popupBg} selectable />
|
|
48
|
+
</>;
|
|
49
|
+
}
|
|
50
|
+
if (event.type === "info") {
|
|
51
|
+
return <>
|
|
52
|
+
<text content={event.message} fg={theme.fg} bg={theme.popupBg} wrapMode="word" />
|
|
53
|
+
{event.links?.map((link) => <text key={link.url} content={`${link.label ? `${link.label}: ` : ""}${link.url}`} fg={theme.accent} bg={theme.popupBg} selectable wrapMode="word" />)}
|
|
54
|
+
</>;
|
|
55
|
+
}
|
|
56
|
+
return <text content={event.message} fg={theme.dim} bg={theme.popupBg} wrapMode="word" />;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function LoginPopup({ theme, page, terminalWidth, terminalHeight }: {
|
|
60
|
+
theme: Theme;
|
|
61
|
+
page: LoginPage;
|
|
62
|
+
terminalWidth: number;
|
|
63
|
+
terminalHeight: number;
|
|
64
|
+
}) {
|
|
65
|
+
const geometry = popupGeometry(terminalWidth, terminalHeight);
|
|
66
|
+
const providerCursor = page.kind === "providers" ? page.cursor : null;
|
|
67
|
+
const title = page.kind === "providers" ? " Login " : page.kind.startsWith("custom") ? " Custom provider " : " Provider login ";
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<box
|
|
71
|
+
title={title}
|
|
72
|
+
style={{
|
|
73
|
+
position: "absolute",
|
|
74
|
+
top: Math.max(1, Math.floor((terminalHeight - geometry.height) / 2)),
|
|
75
|
+
left: geometry.left,
|
|
76
|
+
width: geometry.width,
|
|
77
|
+
height: geometry.height,
|
|
78
|
+
zIndex: 120,
|
|
79
|
+
border: true,
|
|
80
|
+
borderColor: theme.border,
|
|
81
|
+
backgroundColor: theme.popupBg,
|
|
82
|
+
flexDirection: "column",
|
|
83
|
+
padding: 1,
|
|
84
|
+
}}
|
|
85
|
+
>
|
|
86
|
+
{page.kind === "providers" ? <>
|
|
87
|
+
<text content="Select a provider login method" fg={theme.dim} bg={theme.popupBg} style={{ flexShrink: 0 }} />
|
|
88
|
+
<box style={{ height: 1, flexShrink: 0 }} />
|
|
89
|
+
<scrollbox
|
|
90
|
+
id="login-provider-list"
|
|
91
|
+
style={{ flexGrow: 1, minHeight: 1 }}
|
|
92
|
+
verticalScrollbarOptions={{ visible: true }}
|
|
93
|
+
renderBefore={function () {
|
|
94
|
+
if (providerCursor !== null) this.scrollChildIntoView(`login-provider-${providerCursor}`);
|
|
95
|
+
}}
|
|
96
|
+
>
|
|
97
|
+
<box style={{ flexDirection: "column", width: "100%", flexShrink: 0 }}>
|
|
98
|
+
{page.methods.map((method, index) => {
|
|
99
|
+
const selected = page.cursor === index;
|
|
100
|
+
const label = `${method.providerName} — ${method.authType === "oauth" ? method.loginLabel ?? method.methodName : method.methodName}`;
|
|
101
|
+
return <box id={`login-provider-${index}`} key={`${method.providerId}:${method.authType}`} style={{ height: 1, flexShrink: 0, backgroundColor: selected ? theme.selectionBg : theme.popupBg }}>
|
|
102
|
+
<text content={`${selected ? "› " : " "}${label}${method.canLogin ? "" : " (external setup)"}`} fg={selected ? theme.accent : theme.fg} bg={selected ? theme.selectionBg : theme.popupBg} wrapMode="none" />
|
|
103
|
+
</box>;
|
|
104
|
+
})}
|
|
105
|
+
<box id={`login-provider-${page.methods.length}`} style={{ height: 1, flexShrink: 0, backgroundColor: page.cursor === page.methods.length ? theme.selectionBg : theme.popupBg }}>
|
|
106
|
+
<text content={`${page.cursor === page.methods.length ? "› " : " "}Custom OpenAI-compatible provider`} fg={page.cursor === page.methods.length ? theme.accent : theme.fg} bg={page.cursor === page.methods.length ? theme.selectionBg : theme.popupBg} />
|
|
107
|
+
</box>
|
|
108
|
+
</box>
|
|
109
|
+
</scrollbox>
|
|
110
|
+
<text content="↑↓ move enter select esc close" fg={theme.dim} bg={theme.popupBg} style={{ height: 1, flexShrink: 0 }} />
|
|
111
|
+
</> : page.kind === "prompt" ? <>
|
|
112
|
+
<text content={page.providerName} fg={theme.accent} bg={theme.popupBg} />
|
|
113
|
+
<text content={page.prompt.message} fg={theme.fg} bg={theme.popupBg} wrapMode="word" />
|
|
114
|
+
<box style={{ height: 1, flexShrink: 0 }} />
|
|
115
|
+
{page.prompt.type === "select" ? page.prompt.options.map((option, index) => {
|
|
116
|
+
const selected = index === page.cursor;
|
|
117
|
+
return <box key={option.id} style={{ flexDirection: "column", flexShrink: 0, backgroundColor: selected ? theme.selectionBg : theme.popupBg }}>
|
|
118
|
+
<text content={`${selected ? "› " : " "}${option.label}`} fg={selected ? theme.accent : theme.fg} bg={selected ? theme.selectionBg : theme.popupBg} />
|
|
119
|
+
{option.description ? <text content={` ${option.description}`} fg={theme.dim} bg={selected ? theme.selectionBg : theme.popupBg} /> : null}
|
|
120
|
+
</box>;
|
|
121
|
+
}) : <InputRow theme={theme} label={page.prompt.type === "secret" ? "API key" : "Value"} value={page.prompt.type === "secret" ? "x".repeat(page.secretLength) : page.value} secret={page.prompt.type === "secret"} />}
|
|
122
|
+
<text content="enter continue esc cancel" fg={theme.dim} bg={theme.popupBg} style={{ marginTop: 1 }} />
|
|
123
|
+
</> : page.kind === "working" ? <>
|
|
124
|
+
<text content={page.providerName} fg={theme.accent} bg={theme.popupBg} />
|
|
125
|
+
<box style={{ height: 1, flexShrink: 0 }} />
|
|
126
|
+
<EventDetails theme={theme} event={page.event} />
|
|
127
|
+
<text content="URLs and codes are selectable for copying. Esc cancels." fg={theme.dim} bg={theme.popupBg} style={{ marginTop: 1 }} />
|
|
128
|
+
</> : page.kind === "custom-endpoint" ? <>
|
|
129
|
+
<text content="Enter the server endpoint. PUM probes /models and configures OpenAI Chat Completions only after that probe succeeds." fg={theme.fg} bg={theme.popupBg} wrapMode="word" />
|
|
130
|
+
<box style={{ height: 1, flexShrink: 0 }} />
|
|
131
|
+
<InputRow theme={theme} label="Endpoint" value={page.endpoint} />
|
|
132
|
+
<text content="Example: http://localhost:11434/v1" fg={theme.dim} bg={theme.popupBg} />
|
|
133
|
+
<text content="enter continue esc back" fg={theme.dim} bg={theme.popupBg} style={{ marginTop: 1 }} />
|
|
134
|
+
</> : page.kind === "custom-key" ? <>
|
|
135
|
+
<text content="Enter the API key. Leave the field empty for a keyless local server." fg={theme.fg} bg={theme.popupBg} wrapMode="word" />
|
|
136
|
+
<box style={{ height: 1, flexShrink: 0 }} />
|
|
137
|
+
<InputRow theme={theme} label="API key" value={"x".repeat(page.secretLength)} secret />
|
|
138
|
+
<text content="The key is stored in PUM auth.json. The key is not stored in models.json." fg={theme.dim} bg={theme.popupBg} wrapMode="word" />
|
|
139
|
+
<text content="enter discover esc back" fg={theme.dim} bg={theme.popupBg} style={{ marginTop: 1 }} />
|
|
140
|
+
</> : page.kind === "custom-working" ? <>
|
|
141
|
+
<text content={page.message} fg={theme.dim} bg={theme.popupBg} wrapMode="word" />
|
|
142
|
+
<text content="Esc cancels." fg={theme.dim} bg={theme.popupBg} style={{ marginTop: 1 }} />
|
|
143
|
+
</> : page.kind === "error" ? <>
|
|
144
|
+
<text content={page.title} fg={theme.error} bg={theme.popupBg} />
|
|
145
|
+
<text content={page.message} fg={theme.fg} bg={theme.popupBg} wrapMode="word" />
|
|
146
|
+
<text content="enter retry esc close" fg={theme.dim} bg={theme.popupBg} style={{ marginTop: 1 }} />
|
|
147
|
+
</> : <>
|
|
148
|
+
<text content="✓ Setup complete" fg={theme.success} bg={theme.popupBg} />
|
|
149
|
+
<text content={page.message} fg={theme.fg} bg={theme.popupBg} wrapMode="word" />
|
|
150
|
+
<text content="enter or esc close" fg={theme.dim} bg={theme.popupBg} style={{ marginTop: 1 }} />
|
|
151
|
+
</>}
|
|
152
|
+
</box>
|
|
153
|
+
);
|
|
154
|
+
}
|
package/src/platform.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { realpath } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { posix, win32 } from "node:path";
|
|
5
|
+
|
|
6
|
+
export type RuntimePlatform = NodeJS.Platform;
|
|
7
|
+
|
|
8
|
+
type Environment = Record<string, string | undefined>;
|
|
9
|
+
|
|
10
|
+
function pathApi(platform: RuntimePlatform): typeof posix {
|
|
11
|
+
return platform === "win32" ? win32 : posix;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function defaultAgentDir(
|
|
15
|
+
platform: RuntimePlatform = process.platform,
|
|
16
|
+
env: Environment = process.env,
|
|
17
|
+
home = homedir(),
|
|
18
|
+
): string {
|
|
19
|
+
const paths = pathApi(platform);
|
|
20
|
+
if (env.PUM_DIR) return paths.resolve(env.PUM_DIR);
|
|
21
|
+
if (platform === "win32") {
|
|
22
|
+
const base = env.LOCALAPPDATA ?? env.APPDATA ?? paths.join(home, "AppData", "Local");
|
|
23
|
+
return paths.join(base, "pum");
|
|
24
|
+
}
|
|
25
|
+
if (platform === "darwin") return paths.join(home, "Library", "Application Support", "pum");
|
|
26
|
+
return paths.join(env.XDG_CONFIG_HOME ?? paths.join(home, ".config"), "pum");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function projectStorageKey(
|
|
30
|
+
cwd: string,
|
|
31
|
+
platform: RuntimePlatform = process.platform,
|
|
32
|
+
): string {
|
|
33
|
+
if (platform !== "win32") return cwd;
|
|
34
|
+
return win32.resolve(cwd).toLowerCase();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function sessionDirectoryName(
|
|
38
|
+
cwd: string,
|
|
39
|
+
platform: RuntimePlatform = process.platform,
|
|
40
|
+
): string {
|
|
41
|
+
if (platform !== "win32") {
|
|
42
|
+
return `--${posix.resolve(cwd).replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const canonical = win32.resolve(cwd).toLowerCase();
|
|
46
|
+
const readable = canonical
|
|
47
|
+
.replace(/^\\\\/, "unc-")
|
|
48
|
+
.replace(/^[a-z]:\\/i, (prefix) => `${prefix[0]}-`)
|
|
49
|
+
.replace(/[^a-z0-9._-]+/gi, "-")
|
|
50
|
+
.replace(/-+/g, "-")
|
|
51
|
+
.replace(/^-|-$/g, "")
|
|
52
|
+
.slice(0, 72);
|
|
53
|
+
const digest = createHash("sha256").update(canonical).digest("hex").slice(0, 12);
|
|
54
|
+
return `--${readable || "root"}-${digest}--`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function isPathInside(
|
|
58
|
+
parent: string,
|
|
59
|
+
candidate: string,
|
|
60
|
+
platform: RuntimePlatform = process.platform,
|
|
61
|
+
): boolean {
|
|
62
|
+
const paths = pathApi(platform);
|
|
63
|
+
const relative = paths.relative(paths.resolve(parent), paths.resolve(candidate));
|
|
64
|
+
return relative !== "" && relative !== ".." && !relative.startsWith(`..${paths.sep}`) && !paths.isAbsolute(relative);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function canonicalPathIdentity(
|
|
68
|
+
path: string,
|
|
69
|
+
platform: RuntimePlatform = process.platform,
|
|
70
|
+
resolvePath: (path: string) => Promise<string> = realpath,
|
|
71
|
+
): Promise<string> {
|
|
72
|
+
const paths = pathApi(platform);
|
|
73
|
+
const canonical = paths.resolve(await resolvePath(path));
|
|
74
|
+
return platform === "win32" ? canonical.toLowerCase() : canonical;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function pathsHaveSameIdentity(
|
|
78
|
+
first: string,
|
|
79
|
+
second: string,
|
|
80
|
+
platform: RuntimePlatform = process.platform,
|
|
81
|
+
resolvePath: (path: string) => Promise<string> = realpath,
|
|
82
|
+
): Promise<boolean> {
|
|
83
|
+
const [firstIdentity, secondIdentity] = await Promise.all([
|
|
84
|
+
canonicalPathIdentity(first, platform, resolvePath),
|
|
85
|
+
canonicalPathIdentity(second, platform, resolvePath),
|
|
86
|
+
]);
|
|
87
|
+
return firstIdentity === secondIdentity;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function shutdownSignals(
|
|
91
|
+
platform: RuntimePlatform = process.platform,
|
|
92
|
+
): NodeJS.Signals[] {
|
|
93
|
+
return platform === "win32" ? ["SIGINT", "SIGBREAK"] : ["SIGINT", "SIGTERM", "SIGHUP"];
|
|
94
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { AGENT_DIR } from "./config";
|
|
4
|
+
import { loadHistory } from "./history";
|
|
5
|
+
import { projectStorageKey } from "./platform";
|
|
6
|
+
|
|
7
|
+
export type StashedPrompt = {
|
|
8
|
+
text: string;
|
|
9
|
+
executed: boolean;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
type StashFile = Record<string, (StashedPrompt | string)[]>;
|
|
13
|
+
const STASH_PATH = join(AGENT_DIR, "prompt-stash.json");
|
|
14
|
+
const MAX_ENTRIES = 200;
|
|
15
|
+
|
|
16
|
+
function sortStash(list: StashedPrompt[]): StashedPrompt[] {
|
|
17
|
+
return list.sort((a, b) => Number(b.executed) - Number(a.executed));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function readFile(): StashFile {
|
|
21
|
+
try {
|
|
22
|
+
const parsed = JSON.parse(readFileSync(STASH_PATH, "utf8"));
|
|
23
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
24
|
+
} catch {
|
|
25
|
+
return {};
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function loadPromptStash(cwd: string): StashedPrompt[] {
|
|
30
|
+
const file = readFile();
|
|
31
|
+
const entries = file[projectStorageKey(cwd)] ?? file[cwd];
|
|
32
|
+
if (!Array.isArray(entries)) return [];
|
|
33
|
+
const executedPrompts = new Set(loadHistory(cwd));
|
|
34
|
+
return sortStash(
|
|
35
|
+
entries.flatMap((entry) => {
|
|
36
|
+
if (typeof entry === "string") {
|
|
37
|
+
return [{ text: entry, executed: executedPrompts.has(entry) }];
|
|
38
|
+
}
|
|
39
|
+
return entry && typeof entry.text === "string"
|
|
40
|
+
? [{ text: entry.text, executed: entry.executed === true || executedPrompts.has(entry.text) }]
|
|
41
|
+
: [];
|
|
42
|
+
}),
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Add a prompt to the stash and return the bounded list. */
|
|
47
|
+
export function appendPromptStash(
|
|
48
|
+
cwd: string,
|
|
49
|
+
prompt: string,
|
|
50
|
+
executed = false,
|
|
51
|
+
): StashedPrompt[] {
|
|
52
|
+
const file = readFile();
|
|
53
|
+
const key = projectStorageKey(cwd);
|
|
54
|
+
const list = loadPromptStash(cwd);
|
|
55
|
+
list.push({ text: prompt, executed });
|
|
56
|
+
const trimmed = sortStash(list).slice(-MAX_ENTRIES);
|
|
57
|
+
file[key] = trimmed;
|
|
58
|
+
if (key !== cwd) delete file[cwd];
|
|
59
|
+
try {
|
|
60
|
+
writeFileSync(STASH_PATH, JSON.stringify(file, null, 2));
|
|
61
|
+
} catch {
|
|
62
|
+
// The stash is a convenience; never break a prompt over persistence.
|
|
63
|
+
}
|
|
64
|
+
return trimmed;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Replace one stashed prompt and return the newly sorted list. */
|
|
68
|
+
export function replacePromptStash(
|
|
69
|
+
cwd: string,
|
|
70
|
+
index: number,
|
|
71
|
+
prompt: string,
|
|
72
|
+
executed: boolean,
|
|
73
|
+
): StashedPrompt[] {
|
|
74
|
+
const file = readFile();
|
|
75
|
+
const key = projectStorageKey(cwd);
|
|
76
|
+
const list = loadPromptStash(cwd);
|
|
77
|
+
if (list[index]) list[index] = { text: prompt, executed };
|
|
78
|
+
const sorted = sortStash(list);
|
|
79
|
+
file[key] = sorted;
|
|
80
|
+
if (key !== cwd) delete file[cwd];
|
|
81
|
+
try {
|
|
82
|
+
writeFileSync(STASH_PATH, JSON.stringify(file, null, 2));
|
|
83
|
+
} catch {
|
|
84
|
+
// The stash is a convenience; never break a turn over it.
|
|
85
|
+
}
|
|
86
|
+
return sorted;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Remove one prompt from the stash. */
|
|
90
|
+
export function removePromptStash(cwd: string, index: number): StashedPrompt[] {
|
|
91
|
+
const file = readFile();
|
|
92
|
+
const key = projectStorageKey(cwd);
|
|
93
|
+
const list = loadPromptStash(cwd);
|
|
94
|
+
if (index >= 0 && index < list.length) list.splice(index, 1);
|
|
95
|
+
file[key] = list;
|
|
96
|
+
if (key !== cwd) delete file[cwd];
|
|
97
|
+
try {
|
|
98
|
+
writeFileSync(STASH_PATH, JSON.stringify(file, null, 2));
|
|
99
|
+
} catch {
|
|
100
|
+
// The stash is a convenience; never break input handling over it.
|
|
101
|
+
}
|
|
102
|
+
return list;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Mark selected stashed prompts as executed with one persistence update. */
|
|
106
|
+
export function markPromptStashExecutedMany(
|
|
107
|
+
cwd: string,
|
|
108
|
+
indices: Iterable<number>,
|
|
109
|
+
): StashedPrompt[] {
|
|
110
|
+
const file = readFile();
|
|
111
|
+
const key = projectStorageKey(cwd);
|
|
112
|
+
const selected = new Set(indices);
|
|
113
|
+
const list = loadPromptStash(cwd).map((prompt, index) =>
|
|
114
|
+
selected.has(index) ? { ...prompt, executed: true } : prompt,
|
|
115
|
+
);
|
|
116
|
+
const sorted = sortStash(list);
|
|
117
|
+
file[key] = sorted;
|
|
118
|
+
if (key !== cwd) delete file[cwd];
|
|
119
|
+
try {
|
|
120
|
+
writeFileSync(STASH_PATH, JSON.stringify(file, null, 2));
|
|
121
|
+
} catch {
|
|
122
|
+
// The stash is a convenience; never break a turn over persistence.
|
|
123
|
+
}
|
|
124
|
+
return sorted;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Mark a stashed prompt as executed without adding a duplicate row. */
|
|
128
|
+
export function markPromptStashExecuted(cwd: string, index: number): StashedPrompt[] {
|
|
129
|
+
return markPromptStashExecutedMany(cwd, [index]);
|
|
130
|
+
}
|
package/src/replay.ts
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import type { Line } from "./transcript";
|
|
2
|
+
import { isRejectedToolResult } from "./check-mode";
|
|
3
|
+
import { editCounts, toolArg, type ToolCall } from "./tool-line";
|
|
4
|
+
import {
|
|
5
|
+
WEB_SEARCH_CUSTOM_TYPE,
|
|
6
|
+
type SearchCallRecord,
|
|
7
|
+
} from "./web-search";
|
|
8
|
+
import {
|
|
9
|
+
AGENT_MESSAGE_CUSTOM_TYPE,
|
|
10
|
+
AGENT_MESSAGE_DISPLAY_TYPE,
|
|
11
|
+
SUBAGENT_WAKE_PREFIX,
|
|
12
|
+
TOOL_EVENT_CUSTOM_TYPE,
|
|
13
|
+
type AgentMessageData,
|
|
14
|
+
} from "./subagents/types";
|
|
15
|
+
|
|
16
|
+
const textOf = (content: unknown): string => {
|
|
17
|
+
if (typeof content === "string") return content;
|
|
18
|
+
if (!Array.isArray(content)) return "";
|
|
19
|
+
const text = content
|
|
20
|
+
.filter((b: any) => b?.type === "text" && typeof b.text === "string")
|
|
21
|
+
.map((b: any) => b.text)
|
|
22
|
+
.join("")
|
|
23
|
+
.trim();
|
|
24
|
+
const markers = content
|
|
25
|
+
.filter((b: any) => b?.type === "image")
|
|
26
|
+
.map((_b: any, index: number) => `[Image #${index + 1}]`)
|
|
27
|
+
.join(" ");
|
|
28
|
+
return [text, markers].filter(Boolean).join(" ");
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function agentMessageOf(entry: any): AgentMessageData | undefined {
|
|
32
|
+
const isDisplay = entry?.type === "custom" && entry.customType === AGENT_MESSAGE_DISPLAY_TYPE;
|
|
33
|
+
const isMessage = entry?.type === "custom_message" && entry.customType === AGENT_MESSAGE_CUSTOM_TYPE;
|
|
34
|
+
if (!isDisplay && !isMessage) return undefined;
|
|
35
|
+
const data = isDisplay ? entry.data : entry.details;
|
|
36
|
+
if (!data || typeof data !== "object") return undefined;
|
|
37
|
+
if (typeof data.sender !== "string" || typeof data.recipient !== "string") return undefined;
|
|
38
|
+
return {
|
|
39
|
+
id: typeof data.id === "string" ? data.id : `${entry.id ?? "message"}`,
|
|
40
|
+
sender: data.sender,
|
|
41
|
+
recipient: data.recipient,
|
|
42
|
+
text: typeof data.text === "string" ? data.text : textOf(entry.content),
|
|
43
|
+
at: typeof data.at === "number" ? data.at : 0,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function toolEventOf(entry: any): ToolCall | undefined {
|
|
48
|
+
if (entry?.type !== "custom" || entry.customType !== TOOL_EVENT_CUSTOM_TYPE) return undefined;
|
|
49
|
+
const data = entry.data;
|
|
50
|
+
if (!data || typeof data.id !== "string" || typeof data.name !== "string") return undefined;
|
|
51
|
+
if (!["running", "ok", "error", "rejected"].includes(data.state)) return undefined;
|
|
52
|
+
return {
|
|
53
|
+
id: data.id,
|
|
54
|
+
name: data.name,
|
|
55
|
+
arg: typeof data.arg === "string" ? data.arg : "",
|
|
56
|
+
state: data.state,
|
|
57
|
+
detail: typeof data.detail === "string" ? data.detail : undefined,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function searchRecordOf(entry: any): SearchCallRecord | undefined {
|
|
62
|
+
if (entry?.type !== "custom" || entry.customType !== WEB_SEARCH_CUSTOM_TYPE) return undefined;
|
|
63
|
+
const data = entry.data;
|
|
64
|
+
if (!data || typeof data !== "object" || typeof data.id !== "string") return undefined;
|
|
65
|
+
if (![
|
|
66
|
+
"running",
|
|
67
|
+
"ok",
|
|
68
|
+
"error",
|
|
69
|
+
].includes(data.state)) return undefined;
|
|
70
|
+
return {
|
|
71
|
+
id: data.id,
|
|
72
|
+
query: typeof data.query === "string" ? data.query : "",
|
|
73
|
+
state: data.state,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Rebuild transcript lines from a restored session's active entries, so
|
|
79
|
+
* `pum -r` shows the conversation rather than an empty pane. Messages come
|
|
80
|
+
* from pi and are typed loosely, so every access here is defensive.
|
|
81
|
+
*/
|
|
82
|
+
export function replayEntries(
|
|
83
|
+
entries: readonly any[],
|
|
84
|
+
cwd: string,
|
|
85
|
+
showThinking: boolean,
|
|
86
|
+
): Line[] {
|
|
87
|
+
const lines: Line[] = [];
|
|
88
|
+
const calls = new Map<string, ToolCall>();
|
|
89
|
+
const searchCalls = new Map<string, ToolCall>();
|
|
90
|
+
const customCalls = new Map<string, ToolCall>();
|
|
91
|
+
const agentMessages = new Set<string>();
|
|
92
|
+
|
|
93
|
+
for (const entry of entries) {
|
|
94
|
+
const agentMessage = agentMessageOf(entry);
|
|
95
|
+
if (agentMessage) {
|
|
96
|
+
if (!agentMessages.has(agentMessage.id)) {
|
|
97
|
+
agentMessages.add(agentMessage.id);
|
|
98
|
+
lines.push({
|
|
99
|
+
kind: "agent-message",
|
|
100
|
+
sender: agentMessage.sender,
|
|
101
|
+
recipient: agentMessage.recipient,
|
|
102
|
+
text: agentMessage.text,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const customCall = toolEventOf(entry);
|
|
109
|
+
if (customCall) {
|
|
110
|
+
const existing = customCalls.get(customCall.id);
|
|
111
|
+
if (existing) Object.assign(existing, customCall);
|
|
112
|
+
else {
|
|
113
|
+
customCalls.set(customCall.id, customCall);
|
|
114
|
+
lines.push({ kind: "tool", call: customCall });
|
|
115
|
+
}
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const search = searchRecordOf(entry);
|
|
120
|
+
if (search) {
|
|
121
|
+
const existing = searchCalls.get(search.id);
|
|
122
|
+
if (existing) {
|
|
123
|
+
existing.state = search.state;
|
|
124
|
+
if (search.query) existing.arg = search.query;
|
|
125
|
+
} else {
|
|
126
|
+
const call: ToolCall = {
|
|
127
|
+
id: search.id,
|
|
128
|
+
name: "web_search",
|
|
129
|
+
arg: search.query,
|
|
130
|
+
state: search.state,
|
|
131
|
+
};
|
|
132
|
+
searchCalls.set(search.id, call);
|
|
133
|
+
lines.push({ kind: "tool", call });
|
|
134
|
+
}
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Accept raw AgentMessages too; this keeps the replay helper useful for
|
|
139
|
+
// callers that already have `agent.state.messages`.
|
|
140
|
+
const message = entry?.type === "message" ? entry.message : entry;
|
|
141
|
+
|
|
142
|
+
if (message?.role === "user") {
|
|
143
|
+
const text = textOf(message.content);
|
|
144
|
+
if (text.startsWith(SUBAGENT_WAKE_PREFIX)) continue;
|
|
145
|
+
if (text) lines.push({ kind: "text", role: "user", text });
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (message?.role === "assistant" && Array.isArray(message.content)) {
|
|
150
|
+
for (const block of message.content) {
|
|
151
|
+
if (block?.type === "text" && block.text?.trim()) {
|
|
152
|
+
lines.push({ kind: "text", role: "assistant", text: block.text.trim() });
|
|
153
|
+
} else if (showThinking && block?.type === "thinking" && block.thinking?.trim()) {
|
|
154
|
+
lines.push({ kind: "text", role: "thinking", text: block.thinking.trim() });
|
|
155
|
+
} else if (block?.type === "toolCall") {
|
|
156
|
+
const call: ToolCall = {
|
|
157
|
+
id: block.id,
|
|
158
|
+
name: block.name,
|
|
159
|
+
arg: toolArg(block.name, block.arguments, cwd),
|
|
160
|
+
// Anything replayed has already finished; a matching result may
|
|
161
|
+
// downgrade this to an error below.
|
|
162
|
+
state: "ok",
|
|
163
|
+
};
|
|
164
|
+
calls.set(block.id, call);
|
|
165
|
+
lines.push({ kind: "tool", call });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (message?.role === "toolResult") {
|
|
172
|
+
const call = calls.get(message.toolCallId);
|
|
173
|
+
if (call) {
|
|
174
|
+
call.state = isRejectedToolResult(message)
|
|
175
|
+
? "rejected"
|
|
176
|
+
: message.isError
|
|
177
|
+
? "error"
|
|
178
|
+
: "ok";
|
|
179
|
+
if (call.name === "edit" || call.name === "apply_patch") call.detail = editCounts(message);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return lines;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Backwards-compatible name for replaying a list of messages. */
|
|
188
|
+
export const replayMessages = replayEntries;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { SessionInfo } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { Theme } from "./theme";
|
|
3
|
+
|
|
4
|
+
function optionFor(session: SessionInfo) {
|
|
5
|
+
const title = session.name || session.firstMessage || "(empty session)";
|
|
6
|
+
const modified = session.modified.toLocaleString(undefined, {
|
|
7
|
+
month: "short",
|
|
8
|
+
day: "numeric",
|
|
9
|
+
hour: "numeric",
|
|
10
|
+
minute: "2-digit",
|
|
11
|
+
});
|
|
12
|
+
return {
|
|
13
|
+
name: title.replace(/\s+/g, " ").slice(0, 80),
|
|
14
|
+
description: `${modified} · ${session.messageCount} messages`,
|
|
15
|
+
value: session.path,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function SessionHistoryPopup({
|
|
20
|
+
theme,
|
|
21
|
+
sessions,
|
|
22
|
+
onSelect,
|
|
23
|
+
}: {
|
|
24
|
+
theme: Theme;
|
|
25
|
+
sessions: readonly SessionInfo[];
|
|
26
|
+
onSelect: (path: string) => void;
|
|
27
|
+
}) {
|
|
28
|
+
return (
|
|
29
|
+
<box
|
|
30
|
+
title=" Session history "
|
|
31
|
+
style={{
|
|
32
|
+
position: "absolute",
|
|
33
|
+
top: "10%",
|
|
34
|
+
left: "10%",
|
|
35
|
+
width: "80%",
|
|
36
|
+
height: "80%",
|
|
37
|
+
zIndex: 100,
|
|
38
|
+
border: true,
|
|
39
|
+
borderColor: theme.border,
|
|
40
|
+
backgroundColor: theme.popupBg,
|
|
41
|
+
flexDirection: "column",
|
|
42
|
+
padding: 1,
|
|
43
|
+
}}
|
|
44
|
+
>
|
|
45
|
+
{sessions.length > 0 ? (
|
|
46
|
+
<select
|
|
47
|
+
focused
|
|
48
|
+
backgroundColor={theme.popupBg}
|
|
49
|
+
focusedBackgroundColor={theme.popupBg}
|
|
50
|
+
selectedBackgroundColor={theme.popupBg}
|
|
51
|
+
style={{ flexGrow: 1 }}
|
|
52
|
+
options={sessions.map(optionFor)}
|
|
53
|
+
onSelect={(_index, option) => {
|
|
54
|
+
if (option) onSelect(option.value as string);
|
|
55
|
+
}}
|
|
56
|
+
/>
|
|
57
|
+
) : (
|
|
58
|
+
<text content="No saved sessions for this directory." fg={theme.dim} bg={theme.popupBg} />
|
|
59
|
+
)}
|
|
60
|
+
<text
|
|
61
|
+
content="↑↓ select enter open esc close"
|
|
62
|
+
fg={theme.dim}
|
|
63
|
+
bg={theme.popupBg}
|
|
64
|
+
style={{ flexShrink: 0 }}
|
|
65
|
+
/>
|
|
66
|
+
</box>
|
|
67
|
+
);
|
|
68
|
+
}
|