pum-agent 0.1.3-beta.2 → 0.2.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -2
- package/package.json +1 -1
- package/src/browser-launch.ts +68 -0
- package/src/clipboard.ts +152 -0
- package/src/image-paste.ts +15 -5
- package/src/login-controller.ts +30 -1
- package/src/login-popup.tsx +5 -2
- package/src/main.tsx +16 -4
- package/src/shutdown.ts +14 -8
- package/src/syntax.ts +15 -1
- package/src/transcript.tsx +23 -7
package/README.md
CHANGED
|
@@ -74,7 +74,7 @@ bun run login
|
|
|
74
74
|
bun run start
|
|
75
75
|
```
|
|
76
76
|
|
|
77
|
-
PUM opens the login panel automatically when no provider is available. Use `/login` later to add or update a provider.
|
|
77
|
+
PUM opens the login panel automatically when no provider is available. Use `/login` later to add or update a provider. During browser-based login, PUM opens credential-free HTTP(S) authentication URLs with the platform browser. The URL remains selectable when automatic launch is unavailable.
|
|
78
78
|
|
|
79
79
|
Resume the latest session for the current directory:
|
|
80
80
|
|
|
@@ -131,6 +131,28 @@ Set `PUM_DIR` to override PUM's complete configuration and data directory. Run `
|
|
|
131
131
|
|
|
132
132
|
Useful commands include `/login`, `/history`, `/triggers`, `/clear`, `/compress`, and `/worktree`.
|
|
133
133
|
|
|
134
|
+
### Copy transcript text
|
|
135
|
+
|
|
136
|
+
Drag across transcript text with the left mouse button. PUM copies the completed selection when you release the button.
|
|
137
|
+
|
|
138
|
+
- On local Windows, PUM first uses the native clipboard module. PUM then tries `clip.exe`.
|
|
139
|
+
- On local macOS, PUM first uses the native clipboard module. PUM then tries `pbcopy`.
|
|
140
|
+
- On local Linux, PUM tries `wl-copy`, `xclip`, or `xsel` when the matching display is available.
|
|
141
|
+
- Over SSH or Mosh, PUM sends OSC 52 through OpenTUI. OpenTUI wraps OSC 52 for detected `tmux` sessions.
|
|
142
|
+
|
|
143
|
+
Windows Terminal accepts OSC 52 from remote sessions. The terminal can still ask for clipboard-write approval.
|
|
144
|
+
|
|
145
|
+
For `tmux`, enable clipboard integration and passthrough when the server configuration blocks OSC 52:
|
|
146
|
+
|
|
147
|
+
```tmux
|
|
148
|
+
set -g set-clipboard on
|
|
149
|
+
set -g allow-passthrough on
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Reload the `tmux` configuration after this change. Use the terminal's Shift-drag selection as a manual fallback.
|
|
153
|
+
|
|
154
|
+
PUM limits remote OSC 52 payloads to 100,000 Base64 characters. This limit prevents large selections from corrupting terminal output.
|
|
155
|
+
|
|
134
156
|
## Parallel subagents
|
|
135
157
|
|
|
136
158
|
PUM runs up to 10 active subagents by default. Configure a limit from 1 through 25 in Settings. Only starting and running agents count toward the limit. Each subagent has these resources:
|
|
@@ -264,7 +286,7 @@ Use a throwaway `PUM_DIR` for local integration tests. Capture TUI output throug
|
|
|
264
286
|
|
|
265
287
|
## Release status
|
|
266
288
|
|
|
267
|
-
PUM `0.
|
|
289
|
+
PUM `0.2` is in beta. Interfaces and persisted formats can still change before the stable release. Review the release notes before upgrading sessions or custom configuration.
|
|
268
290
|
|
|
269
291
|
## License
|
|
270
292
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
export type BrowserLaunchCommand = {
|
|
4
|
+
executable: string;
|
|
5
|
+
args: string[];
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export type BrowserProcessSpawner = (
|
|
9
|
+
executable: string,
|
|
10
|
+
args: readonly string[],
|
|
11
|
+
) => Promise<void>;
|
|
12
|
+
|
|
13
|
+
export function credentialFreeHttpUrl(value: string): string | null {
|
|
14
|
+
try {
|
|
15
|
+
const url = new URL(value);
|
|
16
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
|
17
|
+
if (url.username || url.password) return null;
|
|
18
|
+
return url.href;
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function browserLaunchCommand(
|
|
25
|
+
platform: NodeJS.Platform,
|
|
26
|
+
url: string,
|
|
27
|
+
): BrowserLaunchCommand | null {
|
|
28
|
+
if (platform === "win32") {
|
|
29
|
+
return {
|
|
30
|
+
executable: "rundll32.exe",
|
|
31
|
+
args: ["url.dll,FileProtocolHandler", url],
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
if (platform === "darwin") return { executable: "open", args: [url] };
|
|
35
|
+
if (platform === "linux") return { executable: "xdg-open", args: [url] };
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const spawnBrowserProcess: BrowserProcessSpawner = (executable, args) =>
|
|
40
|
+
new Promise<void>((resolve, reject) => {
|
|
41
|
+
const child = spawn(executable, [...args], {
|
|
42
|
+
detached: true,
|
|
43
|
+
stdio: "ignore",
|
|
44
|
+
windowsHide: true,
|
|
45
|
+
});
|
|
46
|
+
child.once("spawn", resolve);
|
|
47
|
+
child.once("error", reject);
|
|
48
|
+
child.unref();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
export async function launchBrowserUrl(
|
|
52
|
+
value: string,
|
|
53
|
+
options: {
|
|
54
|
+
platform?: NodeJS.Platform;
|
|
55
|
+
spawn?: BrowserProcessSpawner;
|
|
56
|
+
} = {},
|
|
57
|
+
): Promise<boolean> {
|
|
58
|
+
const url = credentialFreeHttpUrl(value);
|
|
59
|
+
if (!url) return false;
|
|
60
|
+
const command = browserLaunchCommand(options.platform ?? process.platform, url);
|
|
61
|
+
if (!command) return false;
|
|
62
|
+
try {
|
|
63
|
+
await (options.spawn ?? spawnBrowserProcess)(command.executable, command.args);
|
|
64
|
+
return true;
|
|
65
|
+
} catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
package/src/clipboard.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { CliRenderEvents, type CliRenderer, type Selection } from "@opentui/core";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
|
|
4
|
+
const MAX_OSC52_ENCODED_LENGTH = 100_000;
|
|
5
|
+
|
|
6
|
+
type Environment = Record<string, string | undefined>;
|
|
7
|
+
type NativeClipboard = { setText(text: string): Promise<void> };
|
|
8
|
+
type CommandRunner = (command: string, args: string[], input: string) => Promise<void>;
|
|
9
|
+
type Osc52Writer = (text: string) => boolean;
|
|
10
|
+
|
|
11
|
+
export type ClipboardRoute = "native" | "command" | "osc52";
|
|
12
|
+
|
|
13
|
+
export type ClipboardCopyOptions = {
|
|
14
|
+
platform?: NodeJS.Platform;
|
|
15
|
+
env?: Environment;
|
|
16
|
+
nativeClipboard?: NativeClipboard | null;
|
|
17
|
+
runner?: CommandRunner;
|
|
18
|
+
osc52?: Osc52Writer;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type SelectionClipboardBinding = {
|
|
22
|
+
dispose(): void;
|
|
23
|
+
flush(): Promise<void>;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function isRemoteSession(env: Environment): boolean {
|
|
27
|
+
return Boolean(env.SSH_CONNECTION || env.SSH_CLIENT || env.MOSH_CONNECTION);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function osc52PayloadFits(text: string): boolean {
|
|
31
|
+
return Math.ceil(Buffer.byteLength(text, "utf8") / 3) * 4 <= MAX_OSC52_ENCODED_LENGTH;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function loadNativeClipboard(): Promise<NativeClipboard | null> {
|
|
35
|
+
try {
|
|
36
|
+
return await import("@mariozechner/clipboard");
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function runClipboardCommand(command: string, args: string[], input: string): Promise<void> {
|
|
43
|
+
return new Promise((resolve, reject) => {
|
|
44
|
+
const child = spawn(command, args, {
|
|
45
|
+
stdio: ["pipe", "ignore", "ignore"],
|
|
46
|
+
windowsHide: true,
|
|
47
|
+
});
|
|
48
|
+
const timer = setTimeout(() => {
|
|
49
|
+
child.kill();
|
|
50
|
+
reject(new Error(`${command} timed out`));
|
|
51
|
+
}, 5000);
|
|
52
|
+
child.on("error", (error) => {
|
|
53
|
+
clearTimeout(timer);
|
|
54
|
+
reject(error);
|
|
55
|
+
});
|
|
56
|
+
child.on("close", (code) => {
|
|
57
|
+
clearTimeout(timer);
|
|
58
|
+
if (code === 0) resolve();
|
|
59
|
+
else reject(new Error(`${command} failed`));
|
|
60
|
+
});
|
|
61
|
+
child.stdin.on("error", () => {});
|
|
62
|
+
child.stdin.end(input);
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function tryCommand(
|
|
67
|
+
runner: CommandRunner,
|
|
68
|
+
command: string,
|
|
69
|
+
args: string[],
|
|
70
|
+
text: string,
|
|
71
|
+
): Promise<boolean> {
|
|
72
|
+
try {
|
|
73
|
+
await runner(command, args, text);
|
|
74
|
+
return true;
|
|
75
|
+
} catch {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Copy completed OpenTUI selections without assuming local graphical access. */
|
|
81
|
+
export async function copySelectionText(
|
|
82
|
+
text: string,
|
|
83
|
+
options: ClipboardCopyOptions = {},
|
|
84
|
+
): Promise<ClipboardRoute> {
|
|
85
|
+
if (!text) throw new Error("Cannot copy an empty selection");
|
|
86
|
+
|
|
87
|
+
const platform = options.platform ?? process.platform;
|
|
88
|
+
const env = options.env ?? process.env;
|
|
89
|
+
const osc52 = options.osc52 ?? (() => false);
|
|
90
|
+
|
|
91
|
+
if (isRemoteSession(env)) {
|
|
92
|
+
if (osc52PayloadFits(text) && osc52(text)) return "osc52";
|
|
93
|
+
throw new Error("The remote terminal did not accept the OSC 52 clipboard copy");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (platform === "win32" || platform === "darwin") {
|
|
97
|
+
const clipboard = options.nativeClipboard === undefined
|
|
98
|
+
? await loadNativeClipboard()
|
|
99
|
+
: options.nativeClipboard;
|
|
100
|
+
try {
|
|
101
|
+
if (clipboard) {
|
|
102
|
+
await clipboard.setText(text);
|
|
103
|
+
return "native";
|
|
104
|
+
}
|
|
105
|
+
} catch {
|
|
106
|
+
// Use the platform command before the terminal fallback.
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const runner = options.runner ?? runClipboardCommand;
|
|
111
|
+
if (platform === "win32" && await tryCommand(runner, "clip.exe", [], text)) return "command";
|
|
112
|
+
if (platform === "darwin" && await tryCommand(runner, "pbcopy", [], text)) return "command";
|
|
113
|
+
if (platform === "linux" && env.WAYLAND_DISPLAY
|
|
114
|
+
&& await tryCommand(runner, "wl-copy", [], text)) return "command";
|
|
115
|
+
if (platform === "linux" && env.DISPLAY) {
|
|
116
|
+
if (await tryCommand(runner, "xclip", ["-selection", "clipboard"], text)) return "command";
|
|
117
|
+
if (await tryCommand(runner, "xsel", ["--clipboard", "--input"], text)) return "command";
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (osc52PayloadFits(text) && osc52(text)) return "osc52";
|
|
121
|
+
throw new Error("No supported clipboard route accepted the selected text");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function installSelectionClipboard(
|
|
125
|
+
renderer: CliRenderer,
|
|
126
|
+
options: Omit<ClipboardCopyOptions, "osc52"> & {
|
|
127
|
+
onError?: (error: unknown) => void;
|
|
128
|
+
} = {},
|
|
129
|
+
): SelectionClipboardBinding {
|
|
130
|
+
let pending = Promise.resolve();
|
|
131
|
+
const onSelection = (selection: Selection) => {
|
|
132
|
+
const text = selection.getSelectedText();
|
|
133
|
+
if (!text) return;
|
|
134
|
+
pending = pending
|
|
135
|
+
.then(() => copySelectionText(text, {
|
|
136
|
+
...options,
|
|
137
|
+
osc52: (value) => renderer.copyToClipboardOSC52(value),
|
|
138
|
+
}))
|
|
139
|
+
.then(() => undefined)
|
|
140
|
+
.catch((error) => options.onError?.(error));
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
renderer.on(CliRenderEvents.SELECTION, onSelection);
|
|
144
|
+
return {
|
|
145
|
+
dispose() {
|
|
146
|
+
renderer.off(CliRenderEvents.SELECTION, onSelection);
|
|
147
|
+
},
|
|
148
|
+
flush() {
|
|
149
|
+
return pending;
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|
package/src/image-paste.ts
CHANGED
|
@@ -35,7 +35,7 @@ function ensureImageDir(): string {
|
|
|
35
35
|
type CommandRunner = (command: string, args: string[]) => Promise<Buffer>;
|
|
36
36
|
type NativeClipboard = {
|
|
37
37
|
hasImage(): boolean;
|
|
38
|
-
getImageBinary(): Promise<
|
|
38
|
+
getImageBinary(): Promise<ArrayLike<number>>;
|
|
39
39
|
};
|
|
40
40
|
|
|
41
41
|
export type ClipboardBackend = "windows" | "wayland" | "x11";
|
|
@@ -107,10 +107,17 @@ async function readWindowsClipboard(
|
|
|
107
107
|
nativeClipboard: NativeClipboard | null | undefined,
|
|
108
108
|
): Promise<{ data: Buffer; mimeType: string; ext: string }> {
|
|
109
109
|
const clipboard = nativeClipboard === undefined ? await loadNativeClipboard() : nativeClipboard;
|
|
110
|
-
if (clipboard
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
110
|
+
if (clipboard) {
|
|
111
|
+
try {
|
|
112
|
+
if (clipboard.hasImage()) {
|
|
113
|
+
const bytes = await clipboard.getImageBinary();
|
|
114
|
+
if (bytes.length > 0) {
|
|
115
|
+
return { data: Buffer.from(bytes), mimeType: "image/png", ext: "png" };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
} catch {
|
|
119
|
+
// Native access can fail while another Windows process owns the clipboard.
|
|
120
|
+
// PowerShell gives Windows a second clipboard path before the paste fails.
|
|
114
121
|
}
|
|
115
122
|
}
|
|
116
123
|
|
|
@@ -172,6 +179,9 @@ export async function captureClipboardImage(options: {
|
|
|
172
179
|
else if (backend === "x11") image = await readX11Clipboard(runner);
|
|
173
180
|
else throw new Error("No supported graphical clipboard is available");
|
|
174
181
|
|
|
182
|
+
if (image.data.length > MAX_IMAGE_BYTES) {
|
|
183
|
+
throw new Error("Clipboard image is larger than 25 MB");
|
|
184
|
+
}
|
|
175
185
|
const path = join(ensureImageDir(), `image-${++fileSequence}.${image.ext}`);
|
|
176
186
|
writeFileSync(path, image.data);
|
|
177
187
|
return { path, mimeType: image.mimeType };
|
package/src/login-controller.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { AuthEvent, AuthPrompt } from "@earendil-works/pi-ai";
|
|
2
2
|
import type { AgentSession, ModelRuntime } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { credentialFreeHttpUrl, launchBrowserUrl } from "./browser-launch";
|
|
3
4
|
import type { LoginPage } from "./login-popup";
|
|
4
5
|
import {
|
|
5
6
|
customProviderId,
|
|
@@ -52,6 +53,8 @@ export class LoginController {
|
|
|
52
53
|
private providerQuery = "";
|
|
53
54
|
private providerSearchFocused = true;
|
|
54
55
|
private retry?: () => void;
|
|
56
|
+
private latestBrowserAuthEvent?: Extract<AuthEvent, { type: "auth_url" | "device_code" }>;
|
|
57
|
+
private launchedAuthUrls = new Set<string>();
|
|
55
58
|
|
|
56
59
|
constructor(
|
|
57
60
|
private runtime: ModelRuntime,
|
|
@@ -59,6 +62,7 @@ export class LoginController {
|
|
|
59
62
|
private show: (page: LoginPage) => void,
|
|
60
63
|
private complete: (modelId?: string) => void,
|
|
61
64
|
private closePopup: () => void,
|
|
65
|
+
private launchUrl: (url: string) => Promise<boolean> = launchBrowserUrl,
|
|
62
66
|
) {
|
|
63
67
|
this.page = this.providerPage();
|
|
64
68
|
}
|
|
@@ -86,6 +90,8 @@ export class LoginController {
|
|
|
86
90
|
|
|
87
91
|
open() {
|
|
88
92
|
this.cancelOperation();
|
|
93
|
+
this.latestBrowserAuthEvent = undefined;
|
|
94
|
+
this.launchedAuthUrls.clear();
|
|
89
95
|
this.retry = undefined;
|
|
90
96
|
this.providerCursor = 0;
|
|
91
97
|
this.providerQuery = "";
|
|
@@ -153,11 +159,26 @@ export class LoginController {
|
|
|
153
159
|
}
|
|
154
160
|
const controller = new AbortController();
|
|
155
161
|
this.cancelOperation();
|
|
162
|
+
this.latestBrowserAuthEvent = undefined;
|
|
163
|
+
this.launchedAuthUrls.clear();
|
|
156
164
|
this.controller = controller;
|
|
157
165
|
this.setPage({ kind: "working", providerName: method.providerName });
|
|
158
166
|
void this.runtime.login(method.providerId, method.authType, {
|
|
159
167
|
signal: controller.signal,
|
|
160
168
|
notify: (event: AuthEvent) => {
|
|
169
|
+
if (event.type === "auth_url" || event.type === "device_code") {
|
|
170
|
+
this.latestBrowserAuthEvent = event;
|
|
171
|
+
}
|
|
172
|
+
const eventUrl = event.type === "auth_url"
|
|
173
|
+
? event.url
|
|
174
|
+
: event.type === "device_code"
|
|
175
|
+
? event.verificationUri
|
|
176
|
+
: undefined;
|
|
177
|
+
const safeUrl = eventUrl ? credentialFreeHttpUrl(eventUrl) : null;
|
|
178
|
+
if (safeUrl && !this.launchedAuthUrls.has(safeUrl)) {
|
|
179
|
+
this.launchedAuthUrls.add(safeUrl);
|
|
180
|
+
void this.launchUrl(safeUrl).catch(() => {});
|
|
181
|
+
}
|
|
161
182
|
if (!this.promptWaiter) this.setPage({ kind: "working", providerName: method.providerName, event });
|
|
162
183
|
},
|
|
163
184
|
prompt: (prompt: AuthPrompt) => new Promise<string>((resolve, reject) => {
|
|
@@ -173,7 +194,15 @@ export class LoginController {
|
|
|
173
194
|
const onAbort = () => rejectPrompt(new Error("Login cancelled"));
|
|
174
195
|
prompt.signal?.addEventListener("abort", onAbort, { once: true });
|
|
175
196
|
this.promptWaiter = { prompt, resolve: resolvePrompt, reject: rejectPrompt };
|
|
176
|
-
this.setPage({
|
|
197
|
+
this.setPage({
|
|
198
|
+
kind: "prompt",
|
|
199
|
+
providerName: method.providerName,
|
|
200
|
+
prompt,
|
|
201
|
+
event: this.latestBrowserAuthEvent,
|
|
202
|
+
cursor: 0,
|
|
203
|
+
value: "",
|
|
204
|
+
secretLength: 0,
|
|
205
|
+
});
|
|
177
206
|
}),
|
|
178
207
|
}).then(() => {
|
|
179
208
|
this.promptWaiter = undefined;
|
package/src/login-popup.tsx
CHANGED
|
@@ -7,7 +7,7 @@ import { PopupFrame } from "./popup-frame";
|
|
|
7
7
|
|
|
8
8
|
export type LoginPage =
|
|
9
9
|
| { kind: "providers"; methods: readonly LoginMethod[]; cursor: number; query: string; searchFocused: boolean; customVisible: boolean }
|
|
10
|
-
| { kind: "prompt"; providerName: string; prompt: AuthPrompt; cursor: number; value: string; secretLength: number }
|
|
10
|
+
| { kind: "prompt"; providerName: string; prompt: AuthPrompt; event?: AuthEvent; cursor: number; value: string; secretLength: number }
|
|
11
11
|
| { kind: "working"; providerName: string; event?: AuthEvent }
|
|
12
12
|
| { kind: "custom-endpoint"; endpoint: string }
|
|
13
13
|
| { kind: "custom-key"; endpoint: string; secretLength: number }
|
|
@@ -151,6 +151,9 @@ export function LoginPopup({ theme, page, terminalWidth, terminalHeight, onProvi
|
|
|
151
151
|
<text content={terminalWidth < 48 ? "/ search ↑↓ move enter esc" : "/ search ↑↓ move enter select esc close"} fg={theme.dim} bg={theme.popupBg} wrapMode="none" style={{ height: 1, flexShrink: 0 }} />
|
|
152
152
|
</> : page.kind === "prompt" ? <>
|
|
153
153
|
<text content={page.providerName} fg={theme.accent} bg={theme.popupBg} />
|
|
154
|
+
{page.event?.type === "auth_url" || page.event?.type === "device_code"
|
|
155
|
+
? <EventDetails theme={theme} event={page.event} />
|
|
156
|
+
: null}
|
|
154
157
|
<text content={page.prompt.message} fg={theme.fg} bg={theme.popupBg} wrapMode="word" />
|
|
155
158
|
<box style={{ height: 1, flexShrink: 0 }} />
|
|
156
159
|
{page.prompt.type === "select" ? page.prompt.options.map((option, index) => {
|
|
@@ -165,7 +168,7 @@ export function LoginPopup({ theme, page, terminalWidth, terminalHeight, onProvi
|
|
|
165
168
|
<text content={page.providerName} fg={theme.accent} bg={theme.popupBg} />
|
|
166
169
|
<box style={{ height: 1, flexShrink: 0 }} />
|
|
167
170
|
<EventDetails theme={theme} event={page.event} />
|
|
168
|
-
<text content="URLs and codes
|
|
171
|
+
<text content="PUM opens safe URLs automatically. URLs and codes remain selectable. Esc cancels." fg={theme.dim} bg={theme.popupBg} style={{ marginTop: 1 }} />
|
|
169
172
|
</> : page.kind === "custom-endpoint" ? <>
|
|
170
173
|
<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" />
|
|
171
174
|
<box style={{ height: 1, flexShrink: 0 }} />
|
package/src/main.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createCliRenderer } from "@opentui/core";
|
|
1
|
+
import { createCliRenderer, destroyTreeSitterClient } from "@opentui/core";
|
|
2
2
|
import { createRoot } from "@opentui/react";
|
|
3
3
|
import {
|
|
4
4
|
createAgentSessionFromServices,
|
|
@@ -28,6 +28,7 @@ import { SubagentManager } from "./subagents/manager";
|
|
|
28
28
|
import { cleanupPendingImages } from "./image-paste";
|
|
29
29
|
import { shutdownSignals } from "./platform";
|
|
30
30
|
import { createShutdown } from "./shutdown";
|
|
31
|
+
import { settleSyntaxHighlighting } from "./syntax";
|
|
31
32
|
import { applyPatchExtension } from "./apply-patch";
|
|
32
33
|
import { QuestionnaireManager } from "./questionnaire";
|
|
33
34
|
import { SpawnPreviewManager } from "./subagents/spawn-preview";
|
|
@@ -40,6 +41,7 @@ import {
|
|
|
40
41
|
systemTriggerClock,
|
|
41
42
|
} from "./triggers/process";
|
|
42
43
|
import type { StartupOptions } from "./cli";
|
|
44
|
+
import { installSelectionClipboard } from "./clipboard";
|
|
43
45
|
|
|
44
46
|
export async function start(options: StartupOptions): Promise<void> {
|
|
45
47
|
mkdirSync(AGENT_DIR, { recursive: true });
|
|
@@ -183,13 +185,23 @@ export async function start(options: StartupOptions): Promise<void> {
|
|
|
183
185
|
if (sessionRuntime.modelFallbackMessage) console.error(sessionRuntime.modelFallbackMessage);
|
|
184
186
|
|
|
185
187
|
const renderer = await createCliRenderer({ exitOnCtrlC: false });
|
|
188
|
+
const selectionClipboard = installSelectionClipboard(renderer);
|
|
186
189
|
const root = createRoot(renderer);
|
|
187
190
|
const shutdown = createShutdown({
|
|
188
|
-
unmount: () =>
|
|
189
|
-
|
|
191
|
+
unmount: async () => {
|
|
192
|
+
await settleSyntaxHighlighting(renderer.root);
|
|
193
|
+
root.unmount();
|
|
194
|
+
},
|
|
195
|
+
cleanup: () => {
|
|
196
|
+
selectionClipboard.dispose();
|
|
197
|
+
cleanupPendingImages();
|
|
198
|
+
},
|
|
190
199
|
shutdownTriggers: () => triggerManager.shutdown(),
|
|
191
200
|
dispose: () => sessionRuntime.dispose(),
|
|
192
|
-
destroy: () =>
|
|
201
|
+
destroy: async () => {
|
|
202
|
+
renderer.destroy();
|
|
203
|
+
await destroyTreeSitterClient();
|
|
204
|
+
},
|
|
193
205
|
exit: (code) => process.exit(code),
|
|
194
206
|
});
|
|
195
207
|
for (const signal of shutdownSignals()) {
|
package/src/shutdown.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
export type ShutdownActions = {
|
|
2
|
-
unmount(): void
|
|
2
|
+
unmount(): void | Promise<void>;
|
|
3
3
|
cleanup(): void;
|
|
4
4
|
shutdownTriggers?(): Promise<void>;
|
|
5
5
|
dispose(): Promise<void>;
|
|
6
|
-
destroy(): void
|
|
6
|
+
destroy(): void | Promise<void>;
|
|
7
7
|
exit(code: number): void;
|
|
8
8
|
};
|
|
9
9
|
|
|
@@ -13,16 +13,22 @@ export function createShutdown(actions: ShutdownActions): (code: number) => Prom
|
|
|
13
13
|
if (exiting) return;
|
|
14
14
|
exiting = true;
|
|
15
15
|
try {
|
|
16
|
-
actions.unmount();
|
|
17
|
-
actions.cleanup();
|
|
18
16
|
try {
|
|
19
|
-
await actions.
|
|
17
|
+
await actions.unmount();
|
|
20
18
|
} finally {
|
|
21
|
-
|
|
19
|
+
actions.cleanup();
|
|
20
|
+
try {
|
|
21
|
+
await actions.shutdownTriggers?.();
|
|
22
|
+
} finally {
|
|
23
|
+
await actions.dispose();
|
|
24
|
+
}
|
|
22
25
|
}
|
|
23
26
|
} finally {
|
|
24
|
-
|
|
25
|
-
|
|
27
|
+
try {
|
|
28
|
+
await actions.destroy();
|
|
29
|
+
} finally {
|
|
30
|
+
actions.exit(code);
|
|
31
|
+
}
|
|
26
32
|
}
|
|
27
33
|
};
|
|
28
34
|
}
|
package/src/syntax.ts
CHANGED
|
@@ -1,6 +1,20 @@
|
|
|
1
|
-
import { SyntaxStyle } from "@opentui/core";
|
|
1
|
+
import { CodeRenderable, SyntaxStyle, type BaseRenderable } from "@opentui/core";
|
|
2
2
|
import type { Theme } from "./theme";
|
|
3
3
|
|
|
4
|
+
/** Wait for every highlight request that belongs to a renderable tree. */
|
|
5
|
+
export async function settleSyntaxHighlighting(root: BaseRenderable): Promise<void> {
|
|
6
|
+
const pending: Promise<void>[] = [];
|
|
7
|
+
const visit = (renderable: BaseRenderable) => {
|
|
8
|
+
if (renderable instanceof CodeRenderable) pending.push(renderable.highlightingDone);
|
|
9
|
+
for (const child of renderable.getChildren()) visit(child);
|
|
10
|
+
};
|
|
11
|
+
visit(root);
|
|
12
|
+
|
|
13
|
+
const results = await Promise.allSettled(pending);
|
|
14
|
+
const failure = results.find((result): result is PromiseRejectedResult => result.status === "rejected");
|
|
15
|
+
if (failure) throw failure.reason;
|
|
16
|
+
}
|
|
17
|
+
|
|
4
18
|
/**
|
|
5
19
|
* OpenTUI's <markdown> and <code> need a SyntaxStyle and ship no default. The
|
|
6
20
|
* keys are tree-sitter capture names from the bundled highlight queries;
|
package/src/transcript.tsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { StyledText, fg, type SyntaxStyle } from "@opentui/core";
|
|
1
|
+
import { StyledText, fg, type MarkdownRenderable, type SyntaxStyle } from "@opentui/core";
|
|
2
|
+
import type { MarkdownProps } from "@opentui/react";
|
|
2
3
|
import {
|
|
3
4
|
useBlinkingText,
|
|
4
5
|
useMarkdownCaret,
|
|
@@ -32,6 +33,16 @@ export type PendingTranscriptState = {
|
|
|
32
33
|
pending: PendingLine[];
|
|
33
34
|
};
|
|
34
35
|
|
|
36
|
+
/** OpenTUI 0.5.1 supports Markdown selection at runtime but omits the React prop. */
|
|
37
|
+
function SelectableMarkdown({ ref, ...props }: MarkdownProps) {
|
|
38
|
+
const setRef = (renderable: MarkdownRenderable | null) => {
|
|
39
|
+
if (renderable) renderable.selectable = true;
|
|
40
|
+
if (typeof ref === "function") ref(renderable);
|
|
41
|
+
else if (ref) ref.current = renderable;
|
|
42
|
+
};
|
|
43
|
+
return <markdown {...props} ref={setRef} />;
|
|
44
|
+
}
|
|
45
|
+
|
|
35
46
|
/** Resolve a delivered message without splitting the active streamed output. */
|
|
36
47
|
export function resolvePendingDelivery<T extends PendingTranscriptState>(value: T, id: string): T {
|
|
37
48
|
const pending = value.pending.find((item) => item.id === id);
|
|
@@ -187,7 +198,7 @@ export function TextLine({
|
|
|
187
198
|
glyphColor={color}
|
|
188
199
|
background={isUser ? theme.userBg : undefined}
|
|
189
200
|
>
|
|
190
|
-
<
|
|
201
|
+
<SelectableMarkdown
|
|
191
202
|
ref={isAssistant && workingCaret ? markdownCaret.ref : undefined}
|
|
192
203
|
content={isAssistant && workingCaret ? markdownCaret.content : text}
|
|
193
204
|
streaming={false}
|
|
@@ -205,6 +216,7 @@ export function TextLine({
|
|
|
205
216
|
ref={workingCaret ? textCaret : undefined}
|
|
206
217
|
content={workingCaret ? undefined : displayText}
|
|
207
218
|
fg={color}
|
|
219
|
+
selectable
|
|
208
220
|
wrapMode="word"
|
|
209
221
|
style={{ flexGrow: 1, flexShrink: 1, minWidth: 0, width: "100%" }}
|
|
210
222
|
/>
|
|
@@ -238,7 +250,7 @@ export function StreamLine({
|
|
|
238
250
|
return (
|
|
239
251
|
<Row glyph={GUTTER} glyphColor={color}>
|
|
240
252
|
{role === "assistant" ? (
|
|
241
|
-
<
|
|
253
|
+
<SelectableMarkdown
|
|
242
254
|
ref={markdown.ref}
|
|
243
255
|
content={markdown.content}
|
|
244
256
|
streaming
|
|
@@ -249,6 +261,7 @@ export function StreamLine({
|
|
|
249
261
|
) : (
|
|
250
262
|
<text
|
|
251
263
|
ref={shimmer}
|
|
264
|
+
selectable
|
|
252
265
|
wrapMode="word"
|
|
253
266
|
style={{ flexGrow: 1, flexShrink: 1, minWidth: 0, width: "100%" }}
|
|
254
267
|
/>
|
|
@@ -274,10 +287,11 @@ export function PendingMessageLine({
|
|
|
274
287
|
<text
|
|
275
288
|
content={`${line.sender} → ${line.recipient} · queued`}
|
|
276
289
|
fg={theme.dim}
|
|
290
|
+
selectable
|
|
277
291
|
wrapMode="word"
|
|
278
292
|
style={{ width: "100%", flexShrink: 1, minWidth: 0 }}
|
|
279
293
|
/>
|
|
280
|
-
<
|
|
294
|
+
<SelectableMarkdown
|
|
281
295
|
content={line.text}
|
|
282
296
|
streaming={false}
|
|
283
297
|
syntaxStyle={syntaxStyle}
|
|
@@ -291,7 +305,7 @@ export function PendingMessageLine({
|
|
|
291
305
|
|
|
292
306
|
return (
|
|
293
307
|
<Row glyph="○ " glyphColor={theme.dim} background={theme.userBg}>
|
|
294
|
-
<
|
|
308
|
+
<SelectableMarkdown
|
|
295
309
|
content={line.text}
|
|
296
310
|
streaming={false}
|
|
297
311
|
syntaxStyle={syntaxStyle}
|
|
@@ -317,10 +331,11 @@ export function AgentMessageLine({
|
|
|
317
331
|
<text
|
|
318
332
|
content={`${line.sender} → ${line.recipient}`}
|
|
319
333
|
fg={theme.agentMessage}
|
|
334
|
+
selectable
|
|
320
335
|
wrapMode="word"
|
|
321
336
|
style={{ width: "100%", flexShrink: 1, minWidth: 0 }}
|
|
322
337
|
/>
|
|
323
|
-
<
|
|
338
|
+
<SelectableMarkdown
|
|
324
339
|
content={line.text}
|
|
325
340
|
streaming={false}
|
|
326
341
|
syntaxStyle={syntaxStyle}
|
|
@@ -376,10 +391,11 @@ export function ToolLine({
|
|
|
376
391
|
background={rejected ? theme.rejectionBg : undefined}
|
|
377
392
|
>
|
|
378
393
|
<box style={{ flexDirection: "row", flexGrow: 1, flexShrink: 1, minWidth: 0 }}>
|
|
379
|
-
{prefix ? <text content={prefix} style={{ flexShrink: 0 }} /> : null}
|
|
394
|
+
{prefix ? <text content={prefix} selectable style={{ flexShrink: 0 }} /> : null}
|
|
380
395
|
<text
|
|
381
396
|
ref={workingCaret ? caret : undefined}
|
|
382
397
|
content={workingCaret ? undefined : new StyledText(bodyChunks)}
|
|
398
|
+
selectable
|
|
383
399
|
wrapMode="word"
|
|
384
400
|
style={{ flexGrow: 1, flexShrink: 1, minWidth: 0 }}
|
|
385
401
|
/>
|