pum-agent 0.2.4-beta.1 → 0.2.6-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 +5 -3
- package/package.json +1 -1
- package/src/app.tsx +32 -2
- package/src/check-policy.ts +117 -0
- package/src/login-controller.ts +39 -0
- package/src/status-bar.tsx +6 -0
- package/src/status-metadata.ts +19 -4
- package/src/text-paste.ts +109 -0
- package/src/theme.ts +11 -0
- package/src/transcript.tsx +13 -8
package/README.md
CHANGED
|
@@ -40,7 +40,7 @@ The following screens are real OpenTUI renders captured through `tmux`. A local
|
|
|
40
40
|
|
|
41
41
|
## Why PUM
|
|
42
42
|
|
|
43
|
-
- **Compact terminal UI:** Streaming Markdown, syntax highlighting, thinking traces, tool rows, usage, cost, and Git status.
|
|
43
|
+
- **Compact terminal UI:** Streaming Markdown, syntax highlighting, thinking traces, tool rows, usage, cost, launch directory, and Git status.
|
|
44
44
|
- **Full coding loop:** Built-in `read`, `write`, `edit`, `bash`, and atomic `apply_patch` tools.
|
|
45
45
|
- **Parallel subagents:** Persistent agents work in isolated Git worktrees, communicate durably, and report lifecycle transitions to their direct spawners.
|
|
46
46
|
- **Prompt control:** Steer active work, answer model questionnaires, use an ownership-aware message cache, attach clipboard images, and resume sessions with metadata-rich history.
|
|
@@ -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
|
|
@@ -221,10 +223,10 @@ Trigger events target one exact main or retained child session. A missing sessio
|
|
|
221
223
|
Select a Check mode profile in `Ctrl+P`. It applies to `bash`, `edit`, `apply_patch`, and external-trigger process execution:
|
|
222
224
|
|
|
223
225
|
- **Strict:** Run deterministic hard rules, then require a clear verifier approval.
|
|
224
|
-
- **Balanced:** Block deterministic hard-rule or suspicious findings. Allow ordinary complete project-local calls
|
|
226
|
+
- **Balanced:** Block deterministic hard-rule or suspicious findings. Allow ordinary complete project-local calls, explicit non-sensitive external reads, and narrowly validated lifecycle-disabled `npm pack` operations whose cache and output stay in approved roots. Verifier review is non-blocking unless the verifier returns explicit `UNSAFE`.
|
|
225
227
|
- **Ask:** Show the approval popup for every checked call that passes hard rules, unless an exact session or project approval already matches. A verifier `SAFE`, unclear, error, or unavailable result still requires approval.
|
|
226
228
|
|
|
227
|
-
Every active profile hard-blocks external writes, location changes, execution operands, ambiguous path access, escaping links, credential access, privilege escalation, persistence, remote-script execution, destructive Git operations, and broad deletion. Balanced permits
|
|
229
|
+
Every active profile hard-blocks external writes, location changes, execution operands, ambiguous path access, escaping links, credential access, privilege escalation, persistence, remote-script execution, destructive Git operations, and broad deletion. Balanced permits explicit, deterministically classified, non-sensitive external reads. It also accepts one direct `npm pack` only when lifecycle scripts are disabled, an explicit cache stays in an approved root, output stays in an approved root, and any package operand is one exact registry version; file, Git, URL, tag, range, composed, and global-install forms remain blocked. These hard blocks cannot be overridden and do not open the popup. An explicit verifier `UNSAFE` verdict also blocks without a popup. The only publication exception is a deterministic match for direct main-agent `npm publish` or `npm dist-tag add`. The verifier category does not control this exception. The exception still requires explicit popup approval. Managed subagents cannot use it.
|
|
228
230
|
|
|
229
231
|
Use `/check-path list`, `/check-path add <directory>`, `/check-path remove <directory>`, or `/check-path clear` to manage up to 16 additional directory roots for the current launch project. Bash, edit, and external-trigger checks can use these roots; `apply_patch` remains project-local. Added roots are canonicalized and remain subject to credential, traversal, symlink or junction, broad-deletion, and other hard blocks.
|
|
230
232
|
|
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
|
}
|
|
@@ -2280,6 +2309,7 @@ export function App({
|
|
|
2280
2309
|
theme={theme}
|
|
2281
2310
|
modelId={visibleModelId}
|
|
2282
2311
|
thinkingLevel={visibleThinkingLevel}
|
|
2312
|
+
cwd={cwd}
|
|
2283
2313
|
branch={visibleBranch}
|
|
2284
2314
|
outgoingTokens={visibleUsage.outgoing}
|
|
2285
2315
|
incomingTokens={visibleUsage.incoming}
|
package/src/check-policy.ts
CHANGED
|
@@ -17,6 +17,7 @@ export type CheckPolicyFindingCode =
|
|
|
17
17
|
| "external-read-exfiltration"
|
|
18
18
|
| "destructive-git"
|
|
19
19
|
| "broad-deletion"
|
|
20
|
+
| "unsafe-npm-pack"
|
|
20
21
|
| "suspicious-execution"
|
|
21
22
|
| "shell-complexity"
|
|
22
23
|
| "mutation"
|
|
@@ -209,6 +210,84 @@ const EXPLICIT_READ_COMMANDS = new Set([
|
|
|
209
210
|
"cat", "head", "tail", "less", "more", "stat", "file", "ls", "tree", "du", "wc", "realpath", "readlink",
|
|
210
211
|
]);
|
|
211
212
|
const DATA_OPERAND_COMMANDS = new Set(["printf", "echo"]);
|
|
213
|
+
|
|
214
|
+
type NpmPackCommand = {
|
|
215
|
+
valid: boolean;
|
|
216
|
+
reason?: string;
|
|
217
|
+
packageSpec?: string;
|
|
218
|
+
cache?: string;
|
|
219
|
+
packDestination: string;
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
function isExactRegistryPackageVersion(value: string): boolean {
|
|
223
|
+
const packageName = String.raw`(?:@[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*|[a-z0-9][a-z0-9._-]*)`;
|
|
224
|
+
const numericIdentifier = String.raw`(?:0|[1-9]\d*)`;
|
|
225
|
+
const prereleaseIdentifier = String.raw`(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)`;
|
|
226
|
+
const buildIdentifier = String.raw`[0-9a-zA-Z-]+`;
|
|
227
|
+
const version = String.raw`${numericIdentifier}\.${numericIdentifier}\.${numericIdentifier}(?:-${prereleaseIdentifier}(?:\.${prereleaseIdentifier})*)?(?:\+${buildIdentifier}(?:\.${buildIdentifier})*)?`;
|
|
228
|
+
return new RegExp(`^${packageName}@${version}$`).test(value);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function npmPackCommand(argv: string[]): NpmPackCommand | undefined {
|
|
232
|
+
if (commandName(argv[0]) !== "npm" || argv[1] !== "pack") return undefined;
|
|
233
|
+
const positionals: string[] = [];
|
|
234
|
+
let cache: string | undefined;
|
|
235
|
+
let packDestination = ".";
|
|
236
|
+
let packDestinationSet = false;
|
|
237
|
+
let ignoreScripts = false;
|
|
238
|
+
const booleanOptions = new Set(["--dry-run", "--json", "--ignore-scripts"]);
|
|
239
|
+
const pathOptions = new Map([["--cache", "cache"], ["--pack-destination", "packDestination"]] as const);
|
|
240
|
+
|
|
241
|
+
for (let index = 2; index < argv.length; index++) {
|
|
242
|
+
const value = argv[index]!;
|
|
243
|
+
if (booleanOptions.has(value)) {
|
|
244
|
+
if (value === "--ignore-scripts") ignoreScripts = true;
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
const separate = pathOptions.get(value as "--cache" | "--pack-destination");
|
|
248
|
+
if (separate) {
|
|
249
|
+
const path = argv[++index];
|
|
250
|
+
if (!path || path.startsWith("-")) return { valid: false, reason: `${value} requires one explicit path`, packDestination };
|
|
251
|
+
if (separate === "cache") {
|
|
252
|
+
if (cache !== undefined) return { valid: false, reason: "npm pack --cache must occur exactly once", packDestination };
|
|
253
|
+
cache = path;
|
|
254
|
+
} else {
|
|
255
|
+
if (packDestinationSet) return { valid: false, reason: "npm pack --pack-destination must occur at most once", packDestination };
|
|
256
|
+
packDestination = path;
|
|
257
|
+
packDestinationSet = true;
|
|
258
|
+
}
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
const attached = /^(--cache|--pack-destination)=(.*)$/.exec(value);
|
|
262
|
+
if (attached) {
|
|
263
|
+
if (!attached[2]) return { valid: false, reason: `${attached[1]} requires one explicit path`, packDestination };
|
|
264
|
+
if (attached[1] === "--cache") {
|
|
265
|
+
if (cache !== undefined) return { valid: false, reason: "npm pack --cache must occur exactly once", packDestination };
|
|
266
|
+
cache = attached[2];
|
|
267
|
+
} else {
|
|
268
|
+
if (packDestinationSet) return { valid: false, reason: "npm pack --pack-destination must occur at most once", packDestination };
|
|
269
|
+
packDestination = attached[2];
|
|
270
|
+
packDestinationSet = true;
|
|
271
|
+
}
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
if (value === "--") {
|
|
275
|
+
positionals.push(...argv.slice(index + 1));
|
|
276
|
+
break;
|
|
277
|
+
}
|
|
278
|
+
if (value.startsWith("-")) return { valid: false, reason: `npm pack option ${value} is not in the deterministic allowlist`, packDestination };
|
|
279
|
+
positionals.push(value);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (!ignoreScripts) return { valid: false, reason: "npm pack must disable lifecycle scripts with --ignore-scripts", packDestination };
|
|
283
|
+
if (!cache) return { valid: false, reason: "npm pack must set an explicit project-local --cache path", packDestination };
|
|
284
|
+
if (positionals.length > 1) return { valid: false, reason: "npm pack accepts at most one deterministic package spec", cache, packDestination };
|
|
285
|
+
if (positionals[0] && !isExactRegistryPackageVersion(positionals[0])) {
|
|
286
|
+
return { valid: false, reason: "npm pack package spec must be an exact registry package version", cache, packDestination };
|
|
287
|
+
}
|
|
288
|
+
return { valid: true, packageSpec: positionals[0], cache, packDestination };
|
|
289
|
+
}
|
|
290
|
+
|
|
212
291
|
function commandName(argv0: string | undefined): string {
|
|
213
292
|
if (!argv0) return "";
|
|
214
293
|
const basename = argv0.replaceAll("\\", "/").split("/").at(-1) ?? argv0;
|
|
@@ -352,6 +431,7 @@ function mutationIndicators(argv: string[], redirections: BashRedirection[]): st
|
|
|
352
431
|
if (name === "git" && argv[1] && !new Set(["status", "diff", "log", "show", "rev-parse", "ls-files", "branch"]).has(argv[1])) {
|
|
353
432
|
indicators.push("Git state change");
|
|
354
433
|
}
|
|
434
|
+
if (npmPackCommand(argv)?.valid) indicators.push("package archive or cache output");
|
|
355
435
|
return [...new Set(indicators)];
|
|
356
436
|
}
|
|
357
437
|
|
|
@@ -742,6 +822,12 @@ function classifyStageAccesses(stage: BashStage): ClassifiedAccess[] {
|
|
|
742
822
|
}
|
|
743
823
|
|
|
744
824
|
if (DATA_OPERAND_COMMANDS.has(name)) return accesses;
|
|
825
|
+
const npmPack = npmPackCommand(argv);
|
|
826
|
+
if (npmPack?.valid) {
|
|
827
|
+
accesses.push({ path: npmPack.cache!, mode: "write", source: "operand" });
|
|
828
|
+
accesses.push({ path: npmPack.packDestination, mode: "write", source: "operand" });
|
|
829
|
+
return accesses;
|
|
830
|
+
}
|
|
745
831
|
if (["cd", "chdir", "set-location"].includes(name)) {
|
|
746
832
|
return [...accesses, ...positionalOperands(argv).map((path) => ({ path, mode: "location" as const, source: "operand" as const }))];
|
|
747
833
|
}
|
|
@@ -930,6 +1016,34 @@ function inspectHardBlocks(
|
|
|
930
1016
|
const argv = effectiveArgv(stage.argv);
|
|
931
1017
|
const name = commandName(argv[0]);
|
|
932
1018
|
const lowerArgs = argv.map((arg) => arg.toLowerCase());
|
|
1019
|
+
const npmPack = npmPackCommand(argv);
|
|
1020
|
+
if (npmPack) {
|
|
1021
|
+
const direct = commandName(stage.argv[0]) === "npm"
|
|
1022
|
+
&& analysis.stages.length === 1
|
|
1023
|
+
&& analysis.operators.length === 0
|
|
1024
|
+
&& stage.substitutions.length === 0
|
|
1025
|
+
&& stage.redirections.length === 0
|
|
1026
|
+
&& Object.keys(stage.envAssignments).length === 0;
|
|
1027
|
+
if (!direct || !npmPack.valid) {
|
|
1028
|
+
addFinding(findings, {
|
|
1029
|
+
code: "unsafe-npm-pack",
|
|
1030
|
+
severity: "hard-block",
|
|
1031
|
+
message: !direct ? "npm pack must be one direct command without shell composition" : npmPack.reason!,
|
|
1032
|
+
stage: stage.index,
|
|
1033
|
+
});
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
const globalPackageWrite = (name === "npm" || name === "bun")
|
|
1037
|
+
&& lowerArgs.some((arg) => new Set(["install", "add", "i", "update", "upgrade", "remove", "uninstall", "link"]).has(arg))
|
|
1038
|
+
&& lowerArgs.some((arg) => arg === "-g" || arg === "--global");
|
|
1039
|
+
if (globalPackageWrite) {
|
|
1040
|
+
addFinding(findings, {
|
|
1041
|
+
code: "outside-project",
|
|
1042
|
+
severity: "hard-block",
|
|
1043
|
+
message: "global package installation writes outside the project and approved roots",
|
|
1044
|
+
stage: stage.index,
|
|
1045
|
+
});
|
|
1046
|
+
}
|
|
933
1047
|
if (PRIVILEGE_COMMANDS.has(name)
|
|
934
1048
|
|| ((name === "powershell" || name === "start-process") && lowerArgs.includes("runas"))) {
|
|
935
1049
|
addFinding(findings, { code: "privilege-escalation", severity: "hard-block", message: `${name} can escalate privileges`, stage: stage.index });
|
|
@@ -1191,6 +1305,9 @@ function stageNetworkCommand(stage: BashStage): string | undefined {
|
|
|
1191
1305
|
if (NETWORK_COMMANDS.has(name)) return name;
|
|
1192
1306
|
const subcommand = (argv[1] ?? "").toLowerCase();
|
|
1193
1307
|
if (name === "git" && new Set(["clone", "fetch", "pull", "push", "ls-remote"]).has(subcommand)) return `git ${subcommand}`;
|
|
1308
|
+
if (name === "npm" && subcommand === "pack") {
|
|
1309
|
+
return npmPackCommand(argv)?.packageSpec ? "npm pack" : undefined;
|
|
1310
|
+
}
|
|
1194
1311
|
if (["npm", "pnpm", "yarn"].includes(name)
|
|
1195
1312
|
&& new Set(["add", "audit", "install", "publish", "search", "update", "upgrade", "view", "info"]).has(subcommand)) {
|
|
1196
1313
|
return `${name} ${subcommand}`;
|
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") {
|
package/src/status-bar.tsx
CHANGED
|
@@ -15,6 +15,7 @@ export type StatusProps = {
|
|
|
15
15
|
theme: Theme;
|
|
16
16
|
modelId: string;
|
|
17
17
|
thinkingLevel: string;
|
|
18
|
+
cwd: string;
|
|
18
19
|
branch: string | null;
|
|
19
20
|
outgoingTokens: number;
|
|
20
21
|
incomingTokens: number;
|
|
@@ -65,6 +66,7 @@ type StatusBarLayoutInput = Pick<
|
|
|
65
66
|
StatusProps,
|
|
66
67
|
| "modelId"
|
|
67
68
|
| "thinkingLevel"
|
|
69
|
+
| "cwd"
|
|
68
70
|
| "branch"
|
|
69
71
|
| "outgoingTokens"
|
|
70
72
|
| "incomingTokens"
|
|
@@ -183,6 +185,10 @@ export function statusBarLayout(input: StatusBarLayoutInput): StatusBarLayout {
|
|
|
183
185
|
layout.trailingSpace = false;
|
|
184
186
|
measureLayout(input, layout);
|
|
185
187
|
}
|
|
188
|
+
if (layout.totalWidth > input.width) {
|
|
189
|
+
layout.metadata = layout.metadata.filter((item) => item.key !== "cwd");
|
|
190
|
+
measureLayout(input, layout);
|
|
191
|
+
}
|
|
186
192
|
if (layout.totalWidth > input.width) {
|
|
187
193
|
layout.showIdleAgents = false;
|
|
188
194
|
measureLayout(input, layout);
|
package/src/status-metadata.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { fg, type TextChunk } from "@opentui/core";
|
|
|
2
2
|
import type { Theme } from "./theme";
|
|
3
3
|
|
|
4
4
|
export type StatusMetadataValues = {
|
|
5
|
+
cwd?: string;
|
|
5
6
|
branch: string | null;
|
|
6
7
|
outgoingTokens: number;
|
|
7
8
|
incomingTokens: number;
|
|
@@ -11,9 +12,9 @@ export type StatusMetadataValues = {
|
|
|
11
12
|
};
|
|
12
13
|
|
|
13
14
|
export type StatusMetadataItem = {
|
|
14
|
-
key: "branch" | "outgoing" | "incoming" | "cacheRead" | "cost" | "context";
|
|
15
|
+
key: "cwd" | "branch" | "outgoing" | "incoming" | "cacheRead" | "cost" | "context";
|
|
15
16
|
text: string;
|
|
16
|
-
tone: "branch" | "dim" | "warn";
|
|
17
|
+
tone: "cwd" | "branch" | "dim" | "warn";
|
|
17
18
|
priority: number;
|
|
18
19
|
};
|
|
19
20
|
|
|
@@ -28,8 +29,20 @@ export const formatCost = (value: number): string =>
|
|
|
28
29
|
|
|
29
30
|
export const statusTextWidth = (text: string): number => Bun.stringWidth(text);
|
|
30
31
|
|
|
32
|
+
/** Show the launch directory without the long parent path. */
|
|
33
|
+
export function formatWorkingDirectory(cwd: string): string {
|
|
34
|
+
const withoutTrailingSeparators = cwd.replace(/[\\/]+$/, "");
|
|
35
|
+
if (!withoutTrailingSeparators) return "cwd /";
|
|
36
|
+
if (/^[A-Za-z]:$/.test(withoutTrailingSeparators)) return `cwd ${withoutTrailingSeparators}\\`;
|
|
37
|
+
const name = withoutTrailingSeparators.split(/[\\/]/).at(-1) || withoutTrailingSeparators;
|
|
38
|
+
return `cwd ${name}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
31
41
|
export function statusMetadataItems(values: StatusMetadataValues): StatusMetadataItem[] {
|
|
32
42
|
const items: StatusMetadataItem[] = [];
|
|
43
|
+
if (values.cwd) {
|
|
44
|
+
items.push({ key: "cwd", text: formatWorkingDirectory(values.cwd), tone: "cwd", priority: 85 });
|
|
45
|
+
}
|
|
33
46
|
if (values.branch) {
|
|
34
47
|
items.push({ key: "branch", text: values.branch, tone: "branch", priority: 90 });
|
|
35
48
|
}
|
|
@@ -101,8 +114,10 @@ export function statusMetadataChunks(
|
|
|
101
114
|
const chunks: TextChunk[] = [];
|
|
102
115
|
for (const item of items) {
|
|
103
116
|
if (chunks.length) chunks.push(fg(theme.dim)(" · "));
|
|
104
|
-
const color = item.tone === "
|
|
105
|
-
? theme.
|
|
117
|
+
const color = item.tone === "cwd"
|
|
118
|
+
? theme.statusCwd
|
|
119
|
+
: item.tone === "branch"
|
|
120
|
+
? theme.toolArg
|
|
106
121
|
: item.tone === "warn"
|
|
107
122
|
? theme.warn
|
|
108
123
|
: theme.dim;
|
|
@@ -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/theme.ts
CHANGED
|
@@ -9,6 +9,8 @@ export type Theme = {
|
|
|
9
9
|
fg: string;
|
|
10
10
|
dim: string;
|
|
11
11
|
accent: string;
|
|
12
|
+
/** Foreground for the current working directory in the status bar. */
|
|
13
|
+
statusCwd: string;
|
|
12
14
|
border: string;
|
|
13
15
|
/** Foreground and background of a user turn's full-width bar. */
|
|
14
16
|
user: string;
|
|
@@ -47,6 +49,7 @@ const tokyonight: Theme = {
|
|
|
47
49
|
fg: "#c0caf5",
|
|
48
50
|
dim: "#565f89",
|
|
49
51
|
accent: "#7aa2f7",
|
|
52
|
+
statusCwd: "#2ac3de",
|
|
50
53
|
border: "#292e42",
|
|
51
54
|
user: "#c0caf5",
|
|
52
55
|
userBg: "#283457",
|
|
@@ -79,6 +82,7 @@ const gruvbox: Theme = {
|
|
|
79
82
|
fg: "#ebdbb2",
|
|
80
83
|
dim: "#928374",
|
|
81
84
|
accent: "#83a598",
|
|
85
|
+
statusCwd: "#8ec07c",
|
|
82
86
|
border: "#3c3836",
|
|
83
87
|
user: "#ebdbb2",
|
|
84
88
|
userBg: "#3c3836",
|
|
@@ -111,6 +115,7 @@ const catppuccin: Theme = {
|
|
|
111
115
|
fg: "#cdd6f4",
|
|
112
116
|
dim: "#6c7086",
|
|
113
117
|
accent: "#89b4fa",
|
|
118
|
+
statusCwd: "#94e2d5",
|
|
114
119
|
border: "#313244",
|
|
115
120
|
user: "#cdd6f4",
|
|
116
121
|
userBg: "#313244",
|
|
@@ -143,6 +148,7 @@ const nord: Theme = {
|
|
|
143
148
|
fg: "#d8dee9",
|
|
144
149
|
dim: "#7b88a1",
|
|
145
150
|
accent: "#88c0d0",
|
|
151
|
+
statusCwd: "#8fbcbb",
|
|
146
152
|
border: "#3b4252",
|
|
147
153
|
user: "#eceff4",
|
|
148
154
|
userBg: "#434c5e",
|
|
@@ -175,6 +181,7 @@ const dracula: Theme = {
|
|
|
175
181
|
fg: "#f8f8f2",
|
|
176
182
|
dim: "#6272a4",
|
|
177
183
|
accent: "#bd93f9",
|
|
184
|
+
statusCwd: "#8be9fd",
|
|
178
185
|
border: "#44475a",
|
|
179
186
|
user: "#f8f8f2",
|
|
180
187
|
userBg: "#44475a",
|
|
@@ -207,6 +214,7 @@ const rosepine: Theme = {
|
|
|
207
214
|
fg: "#e0def4",
|
|
208
215
|
dim: "#6e6a86",
|
|
209
216
|
accent: "#c4a7e7",
|
|
217
|
+
statusCwd: "#9ccfd8",
|
|
210
218
|
border: "#26233a",
|
|
211
219
|
user: "#e0def4",
|
|
212
220
|
userBg: "#26233a",
|
|
@@ -239,6 +247,7 @@ const solarized: Theme = {
|
|
|
239
247
|
fg: "#839496",
|
|
240
248
|
dim: "#586e75",
|
|
241
249
|
accent: "#268bd2",
|
|
250
|
+
statusCwd: "#2aa198",
|
|
242
251
|
border: "#073642",
|
|
243
252
|
user: "#93a1a1",
|
|
244
253
|
userBg: "#073642",
|
|
@@ -271,6 +280,7 @@ const kanagawa: Theme = {
|
|
|
271
280
|
fg: "#dcd7ba",
|
|
272
281
|
dim: "#727169",
|
|
273
282
|
accent: "#7e9cd8",
|
|
283
|
+
statusCwd: "#7fb4ca",
|
|
274
284
|
border: "#2a2a37",
|
|
275
285
|
user: "#dcd7ba",
|
|
276
286
|
userBg: "#2d4f67",
|
|
@@ -303,6 +313,7 @@ const githubLight: Theme = {
|
|
|
303
313
|
fg: "#24292f",
|
|
304
314
|
dim: "#6e7781",
|
|
305
315
|
accent: "#0969da",
|
|
316
|
+
statusCwd: "#0a3069",
|
|
306
317
|
border: "#d0d7de",
|
|
307
318
|
user: "#24292f",
|
|
308
319
|
userBg: "#ddf4ff",
|
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
|