pi-robot-hand 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,269 @@
1
+ /**
2
+ * Masked secret entry dialog. Keeps the value in this component only and hands it to `done`.
3
+ * Tab switches to a visible question field, so the user can ask the agent instead of cancelling.
4
+ */
5
+
6
+ import type { Theme } from "@earendil-works/pi-coding-agent";
7
+ import {
8
+ type Component,
9
+ decodeKittyPrintable,
10
+ type Focusable,
11
+ getKeybindings,
12
+ isKeyRelease,
13
+ matchesKey,
14
+ type OverlayOptions,
15
+ truncateToWidth,
16
+ visibleWidth,
17
+ wrapTextWithAnsi,
18
+ } from "@earendil-works/pi-tui";
19
+
20
+ const PASTE_START = "\x1b[200~";
21
+ const PASTE_END = "\x1b[201~";
22
+ const MASK = "•";
23
+
24
+ export interface SecretPrompt {
25
+ title: string;
26
+ label: string;
27
+ /** How to get or set up the secret: where to go, what to choose, what to copy. */
28
+ steps: readonly string[];
29
+ /** The page where the user gets the secret. */
30
+ url?: string;
31
+ /** Labelled facts shown under the steps, e.g. where the value goes and what happens next. */
32
+ rows: ReadonlyArray<readonly [string, string]>;
33
+ /** Shown in the error color under the rows, e.g. an overwrite notice. */
34
+ warning?: string;
35
+ footer: string;
36
+ /** Return an error message to keep the dialog open, or undefined to accept. */
37
+ check: (value: string) => string | undefined;
38
+ /** Lines the dialog may take; the steps are shortened to fit. Default: 85% of the terminal. */
39
+ maxLines?: () => number;
40
+ }
41
+
42
+ /** What the dialog closes with: the secret, or a question the user asked instead. */
43
+ export type DialogResult = { kind: "secret"; value: string } | { kind: "question"; text: string };
44
+
45
+ /**
46
+ * `ctx.ui.custom` options for the dialog. Pi-TUI overlays cannot draw over rows holding terminal
47
+ * images, so `PI_ASK_USER_DISPLAY_MODE=inline` (shared with pi-ask-user) renders it inline instead.
48
+ */
49
+ export function dialogOptions(env: NodeJS.ProcessEnv = process.env): { overlay: boolean; overlayOptions?: OverlayOptions } {
50
+ if (env.PI_ASK_USER_DISPLAY_MODE === "inline") return { overlay: false };
51
+ return { overlay: true, overlayOptions: { anchor: "center", width: "70%", minWidth: 50, maxHeight: "85%", margin: 1 } };
52
+ }
53
+
54
+ function isPrintable(data: string): boolean {
55
+ return [...data].every((ch) => {
56
+ const code = ch.charCodeAt(0);
57
+ return !(code < 32 || code === 0x7f || (code >= 0x80 && code <= 0x9f));
58
+ });
59
+ }
60
+
61
+ export class SecretInput implements Component, Focusable {
62
+ focused = false;
63
+ private value = "";
64
+ private question = "";
65
+ private mode: "secret" | "question" = "secret";
66
+ private reveal = false;
67
+ private error: string | undefined;
68
+ private inPaste = false;
69
+ private pasteBuffer = "";
70
+ private closed = false;
71
+
72
+ private readonly prompt: SecretPrompt;
73
+ private readonly theme: Theme;
74
+ private readonly requestRender: () => void;
75
+ private readonly done: (result: DialogResult | null) => void;
76
+
77
+ constructor(prompt: SecretPrompt, theme: Theme, requestRender: () => void, done: (result: DialogResult | null) => void) {
78
+ this.prompt = prompt;
79
+ this.theme = theme;
80
+ this.requestRender = requestRender;
81
+ this.done = done;
82
+ }
83
+
84
+ /** Close the dialog without a value, for example when the tool call is aborted. */
85
+ cancel(): void {
86
+ this.finish(null);
87
+ }
88
+
89
+ private finish(result: DialogResult | null): void {
90
+ if (this.closed) return;
91
+ this.closed = true;
92
+ this.done(result);
93
+ this.value = "";
94
+ this.question = "";
95
+ this.pasteBuffer = "";
96
+ }
97
+
98
+ private append(text: string): void {
99
+ if (this.mode === "question") this.question += text.replace(/\s*\n\s*/g, " ");
100
+ else this.value += text;
101
+ this.error = undefined;
102
+ }
103
+
104
+ private submit(): void {
105
+ if (this.mode === "question") {
106
+ const text = this.question.trim();
107
+ if (!text) {
108
+ this.error = "Type your question first, or press Tab to go back to the secret.";
109
+ this.requestRender();
110
+ return;
111
+ }
112
+ this.finish({ kind: "question", text });
113
+ return;
114
+ }
115
+ const error = this.prompt.check(this.value);
116
+ if (error) {
117
+ this.error = error;
118
+ this.requestRender();
119
+ return;
120
+ }
121
+ this.finish({ kind: "secret", value: this.value });
122
+ }
123
+
124
+ handleInput(data: string): void {
125
+ if (this.closed) return;
126
+ if (data.includes(PASTE_START)) {
127
+ this.inPaste = true;
128
+ this.pasteBuffer = "";
129
+ data = data.replace(PASTE_START, "");
130
+ }
131
+ if (this.inPaste) {
132
+ this.pasteBuffer += data;
133
+ const end = this.pasteBuffer.indexOf(PASTE_END);
134
+ if (end === -1) return;
135
+ this.append(this.pasteBuffer.slice(0, end).replace(/\r\n?/g, "\n"));
136
+ const rest = this.pasteBuffer.slice(end + PASTE_END.length);
137
+ this.inPaste = false;
138
+ this.pasteBuffer = "";
139
+ if (rest) this.handleInput(rest);
140
+ this.requestRender();
141
+ return;
142
+ }
143
+ if (isKeyRelease(data)) return;
144
+
145
+ const kb = getKeybindings();
146
+ if (kb.matches(data, "tui.select.cancel")) {
147
+ this.finish(null);
148
+ return;
149
+ }
150
+ if (kb.matches(data, "tui.input.submit") || data === "\n") {
151
+ this.submit();
152
+ return;
153
+ }
154
+ const asking = this.mode === "question";
155
+ if (matchesKey(data, "tab") || matchesKey(data, "shift+tab")) {
156
+ this.mode = asking ? "secret" : "question";
157
+ this.error = undefined;
158
+ } else if (matchesKey(data, "ctrl+r")) {
159
+ if (!asking) this.reveal = !this.reveal;
160
+ } else if (kb.matches(data, "tui.editor.deleteToLineStart")) {
161
+ if (asking) this.question = "";
162
+ else this.value = "";
163
+ } else if (kb.matches(data, "tui.editor.deleteCharBackward")) {
164
+ if (asking) this.question = [...this.question].slice(0, -1).join("");
165
+ else this.value = [...this.value].slice(0, -1).join("");
166
+ } else {
167
+ const printable = decodeKittyPrintable(data) ?? (isPrintable(data) ? data : undefined);
168
+ if (printable === undefined) return;
169
+ this.append(printable);
170
+ }
171
+ this.requestRender();
172
+ }
173
+
174
+ invalidate(): void {}
175
+
176
+ private field(width: number): string {
177
+ const t = this.theme;
178
+ if (this.value.length === 0) return t.fg("dim", "type or paste the secret");
179
+ if (this.reveal) return truncateToWidth(t.fg("warning", this.value.replace(/\n/g, "⏎")), width, "…");
180
+ const count = [...this.value].length;
181
+ return t.fg("accent", MASK.repeat(Math.min(count, Math.max(1, width - 1)))) + (count > width - 1 ? "…" : "");
182
+ }
183
+
184
+ /** The numbered steps with hanging indents, then the link. */
185
+ private guide(inner: number): string[] {
186
+ const t = this.theme;
187
+ const lines: string[] = [];
188
+ if (this.prompt.steps.length > 0) {
189
+ lines.push(t.bold(t.fg("muted", "How to get it")));
190
+ const indent = `${this.prompt.steps.length}. `.length;
191
+ this.prompt.steps.forEach((step, index) => {
192
+ const marker = `${index + 1}.`.padEnd(indent);
193
+ wrapTextWithAnsi(step, Math.max(10, inner - indent)).forEach((line, i) =>
194
+ lines.push(`${i === 0 ? t.fg("accent", marker) : " ".repeat(indent)}${t.fg("text", line)}`),
195
+ );
196
+ });
197
+ }
198
+ if (this.prompt.url) lines.push(...wrapTextWithAnsi(`${t.fg("muted", "Open ")}${t.fg("accent", this.prompt.url)}`, inner));
199
+ return lines;
200
+ }
201
+
202
+ render(width: number): string[] {
203
+ const t = this.theme;
204
+ const inner = Math.max(10, width - 4);
205
+ const asking = this.mode === "question";
206
+ const wrapped = (text: string) => wrapTextWithAnsi(text, inner);
207
+
208
+ const head = wrapped(t.bold(t.fg("text", this.prompt.label)));
209
+ const facts: string[] = [];
210
+ const pad = Math.max(0, ...this.prompt.rows.map(([name]) => name.length)) + 2;
211
+ for (const [name, value] of this.prompt.rows)
212
+ wrapTextWithAnsi(value, Math.max(10, inner - pad)).forEach((line, i) =>
213
+ facts.push(`${i === 0 ? t.fg("muted", name.padEnd(pad)) : " ".repeat(pad)}${t.fg("text", line)}`),
214
+ );
215
+ if (this.prompt.warning) facts.push(...wrapped(t.bold(t.fg("error", `⚠ ${this.prompt.warning}`))));
216
+
217
+ const entry: string[] = [];
218
+ const secretMarker = asking ? t.fg("dim", " ") : t.fg("accent", "› ");
219
+ entry.push(`${secretMarker}${this.field(inner - 2)}`);
220
+ const chars = [...this.value].length;
221
+ const lines = this.value.length === 0 ? 0 : this.value.split("\n").length;
222
+ entry.push(t.fg("dim", ` ${chars} char${chars === 1 ? "" : "s"}${lines > 1 ? `, ${lines} lines` : ""}`));
223
+ if (asking) {
224
+ if (this.question)
225
+ wrapTextWithAnsi(t.fg("text", this.question), inner - 2).forEach((line, i) =>
226
+ entry.push(`${i === 0 ? t.fg("accent", "? ") : " "}${line}`),
227
+ );
228
+ else entry.push(`${t.fg("accent", "? ")}${t.fg("dim", "ask the agent instead; visible and sent to the chat")}`);
229
+ }
230
+ if (this.error) entry.push(...wrapped(t.fg("error", this.error)));
231
+
232
+ const help = wrapped(
233
+ t.fg(
234
+ "dim",
235
+ asking
236
+ ? "Enter send question (nothing is staged) · Tab back to the secret · Esc cancel · Ctrl+U clear"
237
+ : "Enter stage · Tab ask a question instead · Esc cancel · Ctrl+R show/hide · Ctrl+U clear",
238
+ ),
239
+ );
240
+ const footer = wrapped(t.fg("dim", this.prompt.footer));
241
+
242
+ // The overlay cuts off its bottom, where the input is, so long steps give way instead.
243
+ const fixed = head.length + facts.length + entry.length + help.length + footer.length + 4 + 2;
244
+ const budget = (this.prompt.maxLines ?? defaultMaxLines)() - fixed;
245
+ let guide = this.guide(inner);
246
+ if (guide.length > 0 && guide.length + 1 > budget) {
247
+ const keep = Math.max(0, budget - 2);
248
+ const hidden = guide.length - keep;
249
+ guide = [...guide.slice(0, keep), t.fg("warning", `… ${hidden} more line${hidden === 1 ? "" : "s"}; the full steps are in the chat above`)];
250
+ }
251
+
252
+ const body = [...head, "", ...(guide.length > 0 ? [...guide, ""] : []), ...facts, "", ...entry, "", ...help, ...footer];
253
+
254
+ const border = (s: string) => t.fg("borderAccent", s);
255
+ const title = ` ${this.prompt.title} `;
256
+ const top = border(`╭─${title}${"─".repeat(Math.max(0, inner - visibleWidth(title)))}─╮`);
257
+ const bottom = border(`╰${"─".repeat(inner + 2)}╯`);
258
+ const rows = body.map((line) => {
259
+ const fitted = truncateToWidth(line, inner, "…");
260
+ return `${border("│")} ${fitted}${" ".repeat(Math.max(0, inner - visibleWidth(fitted)))} ${border("│")}`;
261
+ });
262
+ return [top, ...rows, bottom];
263
+ }
264
+ }
265
+
266
+ /** 85% of the terminal's rows, as the overlay allows, less its margin. */
267
+ function defaultMaxLines(): number {
268
+ return Math.max(12, Math.floor((process.stdout.rows || 40) * 0.85) - 2);
269
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Staging area and the `!` apply command handed to the user.
3
+ */
4
+
5
+ import { randomBytes } from "node:crypto";
6
+ import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
7
+ import { homedir } from "node:os";
8
+ import { join, relative, sep } from "node:path";
9
+ import type { Placement } from "../../lib/placement.ts";
10
+ import { shellQuote } from "../../lib/shell.ts";
11
+
12
+ export type ApplyPlan =
13
+ | { format: "placement"; destination: string; placement: Placement; fileMode?: string; overwrite?: boolean }
14
+ | { format: "exec"; command: string };
15
+
16
+ export interface ApplyResult {
17
+ ok: boolean;
18
+ message: string;
19
+ }
20
+
21
+ /** Shortest unambiguous shell form of an absolute path: ./rel inside cwd, ~/rel under home. */
22
+ export function displayPath(absolute: string, cwd: string): string {
23
+ const inCwd = relative(cwd, absolute);
24
+ if (inCwd && !inCwd.startsWith("..") && !inCwd.startsWith(sep)) return shellQuote(`./${inCwd}`);
25
+ const home = homedir();
26
+ if (absolute.startsWith(`${home}${sep}`)) return `~/${shellQuote(absolute.slice(home.length + 1))}`;
27
+ return shellQuote(absolute);
28
+ }
29
+
30
+ /** The command line (without the leading `!`) that applies `staged` according to `plan`. */
31
+ export function buildApplyCommand(script: string, staged: string, plan: ApplyPlan, cwd: string): string {
32
+ const words = ["node", displayPath(script, cwd)];
33
+ if (plan.format === "exec") {
34
+ words.push("exec", shellQuote(plan.command));
35
+ } else {
36
+ const { placement, destination } = plan;
37
+ words.push(placement.mode, displayPath(destination, cwd));
38
+ if (placement.mode === "env") words.push(shellQuote(placement.key));
39
+ if (placement.mode === "regex") words.push(shellQuote(placement.pattern));
40
+ if (placement.mode === "env" && placement.quote !== "auto") words.push("--quote", placement.quote);
41
+ if (placement.mode === "regex" && placement.flags) words.push("--flags", shellQuote(placement.flags));
42
+ if (plan.overwrite) words.push("--overwrite");
43
+ if (plan.fileMode) words.push("--mode", shellQuote(plan.fileMode));
44
+ }
45
+ words.push("--from", displayPath(staged, cwd));
46
+ return words.join(" ");
47
+ }
48
+
49
+ /** A fresh staged-file path. The pid prefix lets other sessions tell live entries from stale ones. */
50
+ export function newStagedPath(dir: string): string {
51
+ return join(dir, `${process.pid}-${randomBytes(6).toString("hex")}`);
52
+ }
53
+
54
+ export async function stageSecret(path: string, value: string, dir: string): Promise<void> {
55
+ await mkdir(dir, { recursive: true, mode: 0o700 });
56
+ await writeFile(path, value, { mode: 0o600, flag: "wx" });
57
+ }
58
+
59
+ export async function discardStaged(path: string): Promise<void> {
60
+ await rm(path, { force: true });
61
+ await rm(`${path}.result`, { force: true });
62
+ }
63
+
64
+ function isAlive(pid: number): boolean {
65
+ try {
66
+ process.kill(pid, 0);
67
+ return true;
68
+ } catch (error) {
69
+ return (error as NodeJS.ErrnoException).code === "EPERM";
70
+ }
71
+ }
72
+
73
+ /** Remove staged files left by this process or by pi processes that no longer run. */
74
+ export async function clearStale(dir: string): Promise<void> {
75
+ let names: string[];
76
+ try {
77
+ names = await readdir(dir);
78
+ } catch {
79
+ return;
80
+ }
81
+ for (const name of names) {
82
+ const pid = Number.parseInt(name.split("-")[0] ?? "", 10);
83
+ if (Number.isNaN(pid) || pid === process.pid || !isAlive(pid)) await rm(join(dir, name), { force: true });
84
+ }
85
+ }
86
+
87
+ async function exists(path: string): Promise<boolean> {
88
+ try {
89
+ await readFile(path);
90
+ return true;
91
+ } catch {
92
+ return false;
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Wait until the apply script reports on `staged`. Resolves null when `signal` aborts.
98
+ */
99
+ export async function waitForResult(staged: string, signal: AbortSignal | undefined, pollMs = 250): Promise<ApplyResult | null> {
100
+ const resultFile = `${staged}.result`;
101
+ while (!signal?.aborted) {
102
+ try {
103
+ const parsed = JSON.parse(await readFile(resultFile, "utf8")) as ApplyResult;
104
+ return { ok: parsed.ok === true, message: String(parsed.message) };
105
+ } catch (error) {
106
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
107
+ }
108
+ if (!(await exists(staged)) && !(await exists(resultFile))) {
109
+ return { ok: false, message: "the staged secret disappeared before it was applied" };
110
+ }
111
+ await new Promise<void>((resolve) => {
112
+ const timer = setTimeout(resolve, pollMs);
113
+ signal?.addEventListener(
114
+ "abort",
115
+ () => {
116
+ clearTimeout(timer);
117
+ resolve();
118
+ },
119
+ { once: true },
120
+ );
121
+ });
122
+ }
123
+ return null;
124
+ }