pum-agent 0.2.4-beta.1 → 0.2.5-beta.1
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 +2 -0
- package/package.json +1 -1
- package/src/app.tsx +31 -2
- package/src/login-controller.ts +39 -0
- package/src/text-paste.ts +109 -0
- package/src/transcript.tsx +13 -8
package/README.md
CHANGED
|
@@ -91,6 +91,8 @@ bun run start
|
|
|
91
91
|
|
|
92
92
|
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.
|
|
93
93
|
|
|
94
|
+
Custom OpenAI-compatible provider fields accept terminal bracketed paste and local `Ctrl+V` clipboard paste for endpoint URLs and API keys. PUM routes pasted API keys directly to the login controller and renders only a length mask. Remote sessions do not invoke a local host clipboard command.
|
|
95
|
+
|
|
94
96
|
Resume the latest session for the current directory:
|
|
95
97
|
|
|
96
98
|
```bash
|
package/package.json
CHANGED
package/src/app.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { decodePasteBytes, type ScrollBoxRenderable, type TextareaRenderable } from "@opentui/core";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
-
import { useKeyboard, useTerminalDimensions } from "@opentui/react";
|
|
3
|
+
import { useKeyboard, usePaste, useTerminalDimensions } from "@opentui/react";
|
|
4
4
|
import type { Model } from "@earendil-works/pi-ai";
|
|
5
5
|
import type { AgentSession, ModelRuntime } from "@earendil-works/pi-coding-agent";
|
|
6
6
|
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
|
|
@@ -121,6 +121,7 @@ import {
|
|
|
121
121
|
type TriggerManagerLike,
|
|
122
122
|
} from "./triggers/popup";
|
|
123
123
|
import type { TerminalTitleController } from "./terminal-title";
|
|
124
|
+
import { readClipboardText } from "./text-paste";
|
|
124
125
|
|
|
125
126
|
type Stream = { kind: "assistant" | "thinking"; text: string } | null;
|
|
126
127
|
type Transcript = { lines: Line[]; stream: Stream; pending: PendingLine[] };
|
|
@@ -341,6 +342,7 @@ export function App({
|
|
|
341
342
|
promptHistoryStore = DEFAULT_PROMPT_HISTORY_STORE,
|
|
342
343
|
promptStashStore = DEFAULT_PROMPT_STASH_STORE,
|
|
343
344
|
captureImage = captureClipboardImage,
|
|
345
|
+
readPastedText = readClipboardText,
|
|
344
346
|
onExit = () => process.exit(0),
|
|
345
347
|
checkApprovalCoordinator,
|
|
346
348
|
checkApprovalStore,
|
|
@@ -366,6 +368,7 @@ export function App({
|
|
|
366
368
|
promptHistoryStore?: PromptHistoryStore;
|
|
367
369
|
promptStashStore?: PromptStashStore;
|
|
368
370
|
captureImage?: typeof captureClipboardImage;
|
|
371
|
+
readPastedText?: typeof readClipboardText;
|
|
369
372
|
onExit?: () => void | Promise<void>;
|
|
370
373
|
checkApprovalCoordinator?: CheckApprovalCoordinator;
|
|
371
374
|
checkApprovalStore?: CheckApprovalStore;
|
|
@@ -507,6 +510,7 @@ export function App({
|
|
|
507
510
|
const nextImageId = useRef(1);
|
|
508
511
|
const lastInputValue = useRef("");
|
|
509
512
|
const imagePasteBusy = useRef(false);
|
|
513
|
+
const loginTextPasteBusy = useRef(false);
|
|
510
514
|
const viewDrafts = useRef(new Map<string, string>());
|
|
511
515
|
const viewEditingStashIndices = useRef(new Map<string, number | null>());
|
|
512
516
|
const spawnPreviewRestoreView = useRef<{ active: boolean; agentId: string | null }>({
|
|
@@ -787,6 +791,19 @@ export function App({
|
|
|
787
791
|
}
|
|
788
792
|
};
|
|
789
793
|
|
|
794
|
+
const pasteLoginClipboardText = async () => {
|
|
795
|
+
if (loginTextPasteBusy.current) return;
|
|
796
|
+
loginTextPasteBusy.current = true;
|
|
797
|
+
try {
|
|
798
|
+
const text = await readPastedText();
|
|
799
|
+
loginControllerRef.current?.pasteText(text);
|
|
800
|
+
} catch {
|
|
801
|
+
append({ kind: "text", role: "error", text: "text paste failed" });
|
|
802
|
+
} finally {
|
|
803
|
+
loginTextPasteBusy.current = false;
|
|
804
|
+
}
|
|
805
|
+
};
|
|
806
|
+
|
|
790
807
|
const append = (line: Line) =>
|
|
791
808
|
setTx((t) => {
|
|
792
809
|
const f = flushed(t);
|
|
@@ -1698,6 +1715,13 @@ export function App({
|
|
|
1698
1715
|
selectAgentView(ids[next] ?? null);
|
|
1699
1716
|
};
|
|
1700
1717
|
|
|
1718
|
+
usePaste((event) => {
|
|
1719
|
+
const controller = loginControllerRef.current;
|
|
1720
|
+
if (!loginOpen || !controller?.acceptsTextPaste()) return;
|
|
1721
|
+
event.stopPropagation();
|
|
1722
|
+
controller.pasteText(decodePasteBytes(event.bytes));
|
|
1723
|
+
});
|
|
1724
|
+
|
|
1701
1725
|
useKeyboard((key) => {
|
|
1702
1726
|
if (checkApproval) {
|
|
1703
1727
|
key.stopPropagation();
|
|
@@ -1814,6 +1838,11 @@ export function App({
|
|
|
1814
1838
|
}
|
|
1815
1839
|
|
|
1816
1840
|
if (loginOpen) {
|
|
1841
|
+
if (key.ctrl && key.name === "v" && loginControllerRef.current?.acceptsTextPaste()) {
|
|
1842
|
+
key.stopPropagation();
|
|
1843
|
+
void pasteLoginClipboardText();
|
|
1844
|
+
return;
|
|
1845
|
+
}
|
|
1817
1846
|
if (loginControllerRef.current?.handleKey(key)) key.stopPropagation();
|
|
1818
1847
|
return;
|
|
1819
1848
|
}
|
package/src/login-controller.ts
CHANGED
|
@@ -20,6 +20,10 @@ export type LoginKey = {
|
|
|
20
20
|
option?: boolean;
|
|
21
21
|
};
|
|
22
22
|
|
|
23
|
+
function pastedSingleLine(text: string): string {
|
|
24
|
+
return text.replace(/[\u0000-\u001f\u007f]/g, "");
|
|
25
|
+
}
|
|
26
|
+
|
|
23
27
|
export function filterLoginMethods(methods: readonly LoginMethod[], query: string): LoginMethod[] {
|
|
24
28
|
const terms = query.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean);
|
|
25
29
|
if (terms.length === 0) return [...methods];
|
|
@@ -254,6 +258,41 @@ export class LoginController {
|
|
|
254
258
|
return false;
|
|
255
259
|
}
|
|
256
260
|
|
|
261
|
+
acceptsTextPaste(): boolean {
|
|
262
|
+
return this.page.kind === "custom-endpoint" ||
|
|
263
|
+
this.page.kind === "custom-key" ||
|
|
264
|
+
(this.page.kind === "prompt" && this.page.prompt.type !== "select");
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
pasteText(text: string): boolean {
|
|
268
|
+
if (!this.acceptsTextPaste()) return false;
|
|
269
|
+
const pasted = pastedSingleLine(text);
|
|
270
|
+
if (!pasted) return true;
|
|
271
|
+
|
|
272
|
+
if (this.page.kind === "prompt") {
|
|
273
|
+
const current = this.page;
|
|
274
|
+
if (current.prompt.type === "secret") {
|
|
275
|
+
this.secret += pasted;
|
|
276
|
+
this.setPage({ ...current, secretLength: this.secret.length });
|
|
277
|
+
} else {
|
|
278
|
+
this.setPage({ ...current, value: current.value + pasted });
|
|
279
|
+
}
|
|
280
|
+
return true;
|
|
281
|
+
}
|
|
282
|
+
if (this.page.kind === "custom-endpoint") {
|
|
283
|
+
this.endpoint = this.page.endpoint + pasted;
|
|
284
|
+
this.setPage({ kind: "custom-endpoint", endpoint: this.endpoint });
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
287
|
+
if (this.page.kind === "custom-key") {
|
|
288
|
+
const current = this.page;
|
|
289
|
+
this.customKey += pasted;
|
|
290
|
+
this.setPage({ ...current, secretLength: this.customKey.length });
|
|
291
|
+
return true;
|
|
292
|
+
}
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
|
|
257
296
|
handleKey(key: LoginKey): boolean {
|
|
258
297
|
const enter = key.name === "return" || key.name === "enter" || key.name === "kpenter" || key.name === "linefeed";
|
|
259
298
|
if (key.name === "escape") {
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
const MAX_CLIPBOARD_TEXT_BYTES = 64 * 1024;
|
|
4
|
+
const WINDOWS_CLIPBOARD_TEXT_SCRIPT = [
|
|
5
|
+
"[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)",
|
|
6
|
+
"[Console]::Out.Write((Get-Clipboard -Raw))",
|
|
7
|
+
].join("; ");
|
|
8
|
+
|
|
9
|
+
type Environment = Record<string, string | undefined>;
|
|
10
|
+
type NativeClipboard = { getText(): Promise<string> };
|
|
11
|
+
type CommandRunner = (command: string, args: string[]) => Promise<string>;
|
|
12
|
+
|
|
13
|
+
export type ClipboardTextOptions = {
|
|
14
|
+
platform?: NodeJS.Platform;
|
|
15
|
+
env?: Environment;
|
|
16
|
+
nativeClipboard?: NativeClipboard | null;
|
|
17
|
+
runner?: CommandRunner;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function isRemoteSession(env: Environment): boolean {
|
|
21
|
+
return Boolean(env.SSH_CONNECTION || env.SSH_CLIENT || env.MOSH_CONNECTION);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function loadNativeClipboard(): Promise<NativeClipboard | null> {
|
|
25
|
+
try {
|
|
26
|
+
return await import("@mariozechner/clipboard");
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function runClipboardCommand(command: string, args: string[]): Promise<string> {
|
|
33
|
+
return new Promise((resolve, reject) => {
|
|
34
|
+
const child = spawn(command, args, {
|
|
35
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
36
|
+
windowsHide: true,
|
|
37
|
+
});
|
|
38
|
+
const chunks: Buffer[] = [];
|
|
39
|
+
let size = 0;
|
|
40
|
+
let settled = false;
|
|
41
|
+
const finish = (error?: Error) => {
|
|
42
|
+
if (settled) return;
|
|
43
|
+
settled = true;
|
|
44
|
+
clearTimeout(timer);
|
|
45
|
+
if (error) reject(error);
|
|
46
|
+
else resolve(Buffer.concat(chunks).toString("utf8"));
|
|
47
|
+
};
|
|
48
|
+
const timer = setTimeout(() => {
|
|
49
|
+
child.kill();
|
|
50
|
+
finish(new Error("Clipboard read timed out"));
|
|
51
|
+
}, 5000);
|
|
52
|
+
child.stdout.on("data", (chunk: Buffer) => {
|
|
53
|
+
size += chunk.length;
|
|
54
|
+
if (size > MAX_CLIPBOARD_TEXT_BYTES) {
|
|
55
|
+
child.kill();
|
|
56
|
+
finish(new Error("Clipboard text is too large"));
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
chunks.push(chunk);
|
|
60
|
+
});
|
|
61
|
+
child.on("error", () => finish(new Error("Clipboard command failed")));
|
|
62
|
+
child.on("close", (code) => finish(code === 0 ? undefined : new Error("Clipboard command failed")));
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function checkedText(text: string): string {
|
|
67
|
+
if (Buffer.byteLength(text, "utf8") > MAX_CLIPBOARD_TEXT_BYTES) {
|
|
68
|
+
throw new Error("Clipboard text is too large");
|
|
69
|
+
}
|
|
70
|
+
return text;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Read local graphical clipboard text without a shell or visible clipboard output. */
|
|
74
|
+
export async function readClipboardText(options: ClipboardTextOptions = {}): Promise<string> {
|
|
75
|
+
const platform = options.platform ?? process.platform;
|
|
76
|
+
const env = options.env ?? process.env;
|
|
77
|
+
if (isRemoteSession(env)) throw new Error("Clipboard text paste is unavailable in a remote session");
|
|
78
|
+
|
|
79
|
+
const clipboard = options.nativeClipboard === undefined
|
|
80
|
+
? await loadNativeClipboard()
|
|
81
|
+
: options.nativeClipboard;
|
|
82
|
+
if (clipboard) {
|
|
83
|
+
try {
|
|
84
|
+
return checkedText(await clipboard.getText());
|
|
85
|
+
} catch {
|
|
86
|
+
// Use a direct platform command when native clipboard access fails.
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const runner = options.runner ?? runClipboardCommand;
|
|
91
|
+
if (platform === "win32") {
|
|
92
|
+
return checkedText(await runner("powershell.exe", [
|
|
93
|
+
"-NoLogo",
|
|
94
|
+
"-NoProfile",
|
|
95
|
+
"-NonInteractive",
|
|
96
|
+
"-STA",
|
|
97
|
+
"-Command",
|
|
98
|
+
WINDOWS_CLIPBOARD_TEXT_SCRIPT,
|
|
99
|
+
]));
|
|
100
|
+
}
|
|
101
|
+
if (platform === "darwin") return checkedText(await runner("pbpaste", []));
|
|
102
|
+
if (platform === "linux" && env.WAYLAND_DISPLAY) {
|
|
103
|
+
return checkedText(await runner("wl-paste", ["--no-newline", "--type", "text"]));
|
|
104
|
+
}
|
|
105
|
+
if (platform === "linux" && env.DISPLAY) {
|
|
106
|
+
return checkedText(await runner("xclip", ["-selection", "clipboard", "-o"]));
|
|
107
|
+
}
|
|
108
|
+
throw new Error("No supported graphical clipboard is available");
|
|
109
|
+
}
|
package/src/transcript.tsx
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
StyledText,
|
|
3
|
+
TextAttributes,
|
|
4
|
+
fg,
|
|
5
|
+
type MarkdownRenderable,
|
|
6
|
+
type SyntaxStyle,
|
|
7
|
+
} from "@opentui/core";
|
|
2
8
|
import type { MarkdownProps } from "@opentui/react";
|
|
3
9
|
import {
|
|
4
10
|
useBlinkingText,
|
|
@@ -360,7 +366,10 @@ function rejectedDetail(theme: Theme, detail: string): StyledText {
|
|
|
360
366
|
return new StyledText([fg(theme.rejection)(detail)]);
|
|
361
367
|
}
|
|
362
368
|
return new StyledText([
|
|
363
|
-
|
|
369
|
+
{
|
|
370
|
+
...fg(theme.rejection)(CHECK_MODE_HARD_BLOCK_PREFIX),
|
|
371
|
+
attributes: TextAttributes.BOLD,
|
|
372
|
+
},
|
|
364
373
|
fg(theme.rejection)(detail.slice(CHECK_MODE_HARD_BLOCK_PREFIX.length)),
|
|
365
374
|
]);
|
|
366
375
|
}
|
|
@@ -398,11 +407,7 @@ export function ToolLine({
|
|
|
398
407
|
|
|
399
408
|
return (
|
|
400
409
|
<box style={{ flexDirection: "column", width: "100%" }}>
|
|
401
|
-
<Row
|
|
402
|
-
glyph={GUTTER}
|
|
403
|
-
glyphColor={toolColor}
|
|
404
|
-
background={rejected ? theme.rejectionBg : undefined}
|
|
405
|
-
>
|
|
410
|
+
<Row glyph={GUTTER} glyphColor={toolColor}>
|
|
406
411
|
<box style={{ flexDirection: "row", flexGrow: 1, flexShrink: 1, minWidth: 0 }}>
|
|
407
412
|
{prefix ? <text content={prefix} selectable style={{ flexShrink: 0 }} /> : null}
|
|
408
413
|
<text
|
|
@@ -430,7 +435,7 @@ export function ToolLine({
|
|
|
430
435
|
</box>
|
|
431
436
|
</Row>
|
|
432
437
|
{rejected && call.detail ? (
|
|
433
|
-
<Row glyph={GUTTER} glyphColor={theme.rejection}
|
|
438
|
+
<Row glyph={GUTTER} glyphColor={theme.rejection}>
|
|
434
439
|
<text
|
|
435
440
|
content={rejectedDetail(theme, call.detail)}
|
|
436
441
|
selectable
|